Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
91029e1
add basic sharing route
jmevissen Jun 8, 2026
f9edcd2
Merge branch 'main' into 29-sharing_route
jmevissen Jun 8, 2026
6e91413
update sharing type
jmevissen Jun 8, 2026
2167a9a
render sharing data in table
jmevissen Jun 8, 2026
e3b3579
update the rendering of the sharing table
jmevissen Jun 9, 2026
d63e5cd
show group/user icons in sharing tab
jmevissen Jun 9, 2026
a84e847
add search for sharing
jmevissen Jun 9, 2026
37ff0df
add close link for sharing
jmevissen Jun 9, 2026
b20ee65
enable saving of role mapping in sharing
jmevissen Jun 10, 2026
8f8ef73
save inherited selection in sharing
jmevissen Jun 10, 2026
5920f9b
smaller fixes
jmevissen Jun 10, 2026
e56cb00
use the quanta searchfield for sharing
jmevissen Jun 10, 2026
74c0676
use search tag and show number of results to screenreaders
jmevissen Jun 10, 2026
2badefa
make resetKey context path aware
jmevissen Jun 10, 2026
776646d
simplefy loader return data for sharing
jmevissen Jun 10, 2026
1e330ca
redirect path from content['@id'], same as cancel for sharing
jmevissen Jun 10, 2026
b7809b5
cleanup updateSharing call
jmevissen Jun 10, 2026
e812e59
add description for sharing inherited checkbox
jmevissen Jun 10, 2026
67d5e7c
add description for sharing
jmevissen Jun 10, 2026
cf6adc5
add news entries for issue 29
jmevissen Jun 11, 2026
c48b172
move translation for cancel up one level
jmevissen Jun 11, 2026
014f5c5
add testing for sharing loader and action
jmevissen Jun 11, 2026
2073005
add testing for useSharingEdits hook
jmevissen Jun 11, 2026
71f8ab0
add basic acceptance test for sharing
jmevissen Jun 11, 2026
05e28c8
add more acceptance test for sharing
jmevissen Jun 11, 2026
3ff34e8
create SharingForm component
jmevissen Jun 11, 2026
202d3c6
update sharing heading class derived from content route
jmevissen Jun 11, 2026
157743e
update sharing wrapper class derived from content route
jmevissen Jun 11, 2026
6e70f4c
Merge branch 'main' into 29-sharing_route
pnicolli Jun 20, 2026
aacb089
remove duplicate translations
jmevissen Jun 23, 2026
00af0fc
update news note
jmevissen Jun 23, 2026
465f1c7
add key paramter for sharingform to refresh whole component on search…
jmevissen Jun 30, 2026
611721c
Merge branch 'main' into 29-sharing_route
pnicolli Jun 30, 2026
6ffc692
use useEffect to clear edits when search changes and not rerender the…
jmevissen Jun 30, 2026
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
1 change: 1 addition & 0 deletions packages/client/news/29.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added the optional `inherit` field to the `updateSharing` data schema. @jmevissen
1 change: 1 addition & 0 deletions packages/client/src/restapi/sharing/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('Sharing', () => {
type: 'group',
},
],
inherit: false,
},
});

Expand Down
1 change: 1 addition & 0 deletions packages/client/src/validation/sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export const updateSharingDataSchema = z.object({
type: z.string(),
}),
),
inherit: z.boolean().optional(),
});
123 changes: 123 additions & 0 deletions packages/cmsui/acceptance/tests/sharing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { expect, test } from '../../../tooling/playwright/test';
import { login } from '../../../tooling/playwright/login';
import { createContent } from '../../../tooling/playwright/content';

test('As an anonymous visitor, I cannot open the sharing page', async ({
page,
}) => {
await createContent(page, {
contentType: 'Document',
contentId: 'mypage',
contentTitle: 'My page',
transition: 'publish',
});

await page.goto('/@@sharing/mypage');

await expect(page).toHaveURL(/\/login/);
await expect(page.locator('input[type="password"]')).toBeVisible();
});

test('As an editor, I can disable permission inheritance', async ({ page }) => {
await login(page);
await createContent(page, {
contentType: 'Document',
contentId: 'mypage',
contentTitle: 'My page',
transition: 'publish',
});

await page.goto('/@@sharing/mypage');

const inherit = page.getByRole('checkbox', {
name: 'Inherit permissions from higher levels',
});
await expect(inherit).toBeChecked();
await inherit.uncheck({ force: true });

await page.getByLabel('Save').click();
await expect(page).toHaveURL(/\/mypage$/);

await page.goto('/@@sharing/mypage');
await expect(
page.getByRole('checkbox', {
name: 'Inherit permissions from higher levels',
}),
).not.toBeChecked();
});

test('As an editor, I can grant a role to a group', async ({ page }) => {
await login(page);
await createContent(page, {
contentType: 'Document',
contentId: 'mypage',
contentTitle: 'My page',
transition: 'publish',
});

await page.goto('/@@sharing/mypage');
await expect(page.locator('h1', { hasText: 'My page' })).toBeVisible();

await page.getByLabel('Search for users and groups').fill('Reviewers');
await page.keyboard.press('Enter');

const reviewersRow = page.getByRole('row', { name: /Reviewers/ });
const canView = reviewersRow.getByRole('checkbox', { name: 'Can view' });
await expect(canView).not.toBeChecked();
await canView.check({ force: true });

await page.getByLabel('Save').click();
await expect(page).toHaveURL(/\/mypage$/);

await page.goto('/@@sharing/mypage?search=Reviewers');
await expect(
page.getByRole('row', { name: /Reviewers/ }).getByRole('checkbox', {
name: 'Can view',
}),
).toBeChecked();
});

test('As an editor, I cannot change my own roles', async ({ page }) => {
const hostname = process.env.BACKEND_HOST || '127.0.0.1';
const siteId = process.env.SITE_ID || 'plone';
const apiURL = process.env.API_PATH || `http://${hostname}:55001/${siteId}`;

// Add a portal user via the REST API (no tooling helper for users yet)
const userResponse = await page.request.post(`${apiURL}/@users`, {
headers: {
Accept: 'application/json',
Authorization: `Basic ${Buffer.from('admin:secret').toString('base64')}`,
},
data: {
username: 'some_test_user',
email: 'some_test_user@example.com',
password: 'some_secret_password',
roles: ['Manager'],
},
});
expect(userResponse.ok()).toBeTruthy();

await createContent(page, {
contentType: 'Document',
contentId: 'mypage',
contentTitle: 'My page',
transition: 'publish',
});

await login(page, {
username: 'some_test_user',
password: 'some_secret_password',
});

await page.goto('/@@sharing/mypage?search=some_test_user');

const ownRow = page.getByRole('row', { name: /some_test_user/ });
await expect(ownRow).toBeVisible();

const ownCheckboxes = ownRow.getByRole('checkbox');
const count = await ownCheckboxes.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(ownCheckboxes.nth(i)).toBeDisabled();
}
});
210 changes: 210 additions & 0 deletions packages/cmsui/components/Sharing/SharingForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { expect, describe, it, vi } from 'vitest';
import {
render,
screen,
fireEvent,
renderHook,
act,
} from '@testing-library/react';
import type { SharingEntry, SharingResponse } from '@plone/types';
import SharingForm, { useSharingEdits } from './SharingForm';

vi.mock('react-router', () => ({
Form: ({ children }: any) => <form>{children}</form>,
useFetcher: () => ({ state: 'idle', submit: vi.fn() }),
}));

vi.mock('@plone/components/Icons', () => ({
WorldIcon: () => null,
ArrowupIcon: () => null,
UserIcon: () => null,
SocialIcon: () => null,
CheckboxIcon: () => null,
}));

vi.mock('@plone/components/quanta', () => ({
Checkbox: ({ isSelected, isDisabled, onChange, children, ...props }: any) => (
<label>
{children}
<input
type="checkbox"
checked={!!isSelected}
disabled={isDisabled}
aria-label={props['aria-label']}
onChange={(e) => onChange(e.target.checked)}
/>
</label>
),
Table: ({ children, ...props }: any) => (
<table aria-label={props['aria-label']}>{children}</table>
),
TableHeader: ({ children }: any) => (
<thead>
<tr>{children}</tr>
</thead>
),
Column: ({ children }: any) => <th>{children}</th>,
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
Row: ({ children }: any) => <tr>{children}</tr>,
Cell: ({ children }: any) => <td>{children}</td>,
SearchField: ({ label, ...props }: any) => (
<input aria-label={label} {...props} />
),
Button: ({ children, onPress, isDisabled, ...props }: any) => (
<button
onClick={onPress}
disabled={isDisabled}
aria-label={props['aria-label']}
>
{children}
</button>
),
Description: ({ children, ...props }: any) => (
<div {...props}>{children}</div>
),
}));

describe('useSharingEdits', () => {
const availableRoles = [
{ id: 'Reader', title: 'Can view' },
{ id: 'Editor', title: 'Can edit' },
];
const editorsEntry: SharingEntry = {
id: 'editors',
title: 'Editors',
type: 'group',
login: '',
disabled: false,
roles: { Reader: false, Editor: true },
};
const inheritedEntry: SharingEntry = {
id: 'reviewers',
title: 'Reviewers',
type: 'group',
login: '',
disabled: false,
roles: { Reader: 'acquired', Editor: false },
};

it('starts without edits', () => {
const { result } = renderHook(() => useSharingEdits(true, ''));

expect(result.current.hasEdits).toBe(false);
expect(result.current.isSelected(editorsEntry, 'Reader')).toBe(false);
expect(result.current.isSelected(editorsEntry, 'Editor')).toBe(true);
expect(result.current.inherit).toBe(true);
});

it('tracks a toggled role and reverts when toggled back', () => {
const { result } = renderHook(() => useSharingEdits(true, ''));

act(() => result.current.toggle(editorsEntry, 'Reader', true));
expect(result.current.isSelected(editorsEntry, 'Reader')).toBe(true);
expect(result.current.hasEdits).toBe(true);

act(() => result.current.toggle(editorsEntry, 'Reader', false));
expect(result.current.isSelected(editorsEntry, 'Reader')).toBe(false);
expect(result.current.hasEdits).toBe(false);
});

it('counts an inherit change as an edit', () => {
const { result } = renderHook(() => useSharingEdits(true, ''));

act(() => result.current.setInherit(false));
expect(result.current.hasEdits).toBe(true);

act(() => result.current.setInherit(true));
expect(result.current.hasEdits).toBe(false);
});

it('clears entry edits but keeps the inherit toggle when the search changes', () => {
const { result, rerender } = renderHook(
({ search }) => useSharingEdits(true, search),
{ initialProps: { search: '' } },
);

act(() => result.current.toggle(editorsEntry, 'Reader', true));
act(() => result.current.setInherit(false));
expect(result.current.isSelected(editorsEntry, 'Reader')).toBe(true);

rerender({ search: 'username' });

expect(result.current.isSelected(editorsEntry, 'Reader')).toBe(false);
expect(result.current.inherit).toBe(false);
expect(result.current.hasEdits).toBe(true);
});

it('builds only changed entries, each with its full boolean role map', () => {
const { result } = renderHook(() => useSharingEdits(true, ''));

act(() => result.current.toggle(inheritedEntry, 'Editor', true));

const changed = result.current.buildChangedEntries(
[editorsEntry, inheritedEntry],
availableRoles,
);

expect(changed).toEqual([
{
id: 'reviewers',
type: 'group',
roles: { Editor: true },
},
]);
});
});

describe('SharingForm', () => {
const content = { '@id': '/my-page', title: 'My Page' };
const sharingData: SharingResponse = {
available_roles: [
{ id: 'Reader', title: 'Can view' },
{ id: 'Editor', title: 'Can edit' },
],
entries: [
{
id: 'editors',
title: 'Editors',
type: 'group',
login: '',
disabled: false,
roles: { Reader: false, Editor: true },
},
],
inherit: true,
};

it('discards unsaved edits when the search changes (same instance, no remount)', () => {
const { rerender } = render(
<SharingForm
content={content}
sharingData={sharingData}
search=""
currentUserId={null}
/>,
);

const reader = screen.getByRole('checkbox', { name: 'Can view' });
const save = screen.getByRole('button', { name: 'cmsui.save' });
expect(reader).not.toBeChecked();
expect(save).toBeDisabled();

fireEvent.click(reader);
expect(reader).toBeChecked();
expect(save).toBeEnabled();

rerender(
<SharingForm
content={content}
sharingData={sharingData}
search="username"
currentUserId={null}
/>,
);

expect(
screen.getByRole('checkbox', { name: 'Can view' }),
).not.toBeChecked();
expect(screen.getByRole('button', { name: 'cmsui.save' })).toBeDisabled();
});
});
Loading
Loading