(frontend) link to create new doc

We create a special URL to create a new doc,
we can set the doc with the URL param to set
the visibility, the permission and the title.
This commit is contained in:
Anthony LC
2025-12-01 15:08:13 +01:00
parent c13f0e97bb
commit 7475b7c3bc
8 changed files with 253 additions and 42 deletions

View File

@@ -10,6 +10,7 @@ and this project adheres to
- ✨ Add comments feature to the editor #1330
- ✨(backend) Comments on text editor #1330
- ✨(frontend) link to create new doc #1574
### Changed

View File

@@ -7,6 +7,7 @@ import {
randomName,
verifyDocName,
} from './utils-common';
import { connectOtherUserToDoc } from './utils-share';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -73,6 +74,82 @@ test.describe('Doc Create', () => {
page.locator('.c__tree-view--row-content').getByText('Untitled document'),
).toBeVisible();
});
test('it creates a doc with link "/doc/new/', async ({
page,
browserName,
}) => {
test.slow();
// Private doc creation
await page.goto('/docs/new/?title=My+private+doc+from+url');
await verifyDocName(page, 'My private doc from url');
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByTestId('doc-visibility').getByText('Private').first(),
).toBeVisible();
// Public editing doc creation
await page.goto(
'/docs/new/?title=My+public+doc+from+url&link-reach=public&link-role=editor',
);
await verifyDocName(page, 'My public doc from url');
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByTestId('doc-visibility').getByText('Public').first(),
).toBeVisible();
await expect(
page.getByTestId('doc-access-mode').getByText('Editing').first(),
).toBeVisible();
// Authenticated reading doc creation
await page.goto(
'/docs/new/?title=My+authenticated+doc+from+url&link-reach=authenticated&link-role=reader',
);
await verifyDocName(page, 'My authenticated doc from url');
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByTestId('doc-visibility').getByText('Connected').first(),
).toBeVisible();
await expect(
page.getByTestId('doc-access-mode').getByText('Reading').first(),
).toBeVisible();
const { cleanup, otherPage, otherBrowserName } =
await connectOtherUserToDoc({
docUrl:
'/docs/new/?title=From+unlogged+doc+from+url&link-reach=authenticated&link-role=reader',
browserName,
withoutSignIn: true,
});
await keyCloakSignIn(otherPage, otherBrowserName, false);
await verifyDocName(otherPage, 'From unlogged doc from url');
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByTestId('doc-visibility').getByText('Connected').first(),
).toBeVisible();
await expect(
otherPage.getByTestId('doc-access-mode').getByText('Reading').first(),
).toBeVisible();
await cleanup();
});
});
test.describe('Doc Create: Not logged', () => {

View File

@@ -27,7 +27,7 @@ export const setAuthUrl = () => {
window.location.pathname !== '/' &&
window.location.pathname !== `${HOME_URL}/`
) {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.href);
}
};

View File

@@ -1,4 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
@@ -6,9 +10,14 @@ import { Doc } from '../types';
import { KEY_LIST_DOC } from './useDocs';
export const createDoc = async (): Promise<Doc> => {
type CreateDocParams = {
title?: string;
} | void;
export const createDoc = async (params: CreateDocParams): Promise<Doc> => {
const response = await fetchAPI(`documents/`, {
method: 'POST',
body: JSON.stringify({ title: params?.title }),
});
if (!response.ok) {
@@ -18,23 +27,17 @@ export const createDoc = async (): Promise<Doc> => {
return response.json() as Promise<Doc>;
};
interface CreateDocProps {
onSuccess: (data: Doc) => void;
onError?: (error: APIError) => void;
}
type UseCreateDocOptions = UseMutationOptions<Doc, APIError, CreateDocParams>;
export function useCreateDoc({ onSuccess, onError }: CreateDocProps) {
export function useCreateDoc(options?: UseCreateDocOptions) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError>({
return useMutation<Doc, APIError, CreateDocParams>({
mutationFn: createDoc,
onSuccess: (data) => {
onSuccess: (data, variables, onMutateResult, context) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
onSuccess(data);
},
onError: (error) => {
onError?.(error);
options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}

View File

@@ -1,17 +1,21 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/docs/doc-management';
import { Doc, LinkReach, LinkRole } from '@/docs/doc-management';
export type UpdateDocLinkParams = Pick<Doc, 'id' | 'link_reach'> &
Partial<Pick<Doc, 'link_role'>>;
type UpdateDocLinkResponse = { link_role: LinkRole; link_reach: LinkReach };
export const updateDocLink = async ({
id,
...params
}: UpdateDocLinkParams): Promise<Doc> => {
}: UpdateDocLinkParams): Promise<UpdateDocLinkResponse> => {
const response = await fetchAPI(`documents/${id}/link-configuration/`, {
method: 'PUT',
body: JSON.stringify({
@@ -26,40 +30,31 @@ export const updateDocLink = async ({
);
}
return response.json() as Promise<Doc>;
return response.json() as Promise<UpdateDocLinkResponse>;
};
interface UpdateDocLinkProps {
onSuccess?: (data: Doc) => void;
type UseUpdateDocLinkOptions = UseMutationOptions<
UpdateDocLinkResponse,
APIError,
UpdateDocLinkParams
> & {
listInvalidQueries?: string[];
}
};
export function useUpdateDocLink({
onSuccess,
listInvalidQueries,
}: UpdateDocLinkProps = {}) {
export function useUpdateDocLink(options?: UseUpdateDocLinkOptions) {
const queryClient = useQueryClient();
const { toast } = useToastProvider();
const { t } = useTranslation();
return useMutation<Doc, APIError, UpdateDocLinkParams>({
return useMutation<UpdateDocLinkResponse, APIError, UpdateDocLinkParams>({
mutationFn: updateDocLink,
onSuccess: (data) => {
listInvalidQueries?.forEach((queryKey) => {
...options,
onSuccess: (data, variables, onMutateResult, context) => {
options?.listInvalidQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
toast(
t('The document visibility has been updated.'),
VariantType.SUCCESS,
{
duration: 2000,
},
);
onSuccess?.(data);
options?.onSuccess?.(data, variables, onMutateResult, context);
},
});
}

View File

@@ -1,4 +1,8 @@
import { Button } from '@openfun/cunningham-react';
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text } from '@/components';
@@ -17,9 +21,15 @@ interface DocDesynchronizedProps {
export const DocDesynchronized = ({ doc }: DocDesynchronizedProps) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
onSuccess: () => {
toast(t('The document visibility restored.'), VariantType.SUCCESS, {
duration: 2000,
});
},
});
return (

View File

@@ -1,3 +1,4 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -41,6 +42,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { isDesynchronized } = useDocUtils(doc);
const { linkModeTranslations, linkReachChoices, linkReachTranslations } =
useTranslatedShareSettings();
const { toast } = useToastProvider();
const description =
docLinkRole === LinkRole.READER
@@ -49,6 +51,15 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
const { mutate: updateDocLink } = useUpdateDocLink({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
onSuccess: () => {
toast(
t('The document visibility has been updated.'),
VariantType.SUCCESS,
{
duration: 2000,
},
);
},
});
const linkReachOptions: DropdownMenuOption[] = useMemo(() => {

View File

@@ -0,0 +1,114 @@
import { captureException } from '@sentry/nextjs';
import Head from 'next/head';
import { useSearchParams } from 'next/navigation';
import { useRouter } from 'next/router';
import { ReactElement, useCallback, useEffect } from 'react';
import { Loading } from '@/components';
import { LOGIN_URL, setAuthUrl, useAuth } from '@/features/auth';
import {
LinkReach,
LinkRole,
useCreateDoc,
} from '@/features/docs/doc-management';
import { useUpdateDocLink } from '@/features/docs/doc-share/api/useUpdateDocLink';
import { useSkeletonStore } from '@/features/skeletons';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { setIsSkeletonVisible } = useSkeletonStore();
const router = useRouter();
const searchParams = useSearchParams();
const linkReach = searchParams.get('link-reach');
const linkRole = searchParams.get('link-role');
const title = searchParams.get('title');
const { authenticated } = useAuth();
const { mutateAsync: createDocAsync, data: doc } = useCreateDoc();
const { mutateAsync: updateDocLinkAsync } = useUpdateDocLink();
const redirectToDoc = useCallback(
(docId: string) => {
void router.push(`/docs/${docId}`);
},
[router],
);
useEffect(() => {
setIsSkeletonVisible(true);
}, [setIsSkeletonVisible]);
useEffect(() => {
if (doc) {
return;
}
if (!authenticated) {
setAuthUrl();
window.location.replace(LOGIN_URL);
return;
}
createDocAsync({
title: title || undefined,
})
.then((createdDoc) => {
if ((linkReach && linkRole) || linkReach) {
updateDocLinkAsync({
id: createdDoc.id,
link_reach: linkReach as LinkReach,
link_role: (linkRole as LinkRole | undefined) || undefined,
})
.catch((error) => {
captureException(error, {
extra: {
docId: createdDoc.id,
linkReach,
linkRole,
},
});
})
.finally(() => {
redirectToDoc(createdDoc.id);
});
return;
}
redirectToDoc(createdDoc.id);
})
.catch((error) => {
captureException(error, {
extra: {
title,
},
});
});
}, [
authenticated,
createDocAsync,
doc,
linkReach,
linkRole,
redirectToDoc,
title,
updateDocLinkAsync,
]);
return <Loading />;
};
Page.getLayout = function getLayout(page: ReactElement) {
return (
<>
<Head>
<meta name="robots" content="noindex" />
</Head>
<MainLayout enableResizablePanel={false}>{page}</MainLayout>
</>
);
};
export default Page;