wip
This commit is contained in:
@@ -2,4 +2,8 @@
|
||||
BURST_THROTTLE_RATES="200/minute"
|
||||
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
THEME_CUSTOMIZATION_FILE_PATH="" #force theme_customization to be empty
|
||||
#COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
||||
#Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export const createDoc = async (
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: isChild ? 'New page' : 'New doc',
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -220,6 +220,7 @@ export const updateDocTitle = async (page: Page, title: string) => {
|
||||
await input.click();
|
||||
await input.fill(title);
|
||||
await input.click();
|
||||
await input.blur();
|
||||
await verifyDocName(page, title);
|
||||
};
|
||||
|
||||
@@ -302,6 +303,24 @@ export const mockedListDocs = async (page: Page, data: object[] = []) => {
|
||||
};
|
||||
|
||||
export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
let result = [
|
||||
{
|
||||
id: '120ec765-43af-4602-83eb-7f4e1224548a',
|
||||
abilities: {
|
||||
destroy: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
created_at: '2024-10-03T12:19:26.107687Z',
|
||||
email: '[email protected]',
|
||||
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
|
||||
role: 'editor',
|
||||
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
|
||||
is_expired: false,
|
||||
...json,
|
||||
},
|
||||
];
|
||||
await page.route('**/invitations/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
@@ -309,41 +328,39 @@ export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
request.url().includes('invitations') &&
|
||||
request.url().includes('page=')
|
||||
) {
|
||||
console.log('GET');
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: 1,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: [
|
||||
{
|
||||
id: '120ec765-43af-4602-83eb-7f4e1224548a',
|
||||
abilities: {
|
||||
destroy: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
created_at: '2024-10-03T12:19:26.107687Z',
|
||||
email: '[email protected]',
|
||||
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
|
||||
role: 'editor',
|
||||
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
|
||||
is_expired: false,
|
||||
...json,
|
||||
},
|
||||
],
|
||||
results: result,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route(
|
||||
'**/invitations/120ec765-43af-4602-83eb-7f4e1224548a/**/',
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('DELETE')) {
|
||||
result = [];
|
||||
|
||||
await route.fulfill({
|
||||
json: {},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const mockedAccesses = async (page: Page, json?: object) => {
|
||||
await page.route('**/accesses/**/', async (route) => {
|
||||
const request = route.request();
|
||||
console.log('oui');
|
||||
|
||||
if (
|
||||
request.method().includes('GET') &&
|
||||
request.url().includes('accesses')
|
||||
|
||||
@@ -98,7 +98,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete the document "${randomDoc}"?`,
|
||||
`This document will be permanently deleted. This action is irreversible.`,
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -161,32 +161,31 @@ test.describe('Doc Header', () => {
|
||||
await expect(shareModal).toBeVisible();
|
||||
await expect(page.getByText('Share the document')).toBeVisible();
|
||||
|
||||
// await expect(page.getByPlaceholder('Type a name or email')).toBeVisible();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(invitationCard).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByText('[email protected]').first(),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-dropdown')).toBeVisible();
|
||||
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
|
||||
await expect(invitationRole).toBeVisible();
|
||||
|
||||
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
await invitationRole.click();
|
||||
|
||||
await expect(page.getByLabel('Delete')).toBeEnabled();
|
||||
await invitationCard.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove access' }).click();
|
||||
await expect(invitationCard).toBeHidden();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
const roles = memberCard.getByLabel('doc-role-dropdown');
|
||||
await expect(memberCard).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByText('[email protected]').first(),
|
||||
).toBeVisible();
|
||||
await expect(memberCard.getByLabel('doc-role-dropdown')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).toBeVisible();
|
||||
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
await expect(roles).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Delete')).toBeEnabled();
|
||||
await roles.click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Remove access' }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
test('it checks the options available if editor', async ({ page }) => {
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
import {
|
||||
addMemberToDoc,
|
||||
searchUserToInviteToDoc,
|
||||
updateShareLink,
|
||||
verifyLinkReachIsDisabled,
|
||||
verifyLinkReachIsEnabled,
|
||||
verifyLinkRoleIsDisabled,
|
||||
verifyLinkRoleIsEnabled,
|
||||
verifyMemberAddedToDoc,
|
||||
} from './share-utils';
|
||||
import { createRootSubPage, createSubPageFromParent } from './sub-pages-utils';
|
||||
import { updateShareLink } from './share-utils';
|
||||
import { createRootSubPage } from './sub-pages-utils';
|
||||
|
||||
test.describe('Inherited share accesses', () => {
|
||||
test('it checks inherited accesses', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
const [titleParent] = await createDoc(page, 'root-doc', browserName, 1);
|
||||
await createDoc(page, 'root-doc', browserName, 1);
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
|
||||
const addButton = page.getByRole('button', { name: 'New page' });
|
||||
// Wait for and intercept the POST request to create a new page
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -27,7 +17,7 @@ test.describe('Inherited share accesses', () => {
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
await addButton.click();
|
||||
await createRootSubPage(page, browserName, 'sub-page');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
@@ -41,10 +31,10 @@ test.describe('Inherited share accesses', () => {
|
||||
await expect(subPageItem).toBeVisible();
|
||||
await subPageItem.click();
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(page.getByText('Inherited share')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: titleParent })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'See access' }).click();
|
||||
await expect(page.getByText('Access inherited from the')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('People with access via the parent document'),
|
||||
).toBeVisible();
|
||||
|
||||
const user = page.getByTestId(
|
||||
`doc-share-member-row-user@${browserName}.e2e`,
|
||||
);
|
||||
@@ -52,58 +42,6 @@ test.describe('Inherited share accesses', () => {
|
||||
await expect(user.getByText('E2E Chromium')).toBeVisible();
|
||||
await expect(user.getByText('Owner')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks that the highest role is displayed', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
await createDoc(page, 'root-doc', browserName, 1);
|
||||
|
||||
// Search user to add
|
||||
let users = await searchUserToInviteToDoc(page);
|
||||
let userToAdd = users[0];
|
||||
|
||||
// Add user as Administrator in root doc
|
||||
await addMemberToDoc(page, 'Administrator', [userToAdd]);
|
||||
await verifyMemberAddedToDoc(page, userToAdd, 'Administrator');
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
// Create sub page
|
||||
const { name: subPageName, item: subPageJson } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'sub-page',
|
||||
);
|
||||
|
||||
// Add user as Editor in sub page
|
||||
users = await searchUserToInviteToDoc(page);
|
||||
userToAdd = users[0];
|
||||
await addMemberToDoc(page, 'Editor', [userToAdd]);
|
||||
const userRow = await verifyMemberAddedToDoc(page, userToAdd, 'Editor');
|
||||
await userRow.getByRole('button', { name: 'doc-role-dropdown' }).click();
|
||||
await page.getByText('This user has access').click();
|
||||
await userRow.click();
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
// Add new sub page to sub page
|
||||
await createSubPageFromParent(
|
||||
page,
|
||||
browserName,
|
||||
subPageJson.id,
|
||||
'sub-page-2',
|
||||
);
|
||||
|
||||
// // Check sub page inherited share
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(page.getByText('Inherited share')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: subPageName })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'See access' }).click();
|
||||
await expect(page.getByText('Access inherited from the')).toBeVisible();
|
||||
const user = page.getByTestId(`doc-share-member-row-${userToAdd.email}`);
|
||||
await expect(user).toBeVisible();
|
||||
await expect(user.getByText('Administrator')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Inherited share link', () => {
|
||||
@@ -122,87 +60,95 @@ test.describe('Inherited share link', () => {
|
||||
|
||||
// // verify share link is restricted and reader
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(page.getByText('Inherited share')).toBeVisible();
|
||||
// await verifyShareLink(page, 'Connected', 'Reading');
|
||||
// await expect(page.getByText('Inherited share')).toBeVisible();
|
||||
const docVisibilityCard = page.getByLabel('Doc visibility card');
|
||||
await expect(docVisibilityCard).toBeVisible();
|
||||
await expect(docVisibilityCard.getByText('Connected')).toBeVisible();
|
||||
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks warning message when sharing rules differ', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
// Create root doc
|
||||
await createDoc(page, 'root-doc', browserName, 1);
|
||||
/**
|
||||
* These tests are temporarily removed because we hide the ability to modify this parameter in sub-pages for now.
|
||||
* There is a high probability that this feature will not change and therefore the test won't either.
|
||||
*/
|
||||
|
||||
// Update share link
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await updateShareLink(page, 'Connected', 'Reading');
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
// test('it checks warning message when sharing rules differ', async ({
|
||||
// page,
|
||||
// browserName,
|
||||
// }) => {
|
||||
// await page.goto('/');
|
||||
// // Create root doc
|
||||
// await createDoc(page, 'root-doc', browserName, 1);
|
||||
|
||||
// Create sub page
|
||||
await createRootSubPage(page, browserName, 'sub-page');
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
// // Update share link
|
||||
// await page.getByRole('button', { name: 'Share' }).click();
|
||||
// await updateShareLink(page, 'Connected', 'Reading');
|
||||
// await page.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
// Update share link to public and edition
|
||||
await updateShareLink(page, 'Public', 'Edition');
|
||||
await expect(page.getByText('Sharing rules differ from the')).toBeVisible();
|
||||
const restoreButton = page.getByRole('button', { name: 'Restore' });
|
||||
await expect(restoreButton).toBeVisible();
|
||||
await restoreButton.click();
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated').first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Sharing rules differ from the')).toBeHidden();
|
||||
});
|
||||
// // Create sub page
|
||||
// await createRootSubPage(page, browserName, 'sub-page');
|
||||
// await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
test('it checks inherited link possibilities', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
// Create root doc
|
||||
await createDoc(page, 'root-doc', browserName, 1);
|
||||
// // Update share link to public and edition
|
||||
// await updateShareLink(page, 'Public', 'Edition');
|
||||
// await expect(page.getByText('Sharing rules differ from the')).toBeVisible();
|
||||
// const restoreButton = page.getByRole('button', { name: 'Restore' });
|
||||
// await expect(restoreButton).toBeVisible();
|
||||
// await restoreButton.click();
|
||||
// await expect(
|
||||
// page.getByText('The document visibility has been updated').first(),
|
||||
// ).toBeVisible();
|
||||
// await expect(page.getByText('Sharing rules differ from the')).toBeHidden();
|
||||
// });
|
||||
|
||||
// Update share link
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await updateShareLink(page, 'Connected', 'Reading');
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(
|
||||
page.getByText('Document accessible to any connected person'),
|
||||
).toBeVisible();
|
||||
// test('it checks inherited link possibilities', async ({
|
||||
// page,
|
||||
// browserName,
|
||||
// }) => {
|
||||
// await page.goto('/');
|
||||
// // Create root doc
|
||||
// await createDoc(page, 'root-doc', browserName, 1);
|
||||
|
||||
// Create sub page
|
||||
const { item: subPageItem } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'sub-page',
|
||||
);
|
||||
await expect(
|
||||
page.getByText('Document accessible to any connected person'),
|
||||
).toBeVisible();
|
||||
// // Update share link
|
||||
// await page.getByRole('button', { name: 'Share' }).click();
|
||||
// await updateShareLink(page, 'Connected', 'Reading');
|
||||
// await page.getByRole('button', { name: 'OK' }).click();
|
||||
// await expect(
|
||||
// page.getByText('Document accessible to any connected person'),
|
||||
// ).toBeVisible();
|
||||
|
||||
// Update share link to public and edition
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await verifyLinkReachIsDisabled(page, 'Private');
|
||||
await updateShareLink(page, 'Public', 'Edition');
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(page.getByText('Public document')).toBeVisible();
|
||||
// // Create sub page
|
||||
// const { item: subPageItem } = await createRootSubPage(
|
||||
// page,
|
||||
// browserName,
|
||||
// 'sub-page',
|
||||
// );
|
||||
// await expect(
|
||||
// page.getByText('Document accessible to any connected person'),
|
||||
// ).toBeVisible();
|
||||
|
||||
// Create sub page
|
||||
await createSubPageFromParent(
|
||||
page,
|
||||
browserName,
|
||||
subPageItem.id,
|
||||
'sub-page-2',
|
||||
);
|
||||
await expect(page.getByText('Public document')).toBeVisible();
|
||||
// // Update share link to public and edition
|
||||
// await page.getByRole('button', { name: 'Share' }).click();
|
||||
// await verifyLinkReachIsDisabled(page, 'Private');
|
||||
// await updateShareLink(page, 'Public', 'Edition');
|
||||
// await page.getByRole('button', { name: 'OK' }).click();
|
||||
// await expect(page.getByText('Public document')).toBeVisible();
|
||||
|
||||
// Verify share link and role
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await verifyLinkReachIsDisabled(page, 'Private');
|
||||
await verifyLinkReachIsDisabled(page, 'Connected');
|
||||
await verifyLinkReachIsEnabled(page, 'Public');
|
||||
await verifyLinkRoleIsDisabled(page, 'Reading');
|
||||
await verifyLinkRoleIsEnabled(page, 'Edition');
|
||||
});
|
||||
// // Create sub page
|
||||
// await createSubPageFromParent(
|
||||
// page,
|
||||
// browserName,
|
||||
// subPageItem.id,
|
||||
// 'sub-page-2',
|
||||
// );
|
||||
// await expect(page.getByText('Public document')).toBeVisible();
|
||||
|
||||
// // Verify share link and role
|
||||
// await page.getByRole('button', { name: 'Share' }).click();
|
||||
// await verifyLinkReachIsDisabled(page, 'Private');
|
||||
// await verifyLinkReachIsDisabled(page, 'Connected');
|
||||
// await verifyLinkReachIsEnabled(page, 'Public');
|
||||
// await verifyLinkRoleIsDisabled(page, 'Reading');
|
||||
// await verifyLinkRoleIsEnabled(page, 'Edition');
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe('Document list members', () => {
|
||||
|
||||
// Get the current URL and extract the last part
|
||||
const currentUrl = page.url();
|
||||
console.log('Current URL:', currentUrl);
|
||||
|
||||
const currentDocId = (() => {
|
||||
// Remove trailing slash if present
|
||||
const cleanUrl = currentUrl.endsWith('/')
|
||||
@@ -184,17 +184,14 @@ test.describe('Document list members', () => {
|
||||
|
||||
const emailMyself = `user@${browserName}.test`;
|
||||
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
|
||||
const mySelfMoreActions = mySelf.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
const mySelfRole = mySelf.getByRole('button', {
|
||||
name: 'doc-role-dropdown',
|
||||
});
|
||||
|
||||
const userOwnerEmail = await addNewMember(page, 0, 'Owner');
|
||||
const userOwner = list.getByTestId(
|
||||
`doc-share-member-row-${userOwnerEmail}`,
|
||||
);
|
||||
const userOwnerMoreActions = userOwner.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
await page.getByRole('button', { name: 'Share' }).first().click();
|
||||
@@ -204,24 +201,21 @@ test.describe('Document list members', () => {
|
||||
const userReader = list.getByTestId(
|
||||
`doc-share-member-row-${userReaderEmail}`,
|
||||
);
|
||||
const userReaderMoreActions = userReader.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
const userReaderRole = userReader.getByRole('button', {
|
||||
name: 'doc-role-dropdown',
|
||||
});
|
||||
|
||||
|
||||
await expect(mySelf).toBeVisible();
|
||||
await expect(userOwner).toBeVisible();
|
||||
await expect(userReader).toBeVisible();
|
||||
|
||||
await expect(userOwnerMoreActions).toBeVisible();
|
||||
await expect(userReaderMoreActions).toBeVisible();
|
||||
await expect(mySelfMoreActions).toBeVisible();
|
||||
|
||||
await userReaderMoreActions.click();
|
||||
await page.getByLabel('Delete').click();
|
||||
await userReaderRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove access' }).click();
|
||||
await expect(userReader).toBeHidden();
|
||||
|
||||
await mySelfMoreActions.click();
|
||||
await page.getByLabel('Delete').click();
|
||||
await mySelfRole.click();
|
||||
await page.getByRole('menuitem', { name: 'Remove access' }).click();
|
||||
await expect(
|
||||
page.getByText('Insufficient access rights to view the document.'),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -154,7 +154,7 @@ test.describe('Sub page search', () => {
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, doc1Title);
|
||||
await page.getByRole('button', { name: 'New page' }).click();
|
||||
await page.getByRole('button', { name: 'New doc' }).click();
|
||||
await verifyDocName(page, '');
|
||||
await page.getByRole('textbox', { name: 'doc title input' }).click();
|
||||
await page
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
expectLoginPage,
|
||||
keyCloakSignIn,
|
||||
randomName,
|
||||
updateDocTitle,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
import { clickOnAddRootSubPage, createRootSubPage } from './sub-pages-utils';
|
||||
|
||||
test.describe('Doc Tree', () => {
|
||||
test('create new sub pages', async ({ page, browserName }) => {
|
||||
@@ -19,7 +21,7 @@ test.describe('Doc Tree', () => {
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, titleParent);
|
||||
const addButton = page.getByRole('button', { name: 'New page' });
|
||||
const addButton = page.getByRole('button', { name: 'New doc' });
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
|
||||
await expect(addButton).toBeVisible();
|
||||
@@ -32,7 +34,7 @@ test.describe('Doc Tree', () => {
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await addButton.click();
|
||||
await clickOnAddRootSubPage(page);
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const subPageJson = await response.json();
|
||||
@@ -58,7 +60,7 @@ test.describe('Doc Tree', () => {
|
||||
test('check the reorder of sub pages', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await createDoc(page, 'doc-tree-content', browserName, 1);
|
||||
const addButton = page.getByRole('button', { name: 'New page' });
|
||||
const addButton = page.getByRole('button', { name: 'New doc' });
|
||||
await expect(addButton).toBeVisible();
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
@@ -71,9 +73,10 @@ test.describe('Doc Tree', () => {
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await addButton.click();
|
||||
await clickOnAddRootSubPage(page);
|
||||
const firstResponse = await firstResponsePromise;
|
||||
expect(firstResponse.ok()).toBeTruthy();
|
||||
await updateDocTitle(page, 'first');
|
||||
|
||||
const secondResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -83,9 +86,10 @@ test.describe('Doc Tree', () => {
|
||||
);
|
||||
|
||||
// Create second sub page
|
||||
await addButton.click();
|
||||
await clickOnAddRootSubPage(page);
|
||||
const secondResponse = await secondResponsePromise;
|
||||
expect(secondResponse.ok()).toBeTruthy();
|
||||
await updateDocTitle(page, 'second');
|
||||
|
||||
const secondSubPageJson = await secondResponse.json();
|
||||
const firstSubPageJson = await firstResponse.json();
|
||||
@@ -123,8 +127,8 @@ test.describe('Doc Tree', () => {
|
||||
|
||||
await page.mouse.move(
|
||||
secondSubPageBoundingBox.x + secondSubPageBoundingBox.width / 2,
|
||||
secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 4,
|
||||
{ steps: 10 },
|
||||
secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 2,
|
||||
{ steps: 20 },
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
@@ -170,16 +174,15 @@ test.describe('Doc Tree', () => {
|
||||
);
|
||||
await verifyDocName(page, docParent);
|
||||
|
||||
const [docChild] = await createDoc(
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
'doc-tree-detach-child',
|
||||
browserName,
|
||||
1,
|
||||
true,
|
||||
'doc-tree-detach-child',
|
||||
);
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||
await docTree.click();
|
||||
const child = docTree
|
||||
.getByRole('treeitem')
|
||||
.locator('.--docs-sub-page-item')
|
||||
@@ -189,7 +192,7 @@ test.describe('Doc Tree', () => {
|
||||
await child.hover();
|
||||
const menu = child.getByText(`more_horiz`);
|
||||
await menu.click();
|
||||
await page.getByText('Convert to doc').click();
|
||||
await page.getByText('Move to my docs').click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'doc title input' }),
|
||||
@@ -232,14 +235,11 @@ test.describe('Doc Tree: Inheritance', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const [docChild] = await createDoc(
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
'doc-tree-inheritance-child',
|
||||
browserName,
|
||||
1,
|
||||
true,
|
||||
'doc-tree-inheritance-child',
|
||||
);
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -258,62 +258,4 @@ test.describe('Doc Tree: Inheritance', () => {
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText(docParent)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Do not show private parent from children', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
const [docParent] = await createDoc(
|
||||
page,
|
||||
'doc-tree-inheritance-private-parent',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, docParent);
|
||||
|
||||
const [docChild] = await createDoc(
|
||||
page,
|
||||
'doc-tree-inheritance-private-child',
|
||||
browserName,
|
||||
1,
|
||||
true,
|
||||
);
|
||||
await verifyDocName(page, docChild);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Logout',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expectLoginPage(page);
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docChild)).toBeVisible();
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText(docParent)).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -246,7 +246,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'search' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New page' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -262,7 +262,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'search' })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'New page' })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'New padoce' })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
@@ -8,11 +8,10 @@ export const createRootSubPage = async (
|
||||
docName: string,
|
||||
) => {
|
||||
// Get add button
|
||||
const addButton = page.getByRole('button', { name: 'New page' });
|
||||
|
||||
// Get response
|
||||
const responsePromise = getWaitForCreateDoc(page);
|
||||
await addButton.click();
|
||||
await clickOnAddRootSubPage(page);
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const subPageJson = (await response.json()) as { id: string };
|
||||
@@ -36,6 +35,13 @@ export const createRootSubPage = async (
|
||||
return { name: randomDocs[0], docTreeItem: subPageItem, item: subPageJson };
|
||||
};
|
||||
|
||||
export const clickOnAddRootSubPage = async (page: Page) => {
|
||||
const rootItem = page.getByTestId('doc-tree-root-item');
|
||||
await expect(rootItem).toBeVisible();
|
||||
await rootItem.hover();
|
||||
await rootItem.getByRole('button', { name: 'add_box' }).click();
|
||||
};
|
||||
|
||||
export const createSubPageFromParent = async (
|
||||
page: Page,
|
||||
browserName: string,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '../Box';
|
||||
import { Text } from '../Text';
|
||||
|
||||
export type AlertModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description: string | React.ReactNode;
|
||||
onConfirm: () => void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
};
|
||||
|
||||
export const AlertModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
}: AlertModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
size={ModalSize.MEDIUM}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Text $size="h6" $align="flex-start" $variation="1000">
|
||||
{title}
|
||||
</Text>
|
||||
}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('Close the modal')}
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{cancelLabel ?? t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={confirmLabel ?? t('Confirm')}
|
||||
color="danger"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel ?? t('Confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Confirmation button')}
|
||||
className="--docs--alert-modal"
|
||||
>
|
||||
<Box>
|
||||
<Text $variation="600">{description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PropsWithChildren, useRef, useState } from 'react';
|
||||
import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
|
||||
import { Fragment, PropsWithChildren, useRef, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
|
||||
@@ -14,6 +15,7 @@ export type DropdownMenuOption = {
|
||||
isSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
show?: boolean;
|
||||
showSeparator?: boolean;
|
||||
};
|
||||
|
||||
export type DropdownMenuProps = {
|
||||
@@ -112,71 +114,76 @@ export const DropdownMenu = ({
|
||||
}
|
||||
const isDisabled = option.disabled !== undefined && option.disabled;
|
||||
return (
|
||||
<BoxButton
|
||||
role="menuitem"
|
||||
aria-label={option.label}
|
||||
data-testid={option.testId}
|
||||
$direction="row"
|
||||
disabled={isDisabled}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenChange?.(false);
|
||||
void option.callback?.();
|
||||
}}
|
||||
key={option.label}
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$background={colorsTokens['greyscale-000']}
|
||||
$color={colorsTokens['primary-600']}
|
||||
$padding={{ vertical: 'xs', horizontal: 'base' }}
|
||||
$width="100%"
|
||||
$gap={spacingsTokens['base']}
|
||||
$css={css`
|
||||
border: none;
|
||||
${index === 0 &&
|
||||
css`
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
`}
|
||||
${index === options.length - 1 &&
|
||||
css`
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
`}
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
font-weight: 500;
|
||||
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-050);
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
<Fragment key={option.label}>
|
||||
<BoxButton
|
||||
role="menuitem"
|
||||
aria-label={option.label}
|
||||
data-testid={option.testId}
|
||||
$direction="row"
|
||||
disabled={isDisabled}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenChange?.(false);
|
||||
void option.callback?.();
|
||||
}}
|
||||
key={option.label}
|
||||
$align="center"
|
||||
$justify="space-between"
|
||||
$background={colorsTokens['greyscale-000']}
|
||||
$color={colorsTokens['primary-600']}
|
||||
$padding={{ vertical: 'xs', horizontal: 'base' }}
|
||||
$width="100%"
|
||||
$gap={spacingsTokens['base']}
|
||||
$css={css`
|
||||
border: none;
|
||||
${index === 0 &&
|
||||
css`
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
`}
|
||||
${index === options.length - 1 &&
|
||||
css`
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
`}
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-1000);
|
||||
font-weight: 500;
|
||||
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-050);
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.icon && (
|
||||
<Icon
|
||||
$size="20px"
|
||||
$theme="greyscale"
|
||||
$variation={isDisabled ? '400' : '1000'}
|
||||
iconName={option.icon}
|
||||
/>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$gap={spacingsTokens['base']}
|
||||
>
|
||||
{option.icon && (
|
||||
<Icon
|
||||
$size="20px"
|
||||
$theme="greyscale"
|
||||
$variation={isDisabled ? '400' : '1000'}
|
||||
iconName={option.icon}
|
||||
/>
|
||||
)}
|
||||
<Text $variation={isDisabled ? '400' : '1000'}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{(option.isSelected ||
|
||||
selectedValues?.includes(option.value ?? '')) && (
|
||||
<Icon iconName="check" $size="20px" $theme="greyscale" />
|
||||
)}
|
||||
<Text $variation={isDisabled ? '400' : '1000'}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{(option.isSelected ||
|
||||
selectedValues?.includes(option.value ?? '')) && (
|
||||
<Icon iconName="check" $size="20px" $theme="greyscale" />
|
||||
</BoxButton>
|
||||
{option.showSeparator && (
|
||||
<HorizontalSeparator withPadding={false} />
|
||||
)}
|
||||
</BoxButton>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const QuickSearchGroup = <T,>({
|
||||
renderElement,
|
||||
}: Props<T>) => {
|
||||
return (
|
||||
<Box $margin={{ top: 'base' }}>
|
||||
<Box $margin={{ top: 'sm' }}>
|
||||
<Command.Group
|
||||
key={group.groupName}
|
||||
heading={group.groupName}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const QuickSearchInput = ({
|
||||
$align="center"
|
||||
className="quick-search-input"
|
||||
$gap={spacingsTokens['2xs']}
|
||||
$padding={{ all: 'base' }}
|
||||
$padding={{ horizontal: 'base', vertical: 'sm' }}
|
||||
>
|
||||
{!loading && <Icon iconName="search" $variation="600" />}
|
||||
{loading && (
|
||||
|
||||
@@ -24,7 +24,7 @@ export const QuickSearchItemContent = ({
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$padding={{ horizontal: '2xs', vertical: '3xs' }}
|
||||
$padding={{ horizontal: '2xs', vertical: '4xs' }}
|
||||
$justify="space-between"
|
||||
$width="100%"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Spacings } from '@/utils';
|
||||
|
||||
import { Box } from '../Box';
|
||||
|
||||
@@ -10,19 +11,25 @@ export enum SeparatorVariant {
|
||||
type Props = {
|
||||
variant?: SeparatorVariant;
|
||||
$withPadding?: boolean;
|
||||
customPadding?: Spacings;
|
||||
};
|
||||
|
||||
export const HorizontalSeparator = ({
|
||||
variant = SeparatorVariant.LIGHT,
|
||||
$withPadding = true,
|
||||
customPadding,
|
||||
}: Props) => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const padding = $withPadding
|
||||
? (customPadding ?? 'base')
|
||||
: ('none' as Spacings);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$height="1px"
|
||||
$width="100%"
|
||||
$margin={{ vertical: $withPadding ? 'base' : 'none' }}
|
||||
$margin={{ vertical: padding }}
|
||||
$background={
|
||||
variant === SeparatorVariant.DARK
|
||||
? '#e5e5e533'
|
||||
|
||||
@@ -117,6 +117,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
doc={doc}
|
||||
modalHistory={modalHistory}
|
||||
modalShare={modalShare}
|
||||
isRootDoc={treeContext?.root?.id === doc.id}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
+7
-1
@@ -37,12 +37,14 @@ interface DocToolBoxLicenceProps {
|
||||
doc: Doc;
|
||||
modalHistory: ModalType;
|
||||
modalShare: ModalType;
|
||||
isRootDoc?: boolean;
|
||||
}
|
||||
|
||||
export const DocToolBoxLicenceAGPL = ({
|
||||
doc,
|
||||
modalHistory,
|
||||
modalShare,
|
||||
isRootDoc = true,
|
||||
}: DocToolBoxLicenceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -176,7 +178,11 @@ export const DocToolBoxLicenceAGPL = ({
|
||||
</DropdownMenu>
|
||||
|
||||
{modalShare.isOpen && (
|
||||
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
|
||||
<DocShareModal
|
||||
onClose={() => modalShare.close()}
|
||||
doc={doc}
|
||||
isRootDoc={isRootDoc}
|
||||
/>
|
||||
)}
|
||||
{isModalExportOpen && (
|
||||
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />
|
||||
|
||||
+7
-1
@@ -31,12 +31,14 @@ interface DocToolBoxLicenceProps {
|
||||
doc: Doc;
|
||||
modalHistory: ModalType;
|
||||
modalShare: ModalType;
|
||||
isRootDoc?: boolean;
|
||||
}
|
||||
|
||||
export const DocToolBoxLicenceMIT = ({
|
||||
doc,
|
||||
modalHistory,
|
||||
modalShare,
|
||||
isRootDoc = true,
|
||||
}: DocToolBoxLicenceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -152,7 +154,11 @@ export const DocToolBoxLicenceMIT = ({
|
||||
</DropdownMenu>
|
||||
|
||||
{modalShare.isOpen && (
|
||||
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
|
||||
<DocShareModal
|
||||
onClose={() => modalShare.close()}
|
||||
doc={doc}
|
||||
isRootDoc={isRootDoc}
|
||||
/>
|
||||
)}
|
||||
{isModalRemoveOpen && (
|
||||
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
|
||||
|
||||
+10
-8
@@ -12,7 +12,6 @@ import { useRouter } from 'next/router';
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
|
||||
import { useRemoveDoc } from '../api/useRemoveDoc';
|
||||
import { useTrans } from '../hooks';
|
||||
import { Doc } from '../types';
|
||||
|
||||
interface ModalRemoveDocProps {
|
||||
@@ -29,7 +28,8 @@ export const ModalRemoveDoc = ({
|
||||
const { toast } = useToastProvider();
|
||||
const { push } = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { untitledDocument } = useTrans(doc);
|
||||
|
||||
const hasChildren = doc.numchild && doc.numchild > 0;
|
||||
|
||||
const {
|
||||
mutate: removeDoc,
|
||||
@@ -82,7 +82,7 @@ export const ModalRemoveDoc = ({
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.SMALL}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Text
|
||||
$size="h6"
|
||||
@@ -100,11 +100,13 @@ export const ModalRemoveDoc = ({
|
||||
className="--docs--modal-remove-doc"
|
||||
>
|
||||
{!isError && (
|
||||
<Text $size="sm" $variation="600">
|
||||
{t('Are you sure you want to delete the document "{{title}}"?', {
|
||||
title: doc.title ?? untitledDocument,
|
||||
})}
|
||||
</Text>
|
||||
<>
|
||||
<Text $size="sm" $variation="600">
|
||||
{t(
|
||||
'This document will be permanently deleted. This action is irreversible.',
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isError && <TextErrors causes={error.cause} />}
|
||||
|
||||
+4
-1
@@ -74,7 +74,10 @@ export const DocSearchModal = ({
|
||||
loading={loading}
|
||||
onFilter={handleInputSearch}
|
||||
>
|
||||
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
|
||||
<Box
|
||||
$padding={{ horizontal: '10px' }}
|
||||
$height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}
|
||||
>
|
||||
{showFilters && (
|
||||
<DocSearchFilters
|
||||
values={filters}
|
||||
|
||||
@@ -15,7 +15,7 @@ export type DocInvitationsParams = {
|
||||
};
|
||||
|
||||
export type DocInvitationsAPIParams = DocInvitationsParams & {
|
||||
page: number;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
type DocInvitationsResponse = APIList<Invitation>;
|
||||
|
||||
+46
-176
@@ -1,90 +1,24 @@
|
||||
import { Button, Modal, ModalSize, useModal } from '@openfun/cunningham-react';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Box, StyledLink, Text } from '@/components';
|
||||
import { Box, HorizontalSeparator, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import {
|
||||
Access,
|
||||
RoleImportance,
|
||||
useDoc,
|
||||
useDocStore,
|
||||
} from '../../doc-management';
|
||||
import SimpleFileIcon from '../../docs-grid/assets/simple-document.svg';
|
||||
import { Access, useDocStore } from '../../doc-management';
|
||||
|
||||
import { DocShareMemberItem } from './DocShareMemberItem';
|
||||
const ShareModalStyle = createGlobalStyle`
|
||||
.c__modal__title {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
.c__modal__scroller {
|
||||
padding: 15px 15px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
rawAccesses: Access[];
|
||||
};
|
||||
|
||||
const getMaxRoleBetweenAccesses = (access1: Access, access2: Access) => {
|
||||
const role1 = access1.max_role;
|
||||
const role2 = access2.max_role;
|
||||
|
||||
const roleImportance1 = RoleImportance[role1];
|
||||
const roleImportance2 = RoleImportance[role2];
|
||||
|
||||
return roleImportance1 > roleImportance2 ? role1 : role2;
|
||||
};
|
||||
|
||||
export const DocInheritedShareContent = ({ rawAccesses }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { currentDoc } = useDocStore();
|
||||
|
||||
const inheritedData = useMemo(() => {
|
||||
if (!currentDoc || rawAccesses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parentId = null;
|
||||
let parentPathLength = 0;
|
||||
const members: Access[] = [];
|
||||
|
||||
// Find the parent document with the longest path that is different from currentDoc
|
||||
for (const access of rawAccesses) {
|
||||
const docPath = access.document.path;
|
||||
|
||||
// Skip if it's the current document
|
||||
if (access.document.id === currentDoc.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const findIndex = members.findIndex(
|
||||
(member) => member.user.id === access.user.id,
|
||||
);
|
||||
if (findIndex === -1) {
|
||||
members.push(access);
|
||||
} else {
|
||||
const accessToUpdate = members[findIndex];
|
||||
const currentRole = accessToUpdate.max_role;
|
||||
const maxRole = getMaxRoleBetweenAccesses(accessToUpdate, access);
|
||||
|
||||
if (maxRole !== currentRole) {
|
||||
members[findIndex] = access;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this document has a longer path than our current candidate
|
||||
if (docPath && (!parentId || docPath.length > parentPathLength)) {
|
||||
parentId = access.document.id;
|
||||
parentPathLength = docPath.length;
|
||||
}
|
||||
}
|
||||
|
||||
return { parentId, members };
|
||||
}, [currentDoc, rawAccesses]);
|
||||
const router = useRouter();
|
||||
|
||||
// Check if accesses map is empty
|
||||
const hasAccesses = rawAccesses.length > 0;
|
||||
@@ -93,114 +27,50 @@ export const DocInheritedShareContent = ({ rawAccesses }: Props) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box $gap={spacingsTokens.sm}>
|
||||
<Box
|
||||
$gap={spacingsTokens.sm}
|
||||
$padding={{
|
||||
horizontal: spacingsTokens.base,
|
||||
vertical: spacingsTokens.sm,
|
||||
bottom: '0px',
|
||||
}}
|
||||
>
|
||||
<Text $variation="1000" $weight="bold" $size="sm">
|
||||
{t('Inherited share')}
|
||||
</Text>
|
||||
|
||||
{inheritedData && (
|
||||
<DocInheritedShareContentItem
|
||||
key={inheritedData?.parentId}
|
||||
accesses={inheritedData?.members ?? []}
|
||||
document_id={inheritedData?.parentId ?? ''}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
type DocInheritedShareContentItemProps = {
|
||||
accesses: Access[];
|
||||
document_id: string;
|
||||
};
|
||||
export const DocInheritedShareContentItem = ({
|
||||
accesses,
|
||||
document_id,
|
||||
}: DocInheritedShareContentItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { data: doc, error, isLoading } = useDoc({ id: document_id });
|
||||
const errorCode = error?.status;
|
||||
|
||||
const accessModal = useModal();
|
||||
if ((!doc && !isLoading && !error) || (error && errorCode !== 403)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
$gap={spacingsTokens.sm}
|
||||
$width="100%"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$margin={{ bottom: spacingsTokens.sm }}
|
||||
$justify="space-between"
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens.sm}>
|
||||
<SimpleFileIcon />
|
||||
<Box>
|
||||
{isLoading ? (
|
||||
<Box $direction="column" $gap="2px">
|
||||
<Box className="skeleton" $width="150px" $height="20px" />
|
||||
<Box className="skeleton" $width="200px" $height="17px" />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<StyledLink href={`/docs/${doc?.id}`}>
|
||||
<Text $variation="1000" $weight="bold" $size="sm">
|
||||
{error && errorCode === 403
|
||||
? t('You do not have permission to view this document')
|
||||
: (doc?.title ?? t('Untitled document'))}
|
||||
</Text>
|
||||
</StyledLink>
|
||||
<Text $variation="600" $weight="400" $size="xs">
|
||||
{t('Members of this page have access')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{!isLoading && (
|
||||
<Button color="primary-text" size="small" onClick={accessModal.open}>
|
||||
{t('See access')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{accessModal.isOpen && (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={accessModal.close}
|
||||
title={
|
||||
<Box $align="flex-start">
|
||||
<Text $variation="1000" $weight="bold" $size="sm">
|
||||
{t('Access inherited from the parent page')}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
<Box $gap={spacingsTokens.sm} $padding={{ top: spacingsTokens.sm }}>
|
||||
<HorizontalSeparator $withPadding={false} />
|
||||
<Box
|
||||
$gap={spacingsTokens.sm}
|
||||
$padding={{
|
||||
horizontal: spacingsTokens.base,
|
||||
// vertical: spacingsTokens.sm,
|
||||
// bottom: '0px',
|
||||
}}
|
||||
>
|
||||
<ShareModalStyle />
|
||||
<Box $padding={{ top: spacingsTokens.sm }}>
|
||||
{accesses.map((access) => (
|
||||
<Fragment key={access.id}>
|
||||
<DocShareMemberItem doc={doc} access={access} isInherited />
|
||||
</Fragment>
|
||||
))}
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens['4xs']}>
|
||||
<Text $variation="1000" $weight="bold" $size="sm">
|
||||
{t('People with access via the parent document')}
|
||||
</Text>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void router.push(`/docs/${rawAccesses[0].document.id}`);
|
||||
}}
|
||||
size="small"
|
||||
icon={
|
||||
<Icon
|
||||
$theme="greyscale"
|
||||
$variation="600"
|
||||
iconName="open_in_new"
|
||||
/>
|
||||
}
|
||||
color="tertiary-text"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</Modal>
|
||||
)}
|
||||
{rawAccesses.map((access) => (
|
||||
<Fragment key={access.id}>
|
||||
<DocShareMemberItem
|
||||
doc={currentDoc}
|
||||
access={access}
|
||||
isInherited
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+87
-4
@@ -1,11 +1,24 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { DropdownMenu, DropdownMenuOption, Text } from '@/components';
|
||||
import { Role, useTrans } from '@/docs/doc-management/';
|
||||
import {
|
||||
Access,
|
||||
Doc,
|
||||
KEY_SUB_PAGE,
|
||||
Role,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management/';
|
||||
|
||||
import { useDeleteDocAccess, useDeleteDocInvitation } from '../api';
|
||||
import { Invitation, isInvitation } from '../types';
|
||||
|
||||
type DocRoleDropdownProps = {
|
||||
doc?: Doc;
|
||||
access?: Access | Invitation;
|
||||
canUpdate?: boolean;
|
||||
currentRole: Role;
|
||||
message?: string;
|
||||
@@ -18,10 +31,65 @@ export const DocRoleDropdown = ({
|
||||
currentRole,
|
||||
message,
|
||||
onSelectRole,
|
||||
doc,
|
||||
rolesAllowed,
|
||||
access,
|
||||
}: DocRoleDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { transRole, translatedRoles } = useTrans();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToastProvider();
|
||||
|
||||
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
|
||||
onSuccess: () => {
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('doc HERE', doc);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(
|
||||
error?.data?.role?.[0] ?? t('Error during delete invitation'),
|
||||
VariantType.ERROR,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: removeDocAccess } = useDeleteDocAccess({
|
||||
onSuccess: () => {
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast(t('Error while deleting invitation'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onRemove = () => {
|
||||
const invitation = isInvitation(access);
|
||||
if (!doc || !access) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (invitation) {
|
||||
removeDocInvitation({ invitationId: access.id, docId: doc.id });
|
||||
} else {
|
||||
removeDocAccess({ accessId: access.id, docId: doc.id });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When there is a higher role, the rolesAllowed are truncated
|
||||
@@ -44,14 +112,17 @@ export const DocRoleDropdown = ({
|
||||
}, [canUpdate, rolesAllowed, translatedRoles, message, t]);
|
||||
|
||||
const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map(
|
||||
(key) => {
|
||||
(key, index) => {
|
||||
const isLast = index === Object.keys(translatedRoles).length - 1;
|
||||
return {
|
||||
label: transRole(key as Role),
|
||||
callback: () => onSelectRole?.(key as Role),
|
||||
isSelected: currentRole === (key as Role),
|
||||
showSeparator: isLast,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (!canUpdate) {
|
||||
return (
|
||||
<Text aria-label="doc-role-text" $variation="600">
|
||||
@@ -59,15 +130,27 @@ export const DocRoleDropdown = ({
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
topMessage={topMessage}
|
||||
label="doc-role-dropdown"
|
||||
showArrow={true}
|
||||
options={roles}
|
||||
arrowCss={css`
|
||||
color: var(--c--theme--colors--primary-800) !important;
|
||||
`}
|
||||
options={[
|
||||
...roles,
|
||||
{
|
||||
label: t('Remove access'),
|
||||
disabled: !access?.abilities.destroy,
|
||||
callback: onRemove,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
$variation="600"
|
||||
$theme="primary"
|
||||
$variation="800"
|
||||
$css={css`
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
`}
|
||||
|
||||
+2
@@ -122,6 +122,8 @@ export const DocShareInvitationItem = ({
|
||||
currentRole={invitation.role}
|
||||
onSelectRole={onUpdate}
|
||||
canUpdate={canUpdate}
|
||||
doc={doc}
|
||||
access={invitation}
|
||||
/>
|
||||
|
||||
{canUpdate && (
|
||||
|
||||
+5
-95
@@ -1,21 +1,13 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Box,
|
||||
DropdownMenu,
|
||||
DropdownMenuOption,
|
||||
IconOptions,
|
||||
} from '@/components';
|
||||
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useDeleteDocAccess, useDocAccesses, useUpdateDocAccess } from '../api';
|
||||
import { useWhoAmI } from '../hooks';
|
||||
import { useUpdateDocAccess } from '../api';
|
||||
import { useWhoAmI } from '../hooks/';
|
||||
|
||||
import { DocRoleDropdown } from './DocRoleDropdown';
|
||||
import { SearchUserRow } from './SearchUserRow';
|
||||
@@ -35,7 +27,6 @@ export const DocShareMemberItem = ({
|
||||
const { isLastOwner } = useWhoAmI(access);
|
||||
const { toast } = useToastProvider();
|
||||
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
const message = isLastOwner
|
||||
@@ -60,22 +51,6 @@ export const DocShareMemberItem = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: removeDocAccess } = useDeleteDocAccess({
|
||||
onSuccess: () => {
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_SUB_PAGE, { id: doc.id }],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast(t('Error while deleting the member.'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onUpdate = (newRole: Role) => {
|
||||
if (!doc) {
|
||||
return;
|
||||
@@ -87,22 +62,6 @@ export const DocShareMemberItem = ({
|
||||
});
|
||||
};
|
||||
|
||||
const onRemove = () => {
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
removeDocAccess({ accessId: access.id, docId: doc.id });
|
||||
};
|
||||
|
||||
const moreActions: DropdownMenuOption[] = [
|
||||
{
|
||||
label: t('Delete'),
|
||||
icon: 'delete',
|
||||
callback: onRemove,
|
||||
disabled: !access.abilities.destroy,
|
||||
},
|
||||
];
|
||||
|
||||
const canUpdate = isInherited
|
||||
? false
|
||||
: (doc?.abilities.accesses_manage ?? false);
|
||||
@@ -124,61 +83,12 @@ export const DocShareMemberItem = ({
|
||||
canUpdate={canUpdate}
|
||||
message={message}
|
||||
rolesAllowed={access.abilities.set_role_to}
|
||||
access={access}
|
||||
doc={doc}
|
||||
/>
|
||||
|
||||
{isDesktop && canUpdate && (
|
||||
<DropdownMenu options={moreActions}>
|
||||
<IconOptions
|
||||
isHorizontal
|
||||
data-testid="doc-share-member-more-actions"
|
||||
$variation="600"
|
||||
/>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface QuickSearchGroupMemberProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const QuickSearchGroupMember = ({
|
||||
doc,
|
||||
}: QuickSearchGroupMemberProps) => {
|
||||
const { t } = useTranslation();
|
||||
const membersQuery = useDocAccesses({
|
||||
docId: doc.id,
|
||||
});
|
||||
|
||||
const membersData: QuickSearchData<Access> = useMemo(() => {
|
||||
const members = membersQuery.data || [];
|
||||
|
||||
const count = members.length;
|
||||
|
||||
return {
|
||||
groupName:
|
||||
count === 1
|
||||
? t('Document owner')
|
||||
: t('Share with {{count}} users', {
|
||||
count: count,
|
||||
}),
|
||||
elements: members,
|
||||
endActions: undefined,
|
||||
};
|
||||
}, [membersQuery, t]);
|
||||
|
||||
return (
|
||||
<Box aria-label={t('List members card')}>
|
||||
<QuickSearchGroup
|
||||
group={membersData}
|
||||
renderElement={(access) => (
|
||||
<DocShareMemberItem doc={doc} access={access} />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
+17
-13
@@ -44,10 +44,11 @@ const ShareModalStyle = createGlobalStyle`
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
isRootDoc?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedUsersRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -61,7 +62,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const [listHeight, setListHeight] = useState<string>('400px');
|
||||
const canShare = doc.abilities.accesses_manage;
|
||||
const canShare = doc.abilities.accesses_manage && isRootDoc;
|
||||
const canViewAccesses = doc.abilities.accesses_view;
|
||||
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
|
||||
const showFooter = selectedUsers.length === 0 && !inputValue;
|
||||
@@ -134,9 +135,6 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
);
|
||||
}, [membersQuery, doc.id]);
|
||||
|
||||
// const rootDoc = treeContext?.root;
|
||||
const isRootDoc = false;
|
||||
|
||||
const showInheritedShareContent =
|
||||
inheritedAccesses.length > 0 && showMemberSection && !isRootDoc;
|
||||
|
||||
@@ -170,10 +168,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
>
|
||||
<Box ref={selectedUsersRef}>
|
||||
{canShare && selectedUsers.length > 0 && (
|
||||
<Box
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$margin={{ top: '11px' }}
|
||||
>
|
||||
<Box $padding={{ horizontal: 'base' }} $margin={{ top: '12x' }}>
|
||||
<DocShareAddMemberList
|
||||
doc={doc}
|
||||
selectedUsers={selectedUsers}
|
||||
@@ -186,7 +181,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!canViewAccesses && <HorizontalSeparator />}
|
||||
{!canViewAccesses && <HorizontalSeparator customPadding="12px" />}
|
||||
</Box>
|
||||
|
||||
<Box data-testid="doc-share-quick-search">
|
||||
@@ -254,7 +249,13 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
</Box>
|
||||
|
||||
<Box ref={handleRef}>
|
||||
{showFooter && <DocShareModalFooter doc={doc} onClose={onClose} />}
|
||||
{showFooter && (
|
||||
<DocShareModalFooter
|
||||
doc={doc}
|
||||
onClose={onClose}
|
||||
canEditVisibility={canShare}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
@@ -333,8 +334,11 @@ const QuickSearchMemberSection = ({
|
||||
docId: doc.id,
|
||||
});
|
||||
|
||||
console.log('data', data);
|
||||
|
||||
const invitationsData: QuickSearchData<Invitation> = useMemo(() => {
|
||||
const invitations = data?.pages.flatMap((page) => page.results) || [];
|
||||
console.log('invitations', invitations);
|
||||
|
||||
return {
|
||||
groupName: t('Pending invitations'),
|
||||
@@ -366,8 +370,8 @@ const QuickSearchMemberSection = ({
|
||||
{invitationsData.elements.length > 0 && (
|
||||
<Box
|
||||
aria-label={t('List invitation card')}
|
||||
$padding={{ horizontal: 'base', bottom: '3xs' }}
|
||||
$margin={{ bottom: showSeparator ? 'base' : undefined }}
|
||||
$padding={{ horizontal: 'base' }}
|
||||
$margin={{ bottom: showSeparator ? 'md' : undefined }}
|
||||
>
|
||||
<QuickSearchGroup
|
||||
group={invitationsData}
|
||||
|
||||
+9
-4
@@ -10,9 +10,14 @@ import { DocVisibility } from './DocVisibility';
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
onClose: () => void;
|
||||
canEditVisibility?: boolean;
|
||||
};
|
||||
|
||||
export const DocShareModalFooter = ({ doc, onClose }: Props) => {
|
||||
export const DocShareModalFooter = ({
|
||||
doc,
|
||||
onClose,
|
||||
canEditVisibility = true,
|
||||
}: Props) => {
|
||||
const copyDocLink = useCopyDocLink(doc.id);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -22,10 +27,10 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
|
||||
`}
|
||||
className="--docs--doc-share-modal-footer"
|
||||
>
|
||||
<HorizontalSeparator $withPadding={true} />
|
||||
<HorizontalSeparator $withPadding={true} customPadding="12px" />
|
||||
|
||||
<DocVisibility doc={doc} />
|
||||
<HorizontalSeparator />
|
||||
<DocVisibility doc={doc} canEdit={canEditVisibility} />
|
||||
<HorizontalSeparator customPadding="12px" />
|
||||
|
||||
<Box
|
||||
$direction="row"
|
||||
|
||||
@@ -34,14 +34,15 @@ import Undo from './../assets/undo.svg';
|
||||
|
||||
interface DocVisibilityProps {
|
||||
doc: Doc;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
export const DocVisibility = ({ doc }: DocVisibilityProps) => {
|
||||
export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const canManage = doc.abilities.accesses_manage;
|
||||
const canManage = doc.abilities.accesses_manage && canEdit;
|
||||
const [linkReach, setLinkReach] = useState<LinkReach>(getDocLinkReach(doc));
|
||||
const [docLinkRole, setDocLinkRole] = useState<LinkRole>(
|
||||
doc.computed_link_role ?? LinkRole.READER,
|
||||
|
||||
@@ -17,6 +17,14 @@ export interface Invitation {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is an Invitation
|
||||
* Invitation has unique properties: email, issuer, is_expired, and document as a string
|
||||
*/
|
||||
export const isInvitation = (obj: unknown): obj is Invitation => {
|
||||
return obj !== null && typeof obj === 'object' && 'issuer' in obj;
|
||||
};
|
||||
|
||||
export enum OptionType {
|
||||
INVITATION = 'invitation',
|
||||
NEW_MEMBER = 'new_member',
|
||||
|
||||
+28
-17
@@ -37,7 +37,7 @@ export const DocSubPageItem = (props: Props) => {
|
||||
const { untitledDocument } = useTrans(doc);
|
||||
const { node } = props;
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
@@ -97,11 +97,22 @@ export const DocSubPageItem = (props: Props) => {
|
||||
return (
|
||||
<Box
|
||||
className="--docs-sub-page-item"
|
||||
onMouseEnter={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
$css={css`
|
||||
&:not(:has(.isSelected)):has(.light-doc-item-actions) {
|
||||
background-color: ${actionsOpen
|
||||
? 'var(--c--theme--colors--greyscale-100)'
|
||||
: 'var(--c--theme--colors--greyscale-000)'};
|
||||
|
||||
.light-doc-item-actions {
|
||||
display: ${actionsOpen ? 'flex' : 'none'};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
border-radius: 4px;
|
||||
|
||||
.light-doc-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -150,19 +161,19 @@ export const DocSubPageItem = (props: Props) => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isHover && (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="light-doc-item-actions"
|
||||
>
|
||||
<DocTreeItemActions
|
||||
doc={doc}
|
||||
parentId={node.data.parentKey}
|
||||
onCreateSuccess={afterCreate}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="light-doc-item-actions"
|
||||
>
|
||||
<DocTreeItemActions
|
||||
doc={doc}
|
||||
isOpen={actionsOpen}
|
||||
onOpenChange={setActionsOpen}
|
||||
parentId={node.data.parentKey}
|
||||
onCreateSuccess={afterCreate}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</TreeViewItem>
|
||||
</Box>
|
||||
|
||||
@@ -26,6 +26,7 @@ type DocTreeProps = {
|
||||
export const DocTree = ({ initialTargetId }: DocTreeProps) => {
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
const [rootActionsOpen, setRootActionsOpen] = useState(false);
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const { currentDoc } = useDocStore();
|
||||
const router = useRouter();
|
||||
@@ -151,11 +152,12 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
data-testid="doc-tree-root-item"
|
||||
$css={css`
|
||||
padding: ${spacingsTokens['2xs']};
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
background-color: ${rootIsSelected
|
||||
background-color: ${rootIsSelected || rootActionsOpen
|
||||
? 'var(--c--theme--colors--greyscale-100)'
|
||||
: 'transparent'};
|
||||
|
||||
@@ -165,7 +167,7 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
|
||||
|
||||
.doc-tree-root-item-actions {
|
||||
display: 'flex';
|
||||
opacity: 0;
|
||||
opacity: ${rootActionsOpen ? '1' : '0'};
|
||||
|
||||
&:has(.isOpen) {
|
||||
opacity: 1;
|
||||
@@ -207,6 +209,8 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => {
|
||||
};
|
||||
treeContext?.treeData.addChild(null, newDoc);
|
||||
}}
|
||||
isOpen={rootActionsOpen}
|
||||
onOpenChange={setRootActionsOpen}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
+9
-5
@@ -5,7 +5,7 @@ import {
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
@@ -22,14 +22,17 @@ type DocTreeItemActionsProps = {
|
||||
doc: Doc;
|
||||
parentId?: string | null;
|
||||
onCreateSuccess?: (newDoc: Doc) => void;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocTreeItemActions = ({
|
||||
doc,
|
||||
parentId,
|
||||
onCreateSuccess,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: DocTreeItemActionsProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const deleteModal = useModal();
|
||||
@@ -68,7 +71,7 @@ export const DocTreeItemActions = ({
|
||||
...(!isCurrentParent
|
||||
? [
|
||||
{
|
||||
label: t('Convert to doc'),
|
||||
label: t('Move to my docs'),
|
||||
isDisabled: !canUpdate,
|
||||
icon: (
|
||||
<Box
|
||||
@@ -94,6 +97,7 @@ export const DocTreeItemActions = ({
|
||||
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
|
||||
onSuccess: (newDoc) => {
|
||||
onCreateSuccess?.(newDoc);
|
||||
void router.push(`/docs/${newDoc.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,13 +124,13 @@ export const DocTreeItemActions = ({
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Icon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
onOpenChange?.(!isOpen);
|
||||
}}
|
||||
iconName="more_horiz"
|
||||
variant="filled"
|
||||
|
||||
+130
-45
@@ -1,15 +1,23 @@
|
||||
import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core';
|
||||
import { getEventCoordinates } from '@dnd-kit/utilities';
|
||||
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { AlertModal } from '@/components/AlertModal/AlertModal';
|
||||
import { Doc, KEY_LIST_DOC, Role } from '@/docs/doc-management';
|
||||
import { useMoveDoc } from '@/docs/doc-tree/api/useMove';
|
||||
|
||||
import { useDragAndDrop } from '../hooks/useDragAndDrop';
|
||||
import {
|
||||
getDocAccesses,
|
||||
getDocInvitations,
|
||||
useDeleteDocAccess,
|
||||
} from '../../doc-share';
|
||||
import { useDeleteDocInvitation } from '../../doc-share/api/useDeleteDocInvitation';
|
||||
import { DocDragEndData, useDragAndDrop } from '../hooks/useDragAndDrop';
|
||||
|
||||
import { DocsGridItem } from './DocsGridItem';
|
||||
import { Draggable } from './Draggable';
|
||||
@@ -45,23 +53,75 @@ type DocGridContentListProps = {
|
||||
};
|
||||
|
||||
export const DocGridContentList = ({ docs }: DocGridContentListProps) => {
|
||||
const { mutate: handleMove, isError } = useMoveDoc();
|
||||
const { mutateAsync: handleMove, isError } = useMoveDoc();
|
||||
const queryClient = useQueryClient();
|
||||
const onDrag = (sourceDocumentId: string, targetDocumentId: string) =>
|
||||
handleMove(
|
||||
{
|
||||
const modalConfirmation = useModal();
|
||||
const { mutate: handleDeleteInvitation } = useDeleteDocInvitation();
|
||||
const { mutate: handleDeleteAccess } = useDeleteDocAccess();
|
||||
const onDragData = useRef<DocDragEndData | null>(null);
|
||||
|
||||
const handleMoveDoc = async () => {
|
||||
if (!onDragData.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sourceDocumentId, targetDocumentId } = onDragData.current;
|
||||
modalConfirmation.onClose();
|
||||
if (!sourceDocumentId || !targetDocumentId) {
|
||||
onDragData.current = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleMove({
|
||||
sourceDocumentId,
|
||||
targetDocumentId,
|
||||
position: TreeViewMoveModeEnum.FIRST_CHILD,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
const accesses = await getDocAccesses({
|
||||
docId: sourceDocumentId,
|
||||
});
|
||||
|
||||
const invitationsResponse = await getDocInvitations({
|
||||
docId: sourceDocumentId,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const invitations = invitationsResponse.results;
|
||||
|
||||
await Promise.all([
|
||||
...invitations.map((invitation) =>
|
||||
handleDeleteInvitation({
|
||||
docId: sourceDocumentId,
|
||||
invitationId: invitation.id,
|
||||
}),
|
||||
),
|
||||
...accesses.map((access) =>
|
||||
handleDeleteAccess({
|
||||
docId: sourceDocumentId,
|
||||
accessId: access.id,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
onDragData.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onDrag = (data: DocDragEndData) => {
|
||||
onDragData.current = data;
|
||||
if (data.source.nb_accesses_direct <= 1) {
|
||||
void handleMoveDoc();
|
||||
return;
|
||||
}
|
||||
|
||||
modalConfirmation.open();
|
||||
};
|
||||
|
||||
const {
|
||||
selectedDoc,
|
||||
@@ -105,37 +165,62 @@ export const DocGridContentList = ({ docs }: DocGridContentListProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[snapToTopLeft]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{docs.map((doc) => (
|
||||
<DraggableDocGridItem
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
dragMode={!!selectedDoc}
|
||||
canDrag={!!canDrag}
|
||||
updateCanDrop={updateCanDrop}
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[snapToTopLeft]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{docs.map((doc) => (
|
||||
<DraggableDocGridItem
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
dragMode={!!selectedDoc}
|
||||
canDrag={!!canDrag}
|
||||
updateCanDrop={updateCanDrop}
|
||||
/>
|
||||
))}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<Box
|
||||
$width="fit-content"
|
||||
$padding={{ horizontal: 'xs', vertical: '3xs' }}
|
||||
$radius="12px"
|
||||
$background={overlayBgColor}
|
||||
data-testid="drag-doc-overlay"
|
||||
$height="auto"
|
||||
role="alert"
|
||||
>
|
||||
<Text $size="xs" $variation="000" $weight="500">
|
||||
{overlayText}
|
||||
</Text>
|
||||
</Box>
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{modalConfirmation.isOpen && (
|
||||
<AlertModal
|
||||
{...modalConfirmation}
|
||||
title={t('Move document')}
|
||||
description={
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t(
|
||||
'By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>',
|
||||
{
|
||||
targetDocumentTitle:
|
||||
onDragData.current?.target.title ?? t('Unnamed document'),
|
||||
},
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
confirmLabel={t('Move')}
|
||||
onConfirm={() => {
|
||||
void handleMoveDoc();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<Box
|
||||
$width="fit-content"
|
||||
$padding={{ horizontal: 'xs', vertical: '3xs' }}
|
||||
$radius="12px"
|
||||
$background={overlayBgColor}
|
||||
data-testid="drag-doc-overlay"
|
||||
$height="auto"
|
||||
role="alert"
|
||||
>
|
||||
<Text $size="xs" $variation="000" $weight="500">
|
||||
{overlayText}
|
||||
</Text>
|
||||
</Box>
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,13 +11,18 @@ import { useState } from 'react';
|
||||
|
||||
import { Doc, Role } from '@/docs/doc-management';
|
||||
|
||||
export type DocDragEndData = {
|
||||
sourceDocumentId: string;
|
||||
targetDocumentId: string;
|
||||
source: Doc;
|
||||
target: Doc;
|
||||
};
|
||||
|
||||
const activationConstraint = {
|
||||
distance: 20,
|
||||
};
|
||||
|
||||
export function useDragAndDrop(
|
||||
onDrag: (sourceDocumentId: string, targetDocumentId: string) => void,
|
||||
) {
|
||||
export function useDragAndDrop(onDrag: (data: DocDragEndData) => void) {
|
||||
const [selectedDoc, setSelectedDoc] = useState<Doc>();
|
||||
const [canDrop, setCanDrop] = useState<boolean>();
|
||||
|
||||
@@ -49,7 +54,12 @@ export function useDragAndDrop(
|
||||
return;
|
||||
}
|
||||
|
||||
onDrag(active.id as string, over.id as string);
|
||||
onDrag({
|
||||
sourceDocumentId: active.id as string,
|
||||
targetDocumentId: over.id as string,
|
||||
source: active.data.current as Doc,
|
||||
target: over.data.current as Doc,
|
||||
});
|
||||
};
|
||||
|
||||
const updateCanDrop = (docCanDrop: boolean, isOver: boolean) => {
|
||||
|
||||
@@ -41,7 +41,7 @@ export const LeftPanel = () => {
|
||||
height: calc(100vh - ${HEADER_HEIGHT}px);
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
overflow: hidden;
|
||||
|
||||
border-right: 1px solid ${colorsTokens['greyscale-200']};
|
||||
`}
|
||||
className="--docs--left-panel-desktop"
|
||||
@@ -70,6 +70,8 @@ export const LeftPanel = () => {
|
||||
position: fixed;
|
||||
transform: translateX(${isPanelOpen ? '0' : '-100dvw'});
|
||||
background-color: var(--c--theme--colors--greyscale-000);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
`}
|
||||
className="--docs--left-panel-mobile"
|
||||
>
|
||||
|
||||
+7
-51
@@ -1,26 +1,13 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Doc, useCreateDoc, useDocStore } from '@/features/docs';
|
||||
import { useCreateChildrenDoc } from '@/features/docs/doc-tree/api/useCreateChildren';
|
||||
import { isOwnerOrAdmin } from '@/features/docs/doc-tree/utils';
|
||||
import { Icon } from '@/components';
|
||||
import { useCreateDoc } from '@/features/docs';
|
||||
|
||||
import { useLeftPanelStore } from '../stores';
|
||||
|
||||
export const LeftPanelHeaderButton = () => {
|
||||
const router = useRouter();
|
||||
const isDoc = router.pathname === '/docs/[id]';
|
||||
|
||||
if (isDoc) {
|
||||
return <LeftPanelHeaderDocButton />;
|
||||
}
|
||||
|
||||
return <LeftPanelHeaderHomeButton />;
|
||||
};
|
||||
|
||||
export const LeftPanelHeaderHomeButton = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
@@ -31,43 +18,12 @@ export const LeftPanelHeaderHomeButton = () => {
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Button color="primary" onClick={() => createDoc()}>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => createDoc()}
|
||||
icon={<Icon $variation="000" iconName="add" />}
|
||||
>
|
||||
{t('New doc')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const LeftPanelHeaderDocButton = () => {
|
||||
const router = useRouter();
|
||||
const { currentDoc } = useDocStore();
|
||||
const { t } = useTranslation();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const tree = treeContext?.treeData;
|
||||
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
|
||||
onSuccess: (doc) => {
|
||||
tree?.addRootNode(doc);
|
||||
tree?.selectNodeById(doc.id);
|
||||
void router.push(`/docs/${doc.id}`);
|
||||
togglePanel();
|
||||
},
|
||||
});
|
||||
|
||||
const onCreateDoc = () => {
|
||||
if (treeContext && treeContext.root) {
|
||||
createChildrenDoc({
|
||||
parentId: treeContext.root.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="tertiary"
|
||||
onClick={onCreateDoc}
|
||||
disabled={currentDoc && !isOwnerOrAdmin(currentDoc)}
|
||||
>
|
||||
{t('New page')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user