diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 4bc006cbe46..090054489c1 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -23,9 +23,16 @@ This file applies only to `packages/client` and its subdirectories. - When adding a new REST API endpoint, follow the existing pattern in `src/restapi/` and export from `src/index.ts`. - Include TypeScript types for all request and response shapes. - Write tests for new query/mutation factories. +- Tests should be end-to-end tests that connect to a real server, and not use mocks. ## Validation +First start the server for running acceptance tests. Run this in the repository root: + +```sh +make acceptance-backend-start +``` + ```sh pnpm --filter @plone/client test --run pnpm --filter @plone/client build diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 77d044730a2..f565d21478a 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -98,6 +98,12 @@ export default class PloneClient { querystringSearch = restapi.querystringSearch; + getRecycleBin = restapi.getRecycleBin; + getRecycleBinItem = restapi.getRecycleBinItem; + restoreRecycleBinItem = restapi.restoreRecycleBinItem; + purgeRecycleBinItem = restapi.purgeRecycleBinItem; + emptyRecycleBin = restapi.emptyRecycleBin; + getRegistry = restapi.getRegistry; getRegistryRecord = restapi.getRegistryRecord; updateRegistry = restapi.updateRegistry; diff --git a/packages/client/src/restapi/index.ts b/packages/client/src/restapi/index.ts index 9ffda1e3bde..750332ca088 100644 --- a/packages/client/src/restapi/index.ts +++ b/packages/client/src/restapi/index.ts @@ -72,6 +72,12 @@ export { getQuerystring } from './querystring/get'; export { querystringSearch } from './querystring-search/get'; +export { getRecycleBin } from './recyclebin/get'; +export { getRecycleBinItem } from './recyclebin/get_item'; +export { restoreRecycleBinItem } from './recyclebin/restore'; +export { purgeRecycleBinItem } from './recyclebin/purge'; +export { emptyRecycleBin } from './recyclebin/empty'; + export { getRegistry } from './registry/get_list'; export { getRegistryRecord } from './registry/get'; export { updateRegistry } from './registry/update'; diff --git a/packages/client/src/restapi/recyclebin/empty.ts b/packages/client/src/restapi/recyclebin/empty.ts new file mode 100644 index 00000000000..feb4de5358c --- /dev/null +++ b/packages/client/src/restapi/recyclebin/empty.ts @@ -0,0 +1,13 @@ +import { apiRequest, type ApiRequestParams } from '../../api'; +import type PloneClient from '../../client'; +import type { RequestResponse } from '../types'; + +export async function emptyRecycleBin( + this: PloneClient, +): Promise> { + const options: ApiRequestParams = { + config: this.config, + }; + + return apiRequest('delete', '/@recyclebin', options); +} diff --git a/packages/client/src/restapi/recyclebin/get.ts b/packages/client/src/restapi/recyclebin/get.ts new file mode 100644 index 00000000000..34bdb8b67c4 --- /dev/null +++ b/packages/client/src/restapi/recyclebin/get.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { apiRequest, type ApiRequestParams } from '../../api'; +import { recycleBinQuerySchema } from '../../validation/recyclebin'; +import type { GetRecycleBinResponse } from '@plone/types'; +import type PloneClient from '../../client'; +import type { RequestResponse } from '../types'; + +export const getRecycleBinArgsSchema = z.object({ + query: recycleBinQuerySchema, +}); + +export type GetRecycleBinArgs = z.infer; + +export async function getRecycleBin( + this: PloneClient, + { query = {} }: GetRecycleBinArgs = { query: {} }, +): Promise> { + const validatedArgs = getRecycleBinArgsSchema.parse({ + query, + }); + + const options: ApiRequestParams = { + config: this.config, + params: validatedArgs.query, + }; + + return apiRequest('get', '/@recyclebin', options); +} diff --git a/packages/client/src/restapi/recyclebin/get_item.ts b/packages/client/src/restapi/recyclebin/get_item.ts new file mode 100644 index 00000000000..72ef9d7b645 --- /dev/null +++ b/packages/client/src/restapi/recyclebin/get_item.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { apiRequest, type ApiRequestParams } from '../../api'; +import { + recycleBinIdSchema, + recycleBinQuerySchema, +} from '../../validation/recyclebin'; +import type { GetRecycleBinItemResponse } from '@plone/types'; +import type PloneClient from '../../client'; +import type { RequestResponse } from '../types'; + +export const getRecycleBinItemArgsSchema = z.object({ + id: recycleBinIdSchema, + query: recycleBinQuerySchema, +}); + +export type GetRecycleBinItemArgs = z.infer; + +export async function getRecycleBinItem( + this: PloneClient, + { id, query = {} }: GetRecycleBinItemArgs, +): Promise> { + const validatedArgs = getRecycleBinItemArgsSchema.parse({ + id, + query, + }); + + const options: ApiRequestParams = { + config: this.config, + params: validatedArgs.query, + }; + + return apiRequest('get', `/@recyclebin/${validatedArgs.id}`, options); +} diff --git a/packages/client/src/restapi/recyclebin/purge.ts b/packages/client/src/restapi/recyclebin/purge.ts new file mode 100644 index 00000000000..490e5ccd464 --- /dev/null +++ b/packages/client/src/restapi/recyclebin/purge.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { apiRequest, type ApiRequestParams } from '../../api'; +import { recycleBinIdSchema } from '../../validation/recyclebin'; +import type PloneClient from '../../client'; +import type { RequestResponse } from '../types'; + +export const purgeRecycleBinItemArgsSchema = z.object({ + id: recycleBinIdSchema, +}); + +export type PurgeRecycleBinItemArgs = z.infer< + typeof purgeRecycleBinItemArgsSchema +>; + +export async function purgeRecycleBinItem( + this: PloneClient, + { id }: PurgeRecycleBinItemArgs, +): Promise> { + const validatedArgs = purgeRecycleBinItemArgsSchema.parse({ + id, + }); + + const options: ApiRequestParams = { + config: this.config, + }; + + return apiRequest('delete', `/@recyclebin/${validatedArgs.id}`, options); +} diff --git a/packages/client/src/restapi/recyclebin/recyclebin.test.ts b/packages/client/src/restapi/recyclebin/recyclebin.test.ts new file mode 100644 index 00000000000..84589350001 --- /dev/null +++ b/packages/client/src/restapi/recyclebin/recyclebin.test.ts @@ -0,0 +1,144 @@ +import { setup, teardown, getUniqueEntityName } from '../../utils/test'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import PloneClient from '../../client'; + +const cli = PloneClient.initialize({ + apiPath: 'http://localhost:55001/plone', +}); + +await cli.login({ data: { login: 'admin', password: 'secret' } }); + +beforeEach(async () => { + await setup(); + await cli.updateRegistry({ + data: { + 'recyclebin-controlpanel.recycling_enabled': true, + }, + }); +}); + +afterEach(async () => { + await teardown(); +}); + +const createDeletedDocument = async (title: string) => { + const created = await cli.createContent({ + path: '/', + data: { + '@type': 'Document', + title, + }, + }); + + await cli.deleteContent({ path: `/${created.data.id}` }); + + const recycleBin = await cli.getRecycleBin({ + query: { title, sort_on: 'deletion_date', sort_order: 'descending' }, + }); + const deletedItem = recycleBin.data.items.find( + (item) => item.title === title, + ); + + expect(deletedItem).toBeDefined(); + + return deletedItem!; +}; + +describe('Recycle bin client', () => { + test('gets recycle bin items with query parameters', async () => { + const matchingTitle = getUniqueEntityName('Deleted recycle page'); + const otherTitle = getUniqueEntityName('Other recycle page'); + + await createDeletedDocument(matchingTitle); + await createDeletedDocument(otherTitle); + + const result = await cli.getRecycleBin({ + query: { + title: matchingTitle, + has_subitems: false, + sort_on: 'deletion_date', + sort_order: 'descending', + b_start: 0, + b_size: '10', + }, + }); + + expect(result.status).toBe(200); + expect(result.data.items_total).toBe(1); + expect(result.data.items[0].title).toBe(matchingTitle); + expect(result.data.items[0].has_children).toBe(false); + }); + + test('gets one recycle bin item', async () => { + const title = getUniqueEntityName('Deleted detail page'); + const deletedItem = await createDeletedDocument(title); + + const result = await cli.getRecycleBinItem({ + id: deletedItem.recycle_id, + query: { b_start: 0, b_size: 5 }, + }); + + expect(result.status).toBe(200); + expect(result.data.title).toBe(title); + expect(result.data.recycle_id).toBe(deletedItem.recycle_id); + expect(result.data.items_total).toBe(0); + }); + + test('restores a recycle bin item', async () => { + const title = getUniqueEntityName('Deleted restore page'); + const deletedItem = await createDeletedDocument(title); + + const result = await cli.restoreRecycleBinItem({ + id: deletedItem.recycle_id, + data: {}, + }); + + expect(result.status).toBe(200); + expect(result.data.status).toBe('success'); + expect(result.data.restored_item.title).toBe(title); + + const restored = await cli.getContent({ path: `/${deletedItem.id}` }); + expect(restored.data.title).toBe(title); + }); + + test('purges a recycle bin item', async () => { + const title = getUniqueEntityName('Deleted purge page'); + const deletedItem = await createDeletedDocument(title); + + const result = await cli.purgeRecycleBinItem({ + id: deletedItem.recycle_id, + }); + + expect(result.status).toBe(204); + + const recycleBin = await cli.getRecycleBin({ query: { title } }); + expect(recycleBin.data.items_total).toBe(0); + }); + + test('empties the recycle bin', async () => { + const firstTitle = getUniqueEntityName('Deleted empty first page'); + const secondTitle = getUniqueEntityName('Deleted empty second page'); + + await createDeletedDocument(firstTitle); + await createDeletedDocument(secondTitle); + + const result = await cli.emptyRecycleBin(); + + expect(result.status).toBe(204); + + const recycleBin = await cli.getRecycleBin({ query: {} }); + expect(recycleBin.data.items_total).toBe(0); + }); + + test('validates ids before making a request', async () => { + await expect(cli.purgeRecycleBinItem({ id: '' })).rejects.toThrow(); + }); + + test('validates supported sort values before making a request', async () => { + await expect( + cli.getRecycleBin({ + query: { sort_on: 'size' as 'title' }, + }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/client/src/restapi/recyclebin/restore.ts b/packages/client/src/restapi/recyclebin/restore.ts new file mode 100644 index 00000000000..5dcba8e2931 --- /dev/null +++ b/packages/client/src/restapi/recyclebin/restore.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { apiRequest, type ApiRequestParams } from '../../api'; +import { + recycleBinIdSchema, + restoreRecycleBinItemDataSchema, +} from '../../validation/recyclebin'; +import type { RestoreRecycleBinItemResponse } from '@plone/types'; +import type PloneClient from '../../client'; +import type { RequestResponse } from '../types'; + +export const restoreRecycleBinItemArgsSchema = z.object({ + id: recycleBinIdSchema, + data: restoreRecycleBinItemDataSchema, +}); + +export type RestoreRecycleBinItemArgs = z.infer< + typeof restoreRecycleBinItemArgsSchema +>; + +export async function restoreRecycleBinItem( + this: PloneClient, + { id, data = {} }: RestoreRecycleBinItemArgs, +): Promise> { + const validatedArgs = restoreRecycleBinItemArgsSchema.parse({ + id, + data, + }); + + const options: ApiRequestParams = { + config: this.config, + data: validatedArgs.data, + }; + + return apiRequest( + 'post', + `/@recyclebin/${validatedArgs.id}/restore`, + options, + ); +} diff --git a/packages/client/src/validation/recyclebin.ts b/packages/client/src/validation/recyclebin.ts new file mode 100644 index 00000000000..2e2b752fa99 --- /dev/null +++ b/packages/client/src/validation/recyclebin.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +const numberOrNumericString = z.union([z.number(), z.string().regex(/^\d+$/)]); + +export const recycleBinQuerySchema = z + .object({ + title: z.string().optional(), + path: z.string().optional(), + portal_type: z.string().optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + deleted_by: z.string().optional(), + has_subitems: z.boolean().optional(), + language: z.string().optional(), + review_state: z.string().optional(), + sort_on: z + .enum(['title', 'portal_type', 'path', 'deletion_date', 'review_state']) + .optional(), + sort_order: z.enum(['ascending', 'descending']).optional(), + b_start: numberOrNumericString.optional(), + b_size: numberOrNumericString.optional(), + }) + .optional() + .default({}); + +export const recycleBinIdSchema = z.string().min(1); + +export const restoreRecycleBinItemDataSchema = z + .object({ + target_path: z.string().optional(), + restore_id: z.string().optional(), + }) + .optional() + .default({}); diff --git a/packages/cmsui/components/ControlPanel/ControlPanelsList.test.tsx b/packages/cmsui/components/ControlPanel/ControlPanelsList.test.tsx index 01ed902e855..a2006d28f80 100644 --- a/packages/cmsui/components/ControlPanel/ControlPanelsList.test.tsx +++ b/packages/cmsui/components/ControlPanel/ControlPanelsList.test.tsx @@ -51,4 +51,12 @@ describe('ControlPanelsList', () => { expect(link).toHaveAttribute('href', panel.href); }); }); + + it('renders Seven control panels with custom hrefs', () => { + render(); + + expect( + screen.getByRole('link', { name: 'cmsui.paneltitles.recycleBin' }), + ).toHaveAttribute('href', '/@@recyclebin'); + }); }); diff --git a/packages/cmsui/components/ControlPanel/ControlPanelsList.tsx b/packages/cmsui/components/ControlPanel/ControlPanelsList.tsx index 80d8f6f3932..bff1087266a 100644 --- a/packages/cmsui/components/ControlPanel/ControlPanelsList.tsx +++ b/packages/cmsui/components/ControlPanel/ControlPanelsList.tsx @@ -4,6 +4,7 @@ import config from '@plone/registry'; interface ControlPanel { '@id': string; + href?: string; title: string; group: string; id?: string; @@ -54,6 +55,12 @@ export const ControlPanelsList = ({ group: t('cmsui.panelgroups.content'), title: t('cmsui.paneltitles.moderatecomments'), }, + { + '@id': '/@@recyclebin', + href: '/@@recyclebin', + group: t('cmsui.panelgroups.maintenance'), + title: t('cmsui.paneltitles.recycleBin'), + }, { '@id': '/users', group: t('cmsui.panelgroups.users'), @@ -107,7 +114,7 @@ export const ControlPanelsList = ({ .map((panel) => (
  • {panel.title} diff --git a/packages/cmsui/components/RecycleBin/RecycleBin.test.tsx b/packages/cmsui/components/RecycleBin/RecycleBin.test.tsx new file mode 100644 index 00000000000..6e970039250 --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBin.test.tsx @@ -0,0 +1,205 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { describe, expect, it, vi } from 'vitest'; +import type React from 'react'; +import type { + GetRecycleBinItemResponse, + GetRecycleBinResponse, +} from '@plone/types'; +import { RecycleBinListing } from './RecycleBinListing'; +import { RecycleBinItemDetails } from './RecycleBinItemDetails'; +import { RecycleBinActiveFilters } from './RecycleBinActiveFilters'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === 'cmsui.recyclebin.pagination.range') { + return `Showing ${options?.start}-${options?.end} of ${options?.total}`; + } + if (key === 'cmsui.recyclebin.table.selectItem') { + return `Select ${options?.title}`; + } + if (key === 'cmsui.recyclebin.children.targetPath') { + return `Target path for ${options?.title}`; + } + return key; + }, + i18n: { language: 'en' }, + }), +})); + +const recycleBin: GetRecycleBinResponse = { + '@id': '/@recyclebin', + items_total: 1, + items: [ + { + '@id': '/@recyclebin/deleted-page', + '@type': 'Document', + id: 'deleted-page', + title: 'Deleted page', + path: '/Plone/deleted-page', + parent_path: '/Plone', + deletion_date: '2026-05-20T10:30:00+00:00', + recycle_id: 'deleted-page', + deleted_by: 'admin', + language: 'en', + review_state: 'private', + has_children: false, + actions: { + restore: '/@recyclebin/deleted-page/restore', + purge: '/@recyclebin/deleted-page', + }, + }, + ], +}; + +const recycleBinItem: GetRecycleBinItemResponse = { + ...recycleBin.items[0], + items_total: 1, + items: [ + { + id: 'child-page', + title: 'Child page', + '@type': 'Document', + path: '/Plone/deleted-page/child-page', + restore_id: 'child-page', + language: 'en', + review_state: 'private', + }, + ], +}; + +const renderWithRouter = (element: React.ReactNode) => { + const router = createMemoryRouter([{ path: '*', element }]); + return render(); +}; + +describe('RecycleBin components', () => { + it('renders listing rows and detail links', () => { + renderWithRouter( + , + ); + + const link = screen.getByRole('link', { name: 'Deleted page' }); + expect(link).toHaveAttribute('href', '/@@recyclebin/deleted-page'); + expect(screen.getByText('/Plone/deleted-page')).toBeInTheDocument(); + }); + + it('only renders pagination controls when batching links are present', () => { + renderWithRouter( + , + ); + + expect( + screen.queryByRole('link', { + name: 'cmsui.recyclebin.pagination.next', + }), + ).not.toBeInTheDocument(); + + renderWithRouter( + , + ); + + expect( + screen.getByRole('link', { + name: 'cmsui.recyclebin.pagination.next', + }), + ).toHaveAttribute('href', '/@@recyclebin?b_start=25&b_size=25'); + }); + + it('renders empty state when there are no items', () => { + renderWithRouter( + , + ); + + expect( + screen.getByText('cmsui.recyclebin.empty.noItems'), + ).toBeInTheDocument(); + }); + + it('renders active filters and clear links', () => { + renderWithRouter( + , + ); + + const searchFilter = screen.getByText(/page/); + expect(searchFilter).toHaveAttribute( + 'href', + '/@@recyclebin?portal_type=Document&sort_by=title_asc&b_size=25', + ); + expect(searchFilter).toHaveTextContent('x'); + expect(screen.queryByText(/title_asc/)).not.toBeInTheDocument(); + expect( + screen.getByText('cmsui.recyclebin.filters.clearAll'), + ).toHaveAttribute('href', '/@@recyclebin'); + }); + + it('enables selected-item actions after a selection', () => { + renderWithRouter( + , + ); + + const restoreButton = screen.getByRole('button', { + name: 'cmsui.recyclebin.actions.restoreSelected', + }); + expect(restoreButton).toBeDisabled(); + + fireEvent.click(screen.getByLabelText('Select Deleted page')); + expect(restoreButton).not.toBeDisabled(); + }); + + it('renders item metadata', () => { + renderWithRouter(); + + expect(screen.getByText('Deleted page')).toBeInTheDocument(); + expect(screen.getAllByText('/Plone/deleted-page')[0]).toBeInTheDocument(); + expect( + screen.queryByText('cmsui.recyclebin.details.originalId'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText('cmsui.recyclebin.details.parentPath'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText('cmsui.recyclebin.details.containedItems'), + ).not.toBeInTheDocument(); + expect( + screen.getByText('cmsui.recyclebin.children.title (1)'), + ).toBeInTheDocument(); + expect(screen.getByText('admin')).toBeInTheDocument(); + }); + + it('prefills the restore target path with the original parent path', () => { + const { container } = renderWithRouter( + , + ); + + expect( + container.querySelector('input[name="target_path"]') + ?.value, + ).toBe('/Plone'); + }); + + it('requires a target path for child restore inputs', () => { + renderWithRouter(); + + const targetPath = screen.getByLabelText('Target path for Child page'); + expect(targetPath).toBeRequired(); + expect(targetPath).toHaveValue(''); + }); +}); diff --git a/packages/cmsui/components/RecycleBin/RecycleBinActions.tsx b/packages/cmsui/components/RecycleBin/RecycleBinActions.tsx new file mode 100644 index 00000000000..7ea197eb86a --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinActions.tsx @@ -0,0 +1,78 @@ +import { Form } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@plone/components/quanta'; + +export function RecycleBinActions({ + selectedItems, + disabled, +}: { + selectedItems: string[]; + disabled?: boolean; +}) { + const { t } = useTranslation(); + const noSelection = selectedItems.length === 0 || disabled; + + return ( +
    +
    + {selectedItems.map((id) => ( + + ))} + +
    +
    { + if ( + // eslint-disable-next-line no-alert + !window.confirm(t('cmsui.recyclebin.confirmations.deleteSelected')) + ) { + event.preventDefault(); + } + }} + > + {selectedItems.map((id) => ( + + ))} + +
    +
    { + // eslint-disable-next-line no-alert + if (!window.confirm(t('cmsui.recyclebin.confirmations.empty'))) { + event.preventDefault(); + } + }} + > + +
    +
    + ); +} diff --git a/packages/cmsui/components/RecycleBin/RecycleBinActiveFilters.tsx b/packages/cmsui/components/RecycleBin/RecycleBinActiveFilters.tsx new file mode 100644 index 00000000000..f60be22e796 --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinActiveFilters.tsx @@ -0,0 +1,58 @@ +import { Link } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { + clearFiltersHref, + removeQueryParamHref, + type RecycleBinQueryState, +} from './utils'; + +const activeFilterLabels: Record = { + title: 'cmsui.recyclebin.filters.search', + portal_type: 'cmsui.recyclebin.filters.type', + deleted_by: 'cmsui.recyclebin.filters.deletedBy', + has_subitems: 'cmsui.recyclebin.filters.hasSubitems', + language: 'cmsui.recyclebin.filters.language', + review_state: 'cmsui.recyclebin.filters.workflowState', + date_from: 'cmsui.recyclebin.filters.dateFrom', + date_to: 'cmsui.recyclebin.filters.dateTo', + sort_by: 'cmsui.recyclebin.sort.label', + b_start: 'cmsui.recyclebin.pagination.start', + b_size: 'cmsui.recyclebin.pagination.pageSize', +}; + +export function RecycleBinActiveFilters({ + queryState, +}: { + queryState: RecycleBinQueryState; +}) { + const { t } = useTranslation(); + const activeFilters = Object.entries(queryState).filter( + ([key, value]) => + value && key !== 'b_start' && key !== 'b_size' && key !== 'sort_by', + ) as Array<[keyof RecycleBinQueryState, string]>; + + if (activeFilters.length === 0) return null; + + return ( +
    + {activeFilters.map(([key, value]) => ( + + {t(activeFilterLabels[key])}: {value} + + + ))} + + {t('cmsui.recyclebin.filters.clearAll')} + +
    + ); +} diff --git a/packages/cmsui/components/RecycleBin/RecycleBinChildrenTable.tsx b/packages/cmsui/components/RecycleBin/RecycleBinChildrenTable.tsx new file mode 100644 index 00000000000..53f990e3f9a --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinChildrenTable.tsx @@ -0,0 +1,85 @@ +import { Form } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@plone/components/quanta'; +import type { RecycleBinChildItem } from '@plone/types'; + +export function RecycleBinChildrenTable({ + childrenItems, + itemsTotal, +}: { + childrenItems: RecycleBinChildItem[]; + itemsTotal: number; +}) { + const { t } = useTranslation(); + + if (childrenItems.length === 0) return null; + + return ( +
    +

    + {t('cmsui.recyclebin.children.title')} ({itemsTotal}) +

    +
    + + + + + + + + + + + + + {childrenItems.map((item) => ( + + + + + + + + + ))} + +
    {t('cmsui.recyclebin.table.title')}{t('cmsui.recyclebin.table.type')} + {t('cmsui.recyclebin.table.workflowState')} + {t('cmsui.recyclebin.table.path')} + {t('cmsui.recyclebin.children.childrenCount')} + + {t('cmsui.recyclebin.children.restoreTo')} +
    {item.title || item.id}{item['@type']}{item.review_state} + {item.path} + {item.children_count ?? ''} +
    + + + +
    +
    +
    +
    + ); +} diff --git a/packages/cmsui/components/RecycleBin/RecycleBinFilters.tsx b/packages/cmsui/components/RecycleBin/RecycleBinFilters.tsx new file mode 100644 index 00000000000..99a2c059289 --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinFilters.tsx @@ -0,0 +1,202 @@ +import { Form } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@plone/components/quanta'; +import type { RecycleBinItemSummary } from '@plone/types'; +import { + getFilterOptions, + type RecycleBinQueryState, + type RecycleBinSortBy, +} from './utils'; + +const sortOptions: RecycleBinSortBy[] = [ + 'date_desc', + 'date_asc', + 'title_asc', + 'title_desc', + 'type_asc', + 'type_desc', + 'workflow_asc', + 'workflow_desc', +]; + +export function RecycleBinFilters({ + items, + queryState, +}: { + items: RecycleBinItemSummary[]; + queryState: RecycleBinQueryState; +}) { + const { t } = useTranslation(); + const types = getFilterOptions(items, '@type'); + const users = getFilterOptions(items, 'deleted_by'); + const languages = getFilterOptions(items, 'language'); + const workflowStates = getFilterOptions(items, 'review_state'); + const hasAdvancedFilters = Boolean( + queryState.portal_type || + queryState.deleted_by || + queryState.has_subitems || + queryState.language || + queryState.review_state || + queryState.date_from || + queryState.date_to, + ); + + return ( +
    + + + +
    + + {t('cmsui.recyclebin.filters.advanced')} + +
    + + + + + + + +
    +
    +
    + +
    +
    + ); +} diff --git a/packages/cmsui/components/RecycleBin/RecycleBinItemDetails.stories.tsx b/packages/cmsui/components/RecycleBin/RecycleBinItemDetails.stories.tsx new file mode 100644 index 00000000000..0e0e16dcfb3 --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinItemDetails.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import type { GetRecycleBinItemResponse } from '@plone/types'; +import { RecycleBinItemDetails } from './RecycleBinItemDetails'; + +const item: GetRecycleBinItemResponse = { + '@id': '/@recyclebin/folder-1', + '@type': 'Folder', + id: 'folder-1', + title: 'Archived section', + path: '/Plone/archived-section', + parent_path: '/Plone', + deletion_date: '2026-05-18T08:15:00+00:00', + recycle_id: 'folder-1', + deleted_by: 'admin', + language: 'en', + review_state: 'private', + has_children: true, + actions: { + restore: '/@recyclebin/folder-1/restore', + purge: '/@recyclebin/folder-1', + }, + items_total: 2, + items: [ + { + id: 'child-page', + title: 'Child page', + '@type': 'Document', + path: '/Plone/archived-section/child-page', + restore_id: 'child-page', + language: 'en', + review_state: 'private', + }, + { + id: 'child-folder', + title: 'Child folder', + '@type': 'Folder', + path: '/Plone/archived-section/child-folder', + restore_id: 'child-folder', + language: 'en', + review_state: 'published', + children_count: 3, + }, + ], +}; + +const meta = { + title: 'CMS UI/RecycleBin/ItemDetails', + component: RecycleBinItemDetails, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const FolderWithChildren: Story = { + args: { + item, + }, + render: (args) => { + const router = createMemoryRouter([ + { + path: '*', + element: , + }, + ]); + return ; + }, +}; diff --git a/packages/cmsui/components/RecycleBin/RecycleBinItemDetails.tsx b/packages/cmsui/components/RecycleBin/RecycleBinItemDetails.tsx new file mode 100644 index 00000000000..f316a996d54 --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinItemDetails.tsx @@ -0,0 +1,72 @@ +import { Link } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import type { GetRecycleBinItemResponse } from '@plone/types'; +import { RecycleBinChildrenTable } from './RecycleBinChildrenTable'; +import { RecycleBinRestorePanel } from './RecycleBinRestorePanel'; +import { formatRecycleBinDate } from './utils'; + +export function RecycleBinItemDetails({ + item, + actionMessage, +}: { + item: GetRecycleBinItemResponse; + actionMessage?: string; +}) { + const { t, i18n } = useTranslation(); + + return ( +
    + + {t('cmsui.recyclebin.actions.backToListing')} + +
    +

    {item.title || item.id}

    + + {item['@type']} + +
    +
    +
    +
    + {t('cmsui.recyclebin.details.path')} +
    +
    {item.path}
    +
    +
    +
    + {t('cmsui.recyclebin.details.workflowState')} +
    +
    {item.review_state}
    +
    +
    +
    + {t('cmsui.recyclebin.details.deletionDate')} +
    +
    {formatRecycleBinDate(item.deletion_date, i18n.language)}
    +
    +
    +
    + {t('cmsui.recyclebin.details.deletedBy')} +
    +
    {item.deleted_by}
    +
    +
    + + +
    + ); +} diff --git a/packages/cmsui/components/RecycleBin/RecycleBinListing.stories.tsx b/packages/cmsui/components/RecycleBin/RecycleBinListing.stories.tsx new file mode 100644 index 00000000000..133165e0f67 --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinListing.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import type React from 'react'; +import type { GetRecycleBinResponse } from '@plone/types'; +import { RecycleBinListing } from './RecycleBinListing'; + +const items: GetRecycleBinResponse['items'] = [ + { + '@id': '/@recyclebin/document-1', + '@type': 'Document', + id: 'document-1', + title: 'Retired page', + path: '/Plone/retired-page', + parent_path: '/Plone', + deletion_date: '2026-05-20T10:30:00+00:00', + recycle_id: 'document-1', + deleted_by: 'admin', + language: 'en', + review_state: 'private', + has_children: false, + actions: { + restore: '/@recyclebin/document-1/restore', + purge: '/@recyclebin/document-1', + }, + }, + { + '@id': '/@recyclebin/folder-1', + '@type': 'Folder', + id: 'folder-1', + title: 'Archived section', + path: '/Plone/archived-section', + parent_path: '/Plone', + deletion_date: '2026-05-18T08:15:00+00:00', + recycle_id: 'folder-1', + deleted_by: 'editor', + language: 'en', + review_state: 'published', + has_children: true, + actions: { + restore: '/@recyclebin/folder-1/restore', + purge: '/@recyclebin/folder-1', + }, + }, +]; + +const renderWithRouter = ( + args: React.ComponentProps, +) => { + const router = createMemoryRouter([ + { + path: '*', + element: , + }, + ]); + return ; +}; + +const meta = { + title: 'CMS UI/RecycleBin/Listing', + component: RecycleBinListing, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WithItems: Story = { + args: { + recycleBin: { + '@id': '/@recyclebin', + items_total: items.length, + items, + } as GetRecycleBinResponse, + queryState: {}, + }, + render: (args) => renderWithRouter(args), +}; + +export const Empty: Story = { + args: { + recycleBin: { + '@id': '/@recyclebin', + items_total: 0, + items: [], + }, + queryState: {}, + }, + render: (args) => renderWithRouter(args), +}; diff --git a/packages/cmsui/components/RecycleBin/RecycleBinListing.tsx b/packages/cmsui/components/RecycleBin/RecycleBinListing.tsx new file mode 100644 index 00000000000..19cb7d9f69b --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinListing.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import type { GetRecycleBinResponse } from '@plone/types'; +import { RecycleBinActions } from './RecycleBinActions'; +import { RecycleBinActiveFilters } from './RecycleBinActiveFilters'; +import { RecycleBinFilters } from './RecycleBinFilters'; +import { RecycleBinTable } from './RecycleBinTable'; +import { getPagination, type RecycleBinQueryState } from './utils'; + +export type RecycleBinActionData = { + message?: string; + failures?: Array<{ id: string; message: string }>; +}; + +export function RecycleBinListing({ + recycleBin, + queryState, + actionData, +}: { + recycleBin: GetRecycleBinResponse; + queryState: RecycleBinQueryState; + actionData?: RecycleBinActionData; +}) { + const { t } = useTranslation(); + const [selectedItems, setSelectedItems] = useState([]); + const { start, end, size, previousStart, nextStart, hasPrevious, hasNext } = + getPagination( + recycleBin.items_total, + queryState.b_start, + queryState.b_size, + ); + const hasActiveFilters = Object.entries(queryState).some( + ([key, value]) => + value && key !== 'b_start' && key !== 'b_size' && key !== 'sort_by', + ); + const makePageHref = (bStart: number) => { + const searchParams = new URLSearchParams(); + Object.entries(queryState).forEach(([key, value]) => { + if (value && key !== 'b_start') searchParams.set(key, value); + }); + searchParams.set('b_start', String(bStart)); + searchParams.set('b_size', String(size)); + return `/@@recyclebin?${searchParams.toString()}`; + }; + const previousHref = + recycleBin.batching?.prev ?? + (hasPrevious ? makePageHref(previousStart) : undefined); + const nextHref = + recycleBin.batching?.next ?? + (hasNext ? makePageHref(nextStart) : undefined); + + return ( +
    +
    +

    {t('cmsui.recyclebin.title')}

    +

    + {t('cmsui.recyclebin.description')} +

    +
    + {actionData?.message ? ( +
    +

    {actionData.message}

    + {actionData.failures?.length ? ( +
      + {actionData.failures.map((failure) => ( +
    • + {failure.id}: {failure.message} +
    • + ))} +
    + ) : null} +
    + ) : null} + + + {recycleBin.items_total === 0 ? ( +
    +

    + {hasActiveFilters + ? t('cmsui.recyclebin.empty.noMatches') + : t('cmsui.recyclebin.empty.noItems')} +

    + {hasActiveFilters ? ( + + {t('cmsui.recyclebin.filters.clearAll')} + + ) : null} +
    + ) : ( + <> +
    + {t('cmsui.recyclebin.pagination.range', { + start, + end, + total: recycleBin.items_total, + })} +
    + + + {recycleBin.batching ? ( + + ) : null} + + )} +
    + ); +} diff --git a/packages/cmsui/components/RecycleBin/RecycleBinRestorePanel.tsx b/packages/cmsui/components/RecycleBin/RecycleBinRestorePanel.tsx new file mode 100644 index 00000000000..d5d73140105 --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinRestorePanel.tsx @@ -0,0 +1,60 @@ +import { Form } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@plone/components/quanta'; + +export function RecycleBinRestorePanel({ + actionMessage, + defaultTargetPath, +}: { + actionMessage?: string; + defaultTargetPath: string; +}) { + const { t } = useTranslation(); + + return ( +
    +

    + {t('cmsui.recyclebin.restorePanel.title')} +

    + {actionMessage ? ( +

    + {actionMessage} +

    + ) : null} +
    + + + +
    +
    + ); +} diff --git a/packages/cmsui/components/RecycleBin/RecycleBinTable.tsx b/packages/cmsui/components/RecycleBin/RecycleBinTable.tsx new file mode 100644 index 00000000000..73ce914f243 --- /dev/null +++ b/packages/cmsui/components/RecycleBin/RecycleBinTable.tsx @@ -0,0 +1,108 @@ +import { Link } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import type { RecycleBinItemSummary } from '@plone/types'; +import { formatRecycleBinDate } from './utils'; + +export function RecycleBinTable({ + items, + selectedItems, + onSelectionChange, +}: { + items: RecycleBinItemSummary[]; + selectedItems: string[]; + onSelectionChange: (selectedItems: string[]) => void; +}) { + const { t, i18n } = useTranslation(); + const allSelected = + items.length > 0 && + items.every((item) => selectedItems.includes(item.recycle_id)); + + const toggleItem = (id: string, selected: boolean) => { + onSelectionChange( + selected + ? Array.from(new Set([...selectedItems, id])) + : selectedItems.filter((itemId) => itemId !== id), + ); + }; + + return ( +
    + + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
    + + onSelectionChange( + event.target.checked + ? items.map((item) => item.recycle_id) + : [], + ) + } + /> + {t('cmsui.recyclebin.table.title')}{t('cmsui.recyclebin.table.type')} + {t('cmsui.recyclebin.table.workflowState')} + {t('cmsui.recyclebin.table.path')} + {t('cmsui.recyclebin.table.deletionDate')} + + {t('cmsui.recyclebin.table.deletedBy')} +
    + + toggleItem(item.recycle_id, event.target.checked) + } + /> + + + {item.title || item.id} + + {item.has_children ? ( + + {t('cmsui.recyclebin.table.hasChildren')} + + ) : null} + {item['@type']}{item.review_state}{item.path} + {formatRecycleBinDate(item.deletion_date, i18n.language)} + {item.deleted_by}
    +
    + ); +} diff --git a/packages/cmsui/components/RecycleBin/utils.ts b/packages/cmsui/components/RecycleBin/utils.ts new file mode 100644 index 00000000000..0c98c3ee57b --- /dev/null +++ b/packages/cmsui/components/RecycleBin/utils.ts @@ -0,0 +1,196 @@ +import type { + GetRecycleBinItemResponse, + GetRecycleBinResponse, + RecycleBinItemSummary, + RecycleBinQuery, +} from '@plone/types'; + +export type RecycleBinSortBy = + | 'date_desc' + | 'date_asc' + | 'title_asc' + | 'title_desc' + | 'type_asc' + | 'type_desc' + | 'workflow_asc' + | 'workflow_desc'; + +export type RecycleBinQueryState = { + title?: string; + portal_type?: string; + deleted_by?: string; + has_subitems?: 'true' | 'false'; + language?: string; + review_state?: string; + date_from?: string; + date_to?: string; + sort_by?: RecycleBinSortBy; + b_start?: string; + b_size?: string; +}; + +export const sortMap: Record< + RecycleBinSortBy, + Pick +> = { + date_desc: { sort_on: 'deletion_date', sort_order: 'descending' }, + date_asc: { sort_on: 'deletion_date', sort_order: 'ascending' }, + title_asc: { sort_on: 'title', sort_order: 'ascending' }, + title_desc: { sort_on: 'title', sort_order: 'descending' }, + type_asc: { sort_on: 'portal_type', sort_order: 'ascending' }, + type_desc: { sort_on: 'portal_type', sort_order: 'descending' }, + workflow_asc: { sort_on: 'review_state', sort_order: 'ascending' }, + workflow_desc: { sort_on: 'review_state', sort_order: 'descending' }, +}; + +const queryKeys: Array = [ + 'title', + 'portal_type', + 'deleted_by', + 'has_subitems', + 'language', + 'review_state', + 'date_from', + 'date_to', + 'sort_by', + 'b_start', + 'b_size', +]; + +export const getQueryStateFromSearchParams = ( + searchParams: URLSearchParams, +): RecycleBinQueryState => + queryKeys.reduce((acc, key) => { + const value = searchParams.get(key); + if (value) { + acc[key] = value as never; + } + return acc; + }, {}); + +export const toRecycleBinQuery = ( + queryState: RecycleBinQueryState, +): RecycleBinQuery => { + const query: RecycleBinQuery = { + title: queryState.title, + portal_type: queryState.portal_type, + deleted_by: queryState.deleted_by, + language: queryState.language, + review_state: queryState.review_state, + date_from: queryState.date_from, + date_to: queryState.date_to, + b_start: queryState.b_start, + b_size: queryState.b_size, + ...(sortMap[queryState.sort_by ?? 'date_desc'] ?? sortMap.date_desc), + }; + + if (queryState.has_subitems === 'true') { + query.has_subitems = true; + } else if (queryState.has_subitems === 'false') { + query.has_subitems = false; + } + + return Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== undefined), + ) as RecycleBinQuery; +}; + +export const removeQueryParamHref = ( + queryState: RecycleBinQueryState, + key: keyof RecycleBinQueryState, +) => { + const searchParams = new URLSearchParams(); + + Object.entries(queryState).forEach(([paramKey, value]) => { + if (paramKey !== key && value) { + searchParams.set(paramKey, value); + } + }); + + const queryString = searchParams.toString(); + return queryString ? `/@@recyclebin?${queryString}` : '/@@recyclebin'; +}; + +export const clearFiltersHref = () => '/@@recyclebin'; + +export const getFilterOptions = ( + items: RecycleBinItemSummary[], + key: keyof Pick< + RecycleBinItemSummary, + '@type' | 'deleted_by' | 'language' | 'review_state' + >, +) => Array.from(new Set(items.map((item) => item[key]).filter(Boolean))).sort(); + +export const formatRecycleBinDate = (value?: string, locale: string = 'en') => { + if (!value) return ''; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +}; + +export const getPagination = ( + total: number, + bStart?: string, + bSize?: string, +) => { + const start = Number(bStart ?? 0); + const size = Number(bSize ?? 25); + const safeStart = Number.isFinite(start) ? start : 0; + const safeSize = Number.isFinite(size) && size > 0 ? size : 25; + const end = Math.min(safeStart + safeSize, total); + + return { + start: total === 0 ? 0 : safeStart + 1, + end, + size: safeSize, + previousStart: Math.max(0, safeStart - safeSize), + nextStart: safeStart + safeSize, + hasPrevious: safeStart > 0, + hasNext: end < total, + }; +}; + +export const stripPortalBasePath = (path: string, apiPath: string) => { + const basePath = new URL(apiPath).pathname.replace(/\/$/, ''); + + if ( + !basePath || + basePath === '/' || + (path !== basePath && !path.startsWith(`${basePath}/`)) + ) { + return path; + } + + const appPath = path.slice(basePath.length); + return appPath || '/'; +}; + +export const normalizeRecycleBinPaths = ( + recycleBin: GetRecycleBinResponse, + apiPath: string, +): GetRecycleBinResponse => ({ + ...recycleBin, + items: recycleBin.items.map((item) => ({ + ...item, + path: stripPortalBasePath(item.path, apiPath), + parent_path: stripPortalBasePath(item.parent_path, apiPath), + })), +}); + +export const normalizeRecycleBinItemPaths = ( + item: GetRecycleBinItemResponse, + apiPath: string, +): GetRecycleBinItemResponse => ({ + ...item, + path: stripPortalBasePath(item.path, apiPath), + parent_path: stripPortalBasePath(item.parent_path, apiPath), + items: item.items.map((child) => ({ + ...child, + path: stripPortalBasePath(child.path, apiPath), + })), +}); diff --git a/packages/cmsui/config/routes.ts b/packages/cmsui/config/routes.ts index 9032a55a241..16fb8e47aae 100644 --- a/packages/cmsui/config/routes.ts +++ b/packages/cmsui/config/routes.ts @@ -67,6 +67,22 @@ export default function install(config: ConfigType) { }, ], }, + { + type: 'prefix', + path: '@@recyclebin', + children: [ + { + type: 'index', + file: '@plone/cmsui/routes/recyclebin.tsx', + options: { id: 'recyclebin' }, + }, + { + type: 'route', + path: ':id', + file: '@plone/cmsui/routes/recyclebin-item.tsx', + }, + ], + }, { type: 'prefix', path: 'test-layout', diff --git a/packages/cmsui/locales/en/common.json b/packages/cmsui/locales/en/common.json index d76a51746ab..67d8e845265 100644 --- a/packages/cmsui/locales/en/common.json +++ b/packages/cmsui/locales/en/common.json @@ -52,10 +52,104 @@ "urlmanagement": "URL Management", "relations": "Relations", "moderatecomments": "Moderate Comments", + "recycleBin": "Recycle Bin", "users": "Users", "groupMembership": "Group Membership", "groups": "Groups" }, + "recyclebin": { + "title": "Recycle bin", + "description": "Items deleted from this site are stored here, and can be restored or permanently deleted.", + "filters": { + "search": "Search", + "type": "Content type", + "deletedBy": "Deleted by", + "hasSubitems": "Contains sub-items", + "language": "Language", + "workflowState": "Workflow state", + "dateFrom": "Deleted from", + "dateTo": "Deleted to", + "any": "Any", + "withSubitems": "With sub-items", + "withoutSubitems": "Without sub-items", + "advanced": "Advanced", + "apply": "Search", + "clearAll": "Clear all" + }, + "sort": { + "label": "Sort", + "date_desc": "Newest deleted first", + "date_asc": "Oldest deleted first", + "title_asc": "Title A-Z", + "title_desc": "Title Z-A", + "type_asc": "Type A-Z", + "type_desc": "Type Z-A", + "workflow_asc": "Workflow A-Z", + "workflow_desc": "Workflow Z-A" + }, + "table": { + "selectAll": "Select all items", + "selectItem": "Select {{title}}", + "title": "Title", + "type": "Type", + "workflowState": "Workflow state", + "path": "Original path", + "deletionDate": "Deleted", + "deletedBy": "Deleted by", + "hasChildren": "Contains items" + }, + "actions": { + "restore": "Restore", + "restoreSelected": "Restore selected", + "delete": "Permanently delete", + "deleteSelected": "Delete selected", + "empty": "Empty recycle bin", + "backToListing": "Back to recycle bin" + }, + "confirmations": { + "delete": "Permanently delete this item?", + "deleteSelected": "Permanently delete the selected items?", + "empty": "Permanently delete all items in the recycle bin?" + }, + "empty": { + "noItems": "The recycle bin is empty.", + "noMatches": "No deleted items match the current filters." + }, + "pagination": { + "range": "Showing {{start}}-{{end}} of {{total}}", + "previous": "Previous", + "next": "Next", + "pageSize": "Page size", + "start": "Start" + }, + "details": { + "originalId": "Original id", + "workflowState": "Workflow state", + "path": "Original path", + "parentPath": "Original parent path", + "deletionDate": "Deletion date", + "deletedBy": "Deleted by", + "containedItems": "Contained items" + }, + "restorePanel": { + "title": "Restore or delete", + "targetPath": "Restore to path" + }, + "children": { + "title": "Contained items", + "childrenCount": "Children", + "restoreTo": "Restore to", + "targetPath": "Target path for {{title}}" + }, + "errors": { + "disabledTitle": "Recycle bin unavailable", + "disabledDescription": "The recycle bin is disabled or unavailable for this site.", + "itemNotFoundTitle": "Item not found", + "itemNotFoundDescription": "The requested item was not found in the recycle bin. It may have been restored or deleted.", + "genericTitle": "Recycle bin error", + "genericDescription": "The recycle bin request could not be completed." + } + }, "blocksEditor": { "blocksTab": "Blocks", "contentTab": "Content" diff --git a/packages/cmsui/locales/it/common.json b/packages/cmsui/locales/it/common.json index 3f7ee0f1274..63b8eb2b836 100644 --- a/packages/cmsui/locales/it/common.json +++ b/packages/cmsui/locales/it/common.json @@ -42,10 +42,104 @@ "urlmanagement": "Gestione URL", "relations": "Relazioni", "moderatecomments": "Moderazione dei commenti", + "recycleBin": "Cestino", "users": "Utenti", "groupMembership": "Appartenenza ai gruppi", "groups": "Gruppi" }, + "recyclebin": { + "title": "Cestino", + "description": "Gli elementi eliminati da questo sito sono conservati qui e possono essere ripristinati o eliminati definitivamente.", + "filters": { + "search": "Cerca", + "type": "Tipo di contenuto", + "deletedBy": "Eliminato da", + "hasSubitems": "Contiene sottoelementi", + "language": "Lingua", + "workflowState": "Stato workflow", + "dateFrom": "Eliminato dal", + "dateTo": "Eliminato al", + "any": "Qualsiasi", + "withSubitems": "Con sottoelementi", + "withoutSubitems": "Senza sottoelementi", + "advanced": "Avanzate", + "apply": "Cerca", + "clearAll": "Rimuovi filtri" + }, + "sort": { + "label": "Ordina", + "date_desc": "Eliminati piu recenti", + "date_asc": "Eliminati meno recenti", + "title_asc": "Titolo A-Z", + "title_desc": "Titolo Z-A", + "type_asc": "Tipo A-Z", + "type_desc": "Tipo Z-A", + "workflow_asc": "Workflow A-Z", + "workflow_desc": "Workflow Z-A" + }, + "table": { + "selectAll": "Seleziona tutti gli elementi", + "selectItem": "Seleziona {{title}}", + "title": "Titolo", + "type": "Tipo", + "workflowState": "Stato workflow", + "path": "Percorso originale", + "deletionDate": "Eliminato", + "deletedBy": "Eliminato da", + "hasChildren": "Contiene elementi" + }, + "actions": { + "restore": "Ripristina", + "restoreSelected": "Ripristina selezionati", + "delete": "Elimina definitivamente", + "deleteSelected": "Elimina selezionati", + "empty": "Svuota cestino", + "backToListing": "Torna al cestino" + }, + "confirmations": { + "delete": "Eliminare definitivamente questo elemento?", + "deleteSelected": "Eliminare definitivamente gli elementi selezionati?", + "empty": "Eliminare definitivamente tutti gli elementi nel cestino?" + }, + "empty": { + "noItems": "Il cestino e vuoto.", + "noMatches": "Nessun elemento eliminato corrisponde ai filtri correnti." + }, + "pagination": { + "range": "Mostra {{start}}-{{end}} di {{total}}", + "previous": "Precedente", + "next": "Successivo", + "pageSize": "Dimensione pagina", + "start": "Inizio" + }, + "details": { + "originalId": "Id originale", + "workflowState": "Stato workflow", + "path": "Percorso originale", + "parentPath": "Percorso padre originale", + "deletionDate": "Data eliminazione", + "deletedBy": "Eliminato da", + "containedItems": "Elementi contenuti" + }, + "restorePanel": { + "title": "Ripristina o elimina", + "targetPath": "Ripristina nel percorso" + }, + "children": { + "title": "Elementi contenuti", + "childrenCount": "Figli", + "restoreTo": "Ripristina in", + "targetPath": "Percorso destinazione per {{title}}" + }, + "errors": { + "disabledTitle": "Cestino non disponibile", + "disabledDescription": "Il cestino e disabilitato o non disponibile per questo sito.", + "itemNotFoundTitle": "Elemento non trovato", + "itemNotFoundDescription": "L'elemento richiesto non e stato trovato nel cestino. Potrebbe essere stato ripristinato o eliminato.", + "genericTitle": "Errore cestino", + "genericDescription": "La richiesta al cestino non puo essere completata." + } + }, "blocksEditor": { "blocksTab": "Blocchi", "contentTab": "Contenuto" diff --git a/packages/cmsui/package.json b/packages/cmsui/package.json index fea097337ae..fdc77ced57e 100644 --- a/packages/cmsui/package.json +++ b/packages/cmsui/package.json @@ -33,7 +33,7 @@ "main": "index.ts", "scripts": { "test": "vitest --coverage", - "check:ts": "pnpm --filter seven run typegen && tsc --project tsconfig.json", + "check:ts": "pnpm --filter seven exec init-loaders && pnpm --filter seven run typegen && tsc --project tsconfig.json", "dry-release": "release-it --dry-run", "release": "release-it", "release-major-alpha": "release-it major --preRelease=alpha", diff --git a/packages/cmsui/routes/recyclebin-item.test.tsx b/packages/cmsui/routes/recyclebin-item.test.tsx new file mode 100644 index 00000000000..f463d94383e --- /dev/null +++ b/packages/cmsui/routes/recyclebin-item.test.tsx @@ -0,0 +1,210 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { RouterContextProvider } from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import { action, loader } from './recyclebin-item'; + +vi.mock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), +})); + +vi.mock('seven/app/middleware.server', () => ({ + ploneClientContext: {}, +})); + +const callLoader = (request: Request, cli: Record) => { + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + config: { apiPath: 'http://example.com/Plone' }, + ...cli, + } as any); + return loader({ + request, + params: { id: 'deleted-item' }, + context, + unstable_pattern: '/@@recyclebin/:id', + unstable_url: new URL(request.url), + }); +}; + +const callAction = (request: Request, cli: Record) => { + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + config: { apiPath: 'http://example.com/Plone' }, + ...cli, + } as any); + return action({ + request, + params: { id: 'deleted-item' }, + context, + unstable_pattern: '/@@recyclebin/:id', + unstable_url: new URL(request.url), + }); +}; + +describe('recycle bin item route', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads one recycle bin item with batching params', async () => { + const getRecycleBinItem = vi.fn().mockResolvedValue({ + data: { + '@id': '/@recyclebin/deleted-item', + '@type': 'Document', + id: 'deleted-item', + title: 'Deleted item', + path: '/Plone/deleted-item', + parent_path: '/Plone', + deletion_date: '2026-05-20T10:30:00+00:00', + recycle_id: 'deleted-item', + deleted_by: 'admin', + language: 'en', + review_state: 'private', + has_children: false, + actions: { + restore: '/@recyclebin/deleted-item/restore', + purge: '/@recyclebin/deleted-item', + }, + items_total: 0, + items: [], + }, + }); + + await callLoader( + new Request( + 'http://example.com/@@recyclebin/deleted-item?b_start=5&b_size=10', + ), + { getRecycleBinItem }, + ); + + expect(getRecycleBinItem).toHaveBeenCalledWith({ + id: 'deleted-item', + query: { b_start: '5', b_size: '10' }, + }); + }); + + it('strips the portal base path from item and child paths', async () => { + const getRecycleBinItem = vi.fn().mockResolvedValue({ + data: { + '@id': '/@recyclebin/deleted-item', + '@type': 'Document', + id: 'deleted-item', + title: 'Deleted item', + path: '/Plone/deleted-item', + parent_path: '/Plone', + deletion_date: '2026-05-20T10:30:00+00:00', + recycle_id: 'deleted-item', + deleted_by: 'admin', + language: 'en', + review_state: 'private', + has_children: true, + actions: { + restore: '/@recyclebin/deleted-item/restore', + purge: '/@recyclebin/deleted-item', + }, + items_total: 1, + items: [ + { + id: 'child', + title: 'Child', + '@type': 'Document', + path: '/Plone/deleted-item/child', + restore_id: 'child', + language: 'en', + review_state: 'private', + }, + ], + }, + }); + + const result = await callLoader( + new Request('http://example.com/@@recyclebin/deleted-item'), + { getRecycleBinItem }, + ); + const body = (result as any).data; + + expect(body.item.path).toBe('/deleted-item'); + expect(body.item.parent_path).toBe('/'); + expect(body.item.items[0].path).toBe('/deleted-item/child'); + }); + + it('restores the item with an optional target path', async () => { + const restoreRecycleBinItem = vi.fn().mockResolvedValue({ + data: { + restored_item: { '@id': 'http://example.com/Plone/restored-item' }, + }, + }); + const formData = new FormData(); + formData.append('_action', 'restore'); + formData.append('target_path', 'target-folder'); + + const result = await callAction( + new Request('http://example.com/@@recyclebin/deleted-item', { + method: 'POST', + body: formData, + }), + { restoreRecycleBinItem }, + ); + + expect(restoreRecycleBinItem).toHaveBeenCalledWith({ + id: 'deleted-item', + data: { target_path: 'target-folder' }, + }); + expect((result as Response).headers.get('Location')).toBe('/restored-item'); + }); + + it('purges the item', async () => { + const purgeRecycleBinItem = vi.fn().mockResolvedValue({}); + const formData = new FormData(); + formData.append('_action', 'purge'); + + await callAction( + new Request('http://example.com/@@recyclebin/deleted-item', { + method: 'POST', + body: formData, + }), + { purgeRecycleBinItem }, + ); + + expect(purgeRecycleBinItem).toHaveBeenCalledWith({ id: 'deleted-item' }); + }); + + it('requires target_path for child restore', async () => { + const restoreRecycleBinItem = vi.fn(); + const formData = new FormData(); + formData.append('_action', 'restore-child'); + formData.append('restore_id', 'child-id'); + + const result = await callAction( + new Request('http://example.com/@@recyclebin/deleted-item', { + method: 'POST', + body: formData, + }), + { restoreRecycleBinItem }, + ); + + expect((result as any).init.status).toBe(400); + expect(restoreRecycleBinItem).not.toHaveBeenCalled(); + }); + + it('passes restore_id and target_path for child restore', async () => { + const restoreRecycleBinItem = vi.fn().mockResolvedValue({}); + const formData = new FormData(); + formData.append('_action', 'restore-child'); + formData.append('restore_id', 'child-id'); + formData.append('target_path', 'target-folder'); + + await callAction( + new Request('http://example.com/@@recyclebin/deleted-item', { + method: 'POST', + body: formData, + }), + { restoreRecycleBinItem }, + ); + + expect(restoreRecycleBinItem).toHaveBeenCalledWith({ + id: 'deleted-item', + data: { restore_id: 'child-id', target_path: 'target-folder' }, + }); + }); +}); diff --git a/packages/cmsui/routes/recyclebin-item.tsx b/packages/cmsui/routes/recyclebin-item.tsx new file mode 100644 index 00000000000..3630f76cf49 --- /dev/null +++ b/packages/cmsui/routes/recyclebin-item.tsx @@ -0,0 +1,170 @@ +import { flattenToAppURL } from '@plone/helpers'; +import { requireAuthCookie } from '@plone/react-router'; +import { + data, + redirect, + RouterContextProvider, + useActionData, + useLoaderData, + useRouteError, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router'; +import { Container } from '@plone/components/quanta'; +import { useTranslation } from 'react-i18next'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import { RecycleBinItemDetails } from '../components/RecycleBin/RecycleBinItemDetails'; +import { + normalizeRecycleBinItemPaths, + stripPortalBasePath, +} from '../components/RecycleBin/utils'; + +const getErrorMessage = (error: unknown) => { + if ( + typeof error === 'object' && + error && + 'data' in error && + typeof error.data === 'object' && + error.data && + 'message' in error.data + ) { + return String(error.data.message); + } + return 'Request failed'; +}; + +const getRestoredItemRedirectUrl = ( + restoredUrl: string | undefined, + apiPath: string, +) => { + if (!restoredUrl) return '/@@recyclebin'; + + const flattenedUrl = flattenToAppURL(restoredUrl); + + try { + return stripPortalBasePath(new URL(flattenedUrl).pathname, apiPath); + } catch { + return stripPortalBasePath(flattenedUrl, apiPath); + } +}; + +export async function loader({ + params, + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const searchParams = new URL(request.url).searchParams; + const query = { + b_start: searchParams.get('b_start') ?? undefined, + b_size: searchParams.get('b_size') ?? undefined, + }; + const { data: item } = await cli.getRecycleBinItem({ + id: params.id ?? '', + query, + }); + + return data( + flattenToAppURL({ + item: normalizeRecycleBinItemPaths(item, cli.config.apiPath), + }), + ); +} + +export async function action({ + params, + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const id = params.id ?? ''; + const formData = await request.formData(); + const intent = formData.get('_action'); + const targetPath = String(formData.get('target_path') ?? '').trim(); + + try { + if (intent === 'restore') { + const result = await cli.restoreRecycleBinItem({ + id, + data: targetPath ? { target_path: targetPath } : {}, + }); + const restoredUrl = result.data.restored_item?.['@id']; + return redirect( + getRestoredItemRedirectUrl(restoredUrl, cli.config.apiPath), + ); + } + + if (intent === 'purge') { + await cli.purgeRecycleBinItem({ id }); + return redirect('/@@recyclebin'); + } + + if (intent === 'restore-child') { + const restoreId = String(formData.get('restore_id') ?? '').trim(); + if (!restoreId || !targetPath) { + return data( + { message: 'Child restore requires a target path.' }, + { status: 400 }, + ); + } + await cli.restoreRecycleBinItem({ + id, + data: { restore_id: restoreId, target_path: targetPath }, + }); + return redirect(`/@@recyclebin/${id}`); + } + + return data( + { message: 'Unsupported recycle bin action.' }, + { status: 400 }, + ); + } catch (error) { + return data({ message: getErrorMessage(error) }, { status: 400 }); + } +} + +export default function RecycleBinItem() { + const { item } = useLoaderData(); + const actionData = useActionData() as + | { message?: string } + | undefined; + + return ( +
    + + + +
    + ); +} + +export function ErrorBoundary() { + const error = useRouteError() as { status?: number }; + const { t } = useTranslation(); + + return ( +
    + +
    +

    + {error?.status === 404 + ? t('cmsui.recyclebin.errors.itemNotFoundTitle') + : t('cmsui.recyclebin.errors.genericTitle')} +

    +

    + {error?.status === 404 + ? t('cmsui.recyclebin.errors.itemNotFoundDescription') + : t('cmsui.recyclebin.errors.genericDescription')} +

    +
    +
    +
    + ); +} diff --git a/packages/cmsui/routes/recyclebin.test.tsx b/packages/cmsui/routes/recyclebin.test.tsx new file mode 100644 index 00000000000..04522c96829 --- /dev/null +++ b/packages/cmsui/routes/recyclebin.test.tsx @@ -0,0 +1,241 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { RouterContextProvider } from 'react-router'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import { action, loader } from './recyclebin'; + +vi.mock('@plone/react-router', () => ({ + requireAuthCookie: vi.fn().mockResolvedValue('fake-token'), +})); + +vi.mock('seven/app/middleware.server', () => ({ + ploneClientContext: {}, +})); + +const callLoader = (request: Request, cli: Record) => { + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + config: { apiPath: 'http://example.com/Plone' }, + ...cli, + } as any); + return loader({ + request, + params: {}, + context, + unstable_pattern: '/@@recyclebin', + unstable_url: new URL(request.url), + }); +}; + +const callAction = (request: Request, cli: Record) => { + const context = new RouterContextProvider(); + context.set(ploneClientContext, { + config: { apiPath: 'http://example.com/Plone' }, + ...cli, + } as any); + return action({ + request, + params: {}, + context, + unstable_pattern: '/@@recyclebin', + unstable_url: new URL(request.url), + }); +}; + +describe('recycle bin route', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('maps listing query params to getRecycleBin query', async () => { + const getRecycleBin = vi.fn().mockResolvedValue({ + data: { '@id': '/@recyclebin', items_total: 0, items: [] }, + }); + + await callLoader( + new Request( + 'http://example.com/@@recyclebin?title=page&portal_type=Document&deleted_by=admin&language=en&review_state=private&date_from=2026-01-01&date_to=2026-02-01&b_start=10&b_size=25', + ), + { getRecycleBin }, + ); + + expect(getRecycleBin).toHaveBeenCalledWith({ + query: { + title: 'page', + portal_type: 'Document', + deleted_by: 'admin', + language: 'en', + review_state: 'private', + date_from: '2026-01-01', + date_to: '2026-02-01', + b_start: '10', + b_size: '25', + sort_on: 'deletion_date', + sort_order: 'descending', + }, + }); + }); + + it('strips the portal base path from response paths', async () => { + const getRecycleBin = vi.fn().mockResolvedValue({ + data: { + '@id': '/@recyclebin', + items_total: 1, + items: [ + { + '@id': '/@recyclebin/deleted-page', + '@type': 'Document', + id: 'deleted-page', + title: 'Deleted page', + path: '/Plone/deleted-page', + parent_path: '/Plone', + deletion_date: '2026-05-20T10:30:00+00:00', + recycle_id: 'deleted-page', + deleted_by: 'admin', + language: 'en', + review_state: 'private', + has_children: false, + actions: { + restore: '/@recyclebin/deleted-page/restore', + purge: '/@recyclebin/deleted-page', + }, + }, + ], + }, + }); + + const result = await callLoader( + new Request('http://example.com/@@recyclebin'), + { getRecycleBin }, + ); + const body = (result as any).data; + + expect(body.recycleBin.items[0].path).toBe('/deleted-page'); + expect(body.recycleBin.items[0].parent_path).toBe('/'); + }); + + it.each([ + ['true', true], + ['false', false], + ])('maps has_subitems=%s', async (value, expected) => { + const getRecycleBin = vi.fn().mockResolvedValue({ + data: { '@id': '/@recyclebin', items_total: 0, items: [] }, + }); + + await callLoader( + new Request(`http://example.com/@@recyclebin?has_subitems=${value}`), + { getRecycleBin }, + ); + + expect(getRecycleBin).toHaveBeenCalledWith({ + query: expect.objectContaining({ has_subitems: expected }), + }); + }); + + it.each([ + ['date_desc', 'deletion_date', 'descending'], + ['date_asc', 'deletion_date', 'ascending'], + ['title_asc', 'title', 'ascending'], + ['title_desc', 'title', 'descending'], + ['type_asc', 'portal_type', 'ascending'], + ['type_desc', 'portal_type', 'descending'], + ['workflow_asc', 'review_state', 'ascending'], + ['workflow_desc', 'review_state', 'descending'], + ])('maps sort_by=%s', async (sortBy, sortOn, sortOrder) => { + const getRecycleBin = vi.fn().mockResolvedValue({ + data: { '@id': '/@recyclebin', items_total: 0, items: [] }, + }); + + await callLoader( + new Request(`http://example.com/@@recyclebin?sort_by=${sortBy}`), + { getRecycleBin }, + ); + + expect(getRecycleBin).toHaveBeenCalledWith({ + query: expect.objectContaining({ + sort_on: sortOn, + sort_order: sortOrder, + }), + }); + }); + + it('restores selected items', async () => { + const restoreRecycleBinItem = vi.fn().mockResolvedValue({ + data: { restored_item: { '@id': 'http://example.com/Plone/restored' } }, + }); + const formData = new FormData(); + formData.append('_action', 'restore-selected'); + formData.append('selected_items', 'one'); + formData.append('selected_items', 'two'); + + await callAction( + new Request('http://example.com/@@recyclebin', { + method: 'POST', + body: formData, + }), + { restoreRecycleBinItem }, + ); + + expect(restoreRecycleBinItem).toHaveBeenCalledWith({ + id: 'one', + data: {}, + }); + expect(restoreRecycleBinItem).toHaveBeenCalledWith({ + id: 'two', + data: {}, + }); + }); + + it('redirects to the restored item when one selected item is restored', async () => { + const restoreRecycleBinItem = vi.fn().mockResolvedValue({ + data: { + restored_item: { '@id': 'http://example.com/Plone/restored-item' }, + }, + }); + const formData = new FormData(); + formData.append('_action', 'restore-selected'); + formData.append('selected_items', 'one'); + + const result = await callAction( + new Request('http://example.com/@@recyclebin', { + method: 'POST', + body: formData, + }), + { restoreRecycleBinItem }, + ); + + expect((result as Response).headers.get('Location')).toBe('/restored-item'); + }); + + it('purges selected items', async () => { + const purgeRecycleBinItem = vi.fn().mockResolvedValue({}); + const formData = new FormData(); + formData.append('_action', 'purge-selected'); + formData.append('selected_items', 'one'); + + await callAction( + new Request('http://example.com/@@recyclebin', { + method: 'POST', + body: formData, + }), + { purgeRecycleBinItem }, + ); + + expect(purgeRecycleBinItem).toHaveBeenCalledWith({ id: 'one' }); + }); + + it('empties the recycle bin', async () => { + const emptyRecycleBin = vi.fn().mockResolvedValue({}); + const formData = new FormData(); + formData.append('_action', 'empty'); + + await callAction( + new Request('http://example.com/@@recyclebin', { + method: 'POST', + body: formData, + }), + { emptyRecycleBin }, + ); + + expect(emptyRecycleBin).toHaveBeenCalled(); + }); +}); diff --git a/packages/cmsui/routes/recyclebin.tsx b/packages/cmsui/routes/recyclebin.tsx new file mode 100644 index 00000000000..c657355b7bf --- /dev/null +++ b/packages/cmsui/routes/recyclebin.tsx @@ -0,0 +1,189 @@ +import { flattenToAppURL } from '@plone/helpers'; +import { requireAuthCookie } from '@plone/react-router'; +import { + data, + redirect, + RouterContextProvider, + useActionData, + useLoaderData, + useRouteError, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from 'react-router'; +import { Container } from '@plone/components/quanta'; +import { useTranslation } from 'react-i18next'; +import { ploneClientContext } from 'seven/app/middleware.server'; +import { + RecycleBinListing, + type RecycleBinActionData, +} from '../components/RecycleBin/RecycleBinListing'; +import { + getQueryStateFromSearchParams, + normalizeRecycleBinPaths, + stripPortalBasePath, + toRecycleBinQuery, +} from '../components/RecycleBin/utils'; + +const getErrorMessage = (error: unknown) => { + if ( + typeof error === 'object' && + error && + 'data' in error && + typeof error.data === 'object' && + error.data && + 'message' in error.data + ) { + return String(error.data.message); + } + return 'Request failed'; +}; + +const getRestoredItemRedirectUrl = ( + restoredUrl: string | undefined, + apiPath: string, +) => { + if (!restoredUrl) return '/@@recyclebin'; + + const flattenedUrl = flattenToAppURL(restoredUrl); + + try { + return stripPortalBasePath(new URL(flattenedUrl).pathname, apiPath); + } catch { + return stripPortalBasePath(flattenedUrl, apiPath); + } +}; + +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const queryState = getQueryStateFromSearchParams( + new URL(request.url).searchParams, + ); + const { data: recycleBin } = await cli.getRecycleBin({ + query: toRecycleBinQuery(queryState), + }); + + return data( + flattenToAppURL({ + recycleBin: normalizeRecycleBinPaths(recycleBin, cli.config.apiPath), + queryState, + }), + ); +} + +export async function action({ + request, + context, +}: ActionFunctionArgs) { + await requireAuthCookie(request); + + const cli = context.get(ploneClientContext); + const url = new URL(request.url); + const formData = await request.formData(); + const intent = formData.get('_action'); + const selectedItems = formData.getAll('selected_items').map(String); + const failures: Array<{ id: string; message: string }> = []; + const restoredUrls: string[] = []; + let succeeded = 0; + + if (intent === 'empty') { + try { + await cli.emptyRecycleBin(); + return redirect('/@@recyclebin'); + } catch (error) { + return data( + { message: getErrorMessage(error) }, + { status: 400 }, + ); + } + } + + if (intent !== 'restore-selected' && intent !== 'purge-selected') { + return data( + { message: 'Unsupported recycle bin action.' }, + { status: 400 }, + ); + } + + for (const id of selectedItems) { + try { + if (intent === 'restore-selected') { + const result = await cli.restoreRecycleBinItem({ id, data: {} }); + const restoredUrl = result.data.restored_item?.['@id']; + if (restoredUrl) { + restoredUrls.push(restoredUrl); + } + } else { + await cli.purgeRecycleBinItem({ id }); + } + succeeded += 1; + } catch (error) { + failures.push({ id, message: getErrorMessage(error) }); + } + } + + if (failures.length > 0) { + return data( + { + message: `${succeeded} succeeded, ${failures.length} failed.`, + failures, + }, + { status: 400 }, + ); + } + + if (intent === 'restore-selected' && selectedItems.length === 1) { + return redirect( + getRestoredItemRedirectUrl(restoredUrls[0], cli.config.apiPath), + ); + } + + return redirect(`/@@recyclebin${url.search}`); +} + +export default function RecycleBin() { + const { recycleBin, queryState } = useLoaderData(); + const actionData = useActionData() as + | RecycleBinActionData + | undefined; + + return ( +
    + + + +
    + ); +} + +export function ErrorBoundary() { + const error = useRouteError() as { status?: number }; + const { t } = useTranslation(); + + return ( +
    + +
    +

    + {error?.status === 404 + ? t('cmsui.recyclebin.errors.disabledTitle') + : t('cmsui.recyclebin.errors.genericTitle')} +

    +

    + {error?.status === 404 + ? t('cmsui.recyclebin.errors.disabledDescription') + : t('cmsui.recyclebin.errors.genericDescription')} +

    +
    +
    +
    + ); +} diff --git a/packages/types/src/services/index.d.ts b/packages/types/src/services/index.d.ts index 2bc7206a3bb..807db5e52c9 100644 --- a/packages/types/src/services/index.d.ts +++ b/packages/types/src/services/index.d.ts @@ -22,6 +22,7 @@ export * from './principals'; export * from './querysources'; export * from './querystring'; export * from './querystring-search'; +export * from './recyclebin'; export * from './registry'; export * from './relations'; export * from './roles'; diff --git a/packages/types/src/services/recyclebin.d.ts b/packages/types/src/services/recyclebin.d.ts new file mode 100644 index 00000000000..9a17bb0ac04 --- /dev/null +++ b/packages/types/src/services/recyclebin.d.ts @@ -0,0 +1,76 @@ +export interface RecycleBinActionLinks { + restore: string; + purge: string; +} + +export interface RecycleBinItemSummary { + '@id': string; + '@type': string; + id: string; + title: string; + path: string; + parent_path: string; + deletion_date: string; + recycle_id: string; + deleted_by: string; + language: string; + review_state: string; + has_children: boolean; + actions: RecycleBinActionLinks; +} + +export interface RecycleBinChildItem { + id: string; + title: string; + '@type': string; + path: string; + restore_id: string; + language: string; + review_state: string; + children_count?: number; +} + +export interface GetRecycleBinResponse { + '@id': string; + items_total: number; + batching?: Record; + items: RecycleBinItemSummary[]; +} + +export interface GetRecycleBinItemResponse extends RecycleBinItemSummary { + items_total: number; + batching?: Record; + items: RecycleBinChildItem[]; +} + +export interface RestoreRecycleBinItemData { + target_path?: string; + restore_id?: string; +} + +export interface RestoreRecycleBinItemResponse { + status: 'success'; + message: string; + restored_item: { + '@id': string; + id: string; + title: string; + '@type': string; + }; +} + +export interface RecycleBinQuery { + title?: string; + path?: string; + portal_type?: string; + date_from?: string; + date_to?: string; + deleted_by?: string; + has_subitems?: boolean; + language?: string; + review_state?: string; + sort_on?: 'title' | 'portal_type' | 'path' | 'deletion_date' | 'review_state'; + sort_order?: 'ascending' | 'descending'; + b_start?: number | string; + b_size?: number | string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b66e17fee51..69d931444aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,7 +219,7 @@ importers: version: 7.14.0(@react-router/serve@7.14.0(react-router@7.14.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2))(@types/node@24.10.1)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.14.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(terser@5.30.3)(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))(yaml@2.8.1) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.9.1 @@ -423,7 +423,7 @@ importers: version: 19.2.3(@types/react@19.2.6) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -535,16 +535,16 @@ importers: version: link:../types '@storybook/addon-docs': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@storybook/addon-links': specifier: ^10.4.0 version: 10.4.0(@types/react@19.2.6)(react@19.2.0)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@testing-library/jest-dom': specifier: 'catalog:' version: 6.9.1 @@ -565,7 +565,7 @@ importers: version: 19.2.3(@types/react@19.2.6) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -649,7 +649,7 @@ importers: version: 1.0.7(tailwindcss@4.2.2) vite-plugin-svgr: specifier: ^4.3.0 - version: 4.5.0(rollup@4.52.5)(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 4.5.0(rollup@4.52.5)(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) devDependencies: '@arethetypeswrong/cli': specifier: ^0.16.4 @@ -662,16 +662,16 @@ importers: version: 3.32.1(react@19.2.0) '@storybook/addon-docs': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@storybook/addon-links': specifier: ^10.4.0 version: 10.4.0(@types/react@19.2.6)(react@19.2.0)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@testing-library/jest-dom': specifier: 'catalog:' version: 6.9.1 @@ -689,7 +689,7 @@ importers: version: 19.2.3(@types/react@19.2.6) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -829,16 +829,16 @@ importers: version: link:../types '@storybook/addon-docs': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@storybook/addon-links': specifier: ^10.4.0 version: 10.4.0(@types/react@19.2.6)(react@19.2.0)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@testing-library/jest-dom': specifier: 'catalog:' version: 6.9.1 @@ -859,7 +859,7 @@ importers: version: 19.2.3(@types/react@19.2.6) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) eslint-plugin-storybook: specifier: ^10.4.0 version: 10.4.0(eslint@9.38.0(jiti@2.6.1))(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2) @@ -1124,16 +1124,16 @@ importers: version: link:../types '@storybook/addon-docs': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@storybook/addon-links': specifier: ^10.4.0 version: 10.4.0(@types/react@19.2.6)(react@19.2.0)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 4.2.2(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@testing-library/jest-dom': specifier: 'catalog:' version: 6.9.1 @@ -1157,7 +1157,7 @@ importers: version: 19.2.3(@types/react@19.2.6) '@vitejs/plugin-react': specifier: 'catalog:' - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) eslint-plugin-storybook: specifier: ^10.4.0 version: 10.4.0(eslint@9.38.0(jiti@2.6.1))(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2) @@ -1489,13 +1489,13 @@ importers: version: link:../react-router '@storybook/addon-docs': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@storybook/addon-links': specifier: ^10.4.0 version: 10.4.0(@types/react@19.2.6)(react@19.2.0)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + version: 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@types/react': specifier: 'catalog:' version: 19.2.6 @@ -10302,7 +10302,7 @@ snapshots: '@types/yargs': 17.0.34 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@5.9.2) @@ -11709,10 +11709,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': + '@storybook/addon-docs@10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.6)(react@19.2.0) - '@storybook/csf-plugin': 10.4.0(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + '@storybook/csf-plugin': 10.4.0(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@storybook/icons': 2.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@storybook/react-dom-shim': 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) react: 19.2.0 @@ -11736,9 +11736,9 @@ snapshots: '@types/react': 19.2.6 react: 19.2.0 - '@storybook/builder-vite@10.4.0(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': + '@storybook/builder-vite@10.4.0(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': dependencies: - '@storybook/csf-plugin': 10.4.0(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + '@storybook/csf-plugin': 10.4.0(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) storybook: 10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) ts-dedent: 2.2.0 vite: 8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1) @@ -11747,12 +11747,11 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.4.0(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': + '@storybook/csf-plugin@10.4.0(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': dependencies: storybook: 10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.25.11 rollup: 4.52.5 vite: 8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1) @@ -11776,11 +11775,11 @@ snapshots: '@types/react': 19.2.6 '@types/react-dom': 19.2.3(@types/react@19.2.6) - '@storybook/react-vite@10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(esbuild@0.25.11)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': + '@storybook/react-vite@10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@rollup/pluginutils': 5.3.0(rollup@4.52.5) - '@storybook/builder-vite': 10.4.0(esbuild@0.25.11)(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) + '@storybook/builder-vite': 10.4.0(rollup@4.52.5)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)) '@storybook/react': 10.4.0(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.6)(prettier@3.6.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2) empathic: 2.0.1 magic-string: 0.30.21 @@ -11960,7 +11959,7 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': + '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 @@ -12408,7 +12407,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': + '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1) @@ -17743,7 +17742,7 @@ snapshots: uuid: 11.1.0 vite: 8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1) - vite-plugin-svgr@4.5.0(rollup@4.52.5)(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(esbuild@0.25.11)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)): + vite-plugin-svgr@4.5.0(rollup@4.52.5)(typescript@5.9.2)(vite@8.0.8(@types/node@24.10.1)(jiti@2.6.1)(terser@5.30.3)(yaml@2.8.1)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.52.5) '@svgr/core': 8.1.0(typescript@5.9.2)