Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/client/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/restapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 13 additions & 0 deletions packages/client/src/restapi/recyclebin/empty.ts
Original file line number Diff line number Diff line change
@@ -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<RequestResponse<undefined>> {
const options: ApiRequestParams = {
config: this.config,
};

return apiRequest('delete', '/@recyclebin', options);
}
28 changes: 28 additions & 0 deletions packages/client/src/restapi/recyclebin/get.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getRecycleBinArgsSchema>;

export async function getRecycleBin(
this: PloneClient,
{ query = {} }: GetRecycleBinArgs = { query: {} },
): Promise<RequestResponse<GetRecycleBinResponse>> {
const validatedArgs = getRecycleBinArgsSchema.parse({
query,
});

const options: ApiRequestParams = {
config: this.config,
params: validatedArgs.query,
};

return apiRequest('get', '/@recyclebin', options);
}
33 changes: 33 additions & 0 deletions packages/client/src/restapi/recyclebin/get_item.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getRecycleBinItemArgsSchema>;

export async function getRecycleBinItem(
this: PloneClient,
{ id, query = {} }: GetRecycleBinItemArgs,
): Promise<RequestResponse<GetRecycleBinItemResponse>> {
const validatedArgs = getRecycleBinItemArgsSchema.parse({
id,
query,
});

const options: ApiRequestParams = {
config: this.config,
params: validatedArgs.query,
};

return apiRequest('get', `/@recyclebin/${validatedArgs.id}`, options);
}
28 changes: 28 additions & 0 deletions packages/client/src/restapi/recyclebin/purge.ts
Original file line number Diff line number Diff line change
@@ -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<RequestResponse<undefined>> {
const validatedArgs = purgeRecycleBinItemArgsSchema.parse({
id,
});

const options: ApiRequestParams = {
config: this.config,
};

return apiRequest('delete', `/@recyclebin/${validatedArgs.id}`, options);
}
144 changes: 144 additions & 0 deletions packages/client/src/restapi/recyclebin/recyclebin.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
39 changes: 39 additions & 0 deletions packages/client/src/restapi/recyclebin/restore.ts
Original file line number Diff line number Diff line change
@@ -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<RequestResponse<RestoreRecycleBinItemResponse>> {
const validatedArgs = restoreRecycleBinItemArgsSchema.parse({
id,
data,
});

const options: ApiRequestParams = {
config: this.config,
data: validatedArgs.data,
};

return apiRequest(
'post',
`/@recyclebin/${validatedArgs.id}/restore`,
options,
);
}
34 changes: 34 additions & 0 deletions packages/client/src/validation/recyclebin.ts
Original file line number Diff line number Diff line change
@@ -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({});
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,12 @@ describe('ControlPanelsList', () => {
expect(link).toHaveAttribute('href', panel.href);
});
});

it('renders Seven control panels with custom hrefs', () => {
render(<ControlPanelsList controlpanels={[]} />);

expect(
screen.getByRole('link', { name: 'cmsui.paneltitles.recycleBin' }),
).toHaveAttribute('href', '/@@recyclebin');
});
});
Loading
Loading