From 0b6730c06fd16321d27336bddb9cd1294237f0d5 Mon Sep 17 00:00:00 2001 From: Philip Okugbe Date: Fri, 13 Sep 2024 17:40:24 +0100 Subject: [PATCH 01/14] fix page export failure when title contains non-ASCII characters (#309) --- .../src/features/page/services/page-service.ts | 13 ++++++++----- .../src/core/attachment/attachment.controller.ts | 2 +- .../src/integrations/export/export.controller.ts | 3 ++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index ebe2206..1fce401 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -59,12 +59,12 @@ export async function exportPage(data: IExportPageParams): Promise { const req = await api.post("/pages/export", data, { responseType: "blob", }); - + console.log(req?.headers); const fileName = req?.headers["content-disposition"] .split("filename=")[1] .replace(/"/g, ""); - saveAs(req.data, fileName); + saveAs(req.data, decodeURIComponent(fileName)); } export async function importPage(file: File, spaceId: string) { @@ -81,14 +81,17 @@ export async function importPage(file: File, spaceId: string) { return req.data; } -export async function uploadFile(file: File, pageId: string, attachmentId?: string): Promise { +export async function uploadFile( + file: File, + pageId: string, + attachmentId?: string, +): Promise { const formData = new FormData(); - if(attachmentId){ + if (attachmentId) { formData.append("attachmentId", attachmentId); } formData.append("pageId", pageId); formData.append("file", file); - const req = await api.post("/files/upload", formData, { headers: { diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index c06769f..7777378 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -182,7 +182,7 @@ export class AttachmentController { if (!inlineFileExtensions.includes(attachment.fileExt)) { res.header( 'Content-Disposition', - `attachment; filename="${attachment.fileName}"`, + `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, ); } diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts index 09b2c5d..0273113 100644 --- a/apps/server/src/integrations/export/export.controller.ts +++ b/apps/server/src/integrations/export/export.controller.ts @@ -61,7 +61,8 @@ export class ImportController { res.headers({ 'Content-Type': getMimeType(fileExt), - 'Content-Disposition': 'attachment; filename="' + fileName + '"', + 'Content-Disposition': + 'attachment; filename="' + encodeURIComponent(fileName) + '"', }); res.send(rawContent); From dea9f4c063614cd884e37a3775b7d9efbc62bb36 Mon Sep 17 00:00:00 2001 From: Philip Okugbe Date: Fri, 13 Sep 2024 22:37:38 +0100 Subject: [PATCH 02/14] remove unnecessary log --- apps/client/src/features/page/services/page-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index 1fce401..e2f5032 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -59,7 +59,7 @@ export async function exportPage(data: IExportPageParams): Promise { const req = await api.post("/pages/export", data, { responseType: "blob", }); - console.log(req?.headers); + const fileName = req?.headers["content-disposition"] .split("filename=")[1] .replace(/"/g, ""); From fb2728288611da90f48dfcc7d97f98ceede121a4 Mon Sep 17 00:00:00 2001 From: Philip Okugbe Date: Mon, 16 Sep 2024 17:43:40 +0100 Subject: [PATCH 03/14] feat: delete space and edit space slug (#307) * feat: make space slug editable * feat: delete space * client --- .../src/components/common/recent-changes.tsx | 27 ++--- .../src/features/group/queries/group-query.ts | 60 +++++------ .../space/components/delete-space-modal.tsx | 86 ++++++++++++++++ .../space/components/edit-space-form.tsx | 18 +++- .../space/components/space-details.tsx | 26 ++++- .../src/features/space/queries/space-query.ts | 99 ++++++++++++------- .../features/space/services/space-service.ts | 34 ++++--- apps/client/src/theme.ts | 36 ++++--- .../src/common/events/event.contants.ts | 3 + .../src/core/attachment/attachment.module.ts | 3 +- .../processors/attachment.processor.ts | 47 +++++++++ .../attachment/services/attachment.service.ts | 33 +++++++ .../src/core/space/services/space.service.ts | 28 ++++++ .../server/src/core/space/space.controller.ts | 21 +++- .../repos/attachment/attachment.repo.ts | 17 +++- .../database/repos/space/space-member.repo.ts | 2 +- .../queue/constants/queue.constants.ts | 3 + .../src/integrations/queue/queue.module.ts | 3 + 18 files changed, 435 insertions(+), 111 deletions(-) create mode 100644 apps/client/src/features/space/components/delete-space-modal.tsx create mode 100644 apps/server/src/common/events/event.contants.ts create mode 100644 apps/server/src/core/attachment/processors/attachment.processor.ts diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx index 1fe8050..de7f023 100644 --- a/apps/client/src/components/common/recent-changes.tsx +++ b/apps/client/src/components/common/recent-changes.tsx @@ -5,14 +5,15 @@ import { Badge, Table, ScrollArea, -} from "@mantine/core"; -import { Link } from "react-router-dom"; -import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx"; -import { buildPageUrl } from "@/features/page/page.utils.ts"; -import { formattedDate } from "@/lib/time.ts"; -import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts"; -import { IconFileDescription } from "@tabler/icons-react"; -import { getSpaceUrl } from "@/lib/config.ts"; + ActionIcon, +} from '@mantine/core'; +import { Link } from 'react-router-dom'; +import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx'; +import { buildPageUrl } from '@/features/page/page.utils.ts'; +import { formattedDate } from '@/lib/time.ts'; +import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts'; +import { IconFileDescription } from '@tabler/icons-react'; +import { getSpaceUrl } from '@/lib/config.ts'; interface Props { spaceId?: string; @@ -40,10 +41,14 @@ export default function RecentChanges({ spaceId }: Props) { to={buildPageUrl(page?.space.slug, page.slugId, page.title)} > - {page.icon || } + {page.icon || ( + + + + )} - {page.title || "Untitled"} + {page.title || 'Untitled'} @@ -55,7 +60,7 @@ export default function RecentChanges({ spaceId }: Props) { variant="light" component={Link} to={getSpaceUrl(page?.space.slug)} - style={{ cursor: "pointer" }} + style={{ cursor: 'pointer' }} > {page?.space.name} diff --git a/apps/client/src/features/group/queries/group-query.ts b/apps/client/src/features/group/queries/group-query.ts index 4707b19..b61314a 100644 --- a/apps/client/src/features/group/queries/group-query.ts +++ b/apps/client/src/features/group/queries/group-query.ts @@ -3,8 +3,8 @@ import { useQuery, useQueryClient, UseQueryResult, -} from "@tanstack/react-query"; -import { IGroup } from "@/features/group/types/group.types"; +} from '@tanstack/react-query'; +import { IGroup } from '@/features/group/types/group.types'; import { addGroupMember, createGroup, @@ -14,22 +14,22 @@ import { getGroups, removeGroupMember, updateGroup, -} from "@/features/group/services/group-service"; -import { notifications } from "@mantine/notifications"; -import { QueryParams } from "@/lib/types.ts"; +} from '@/features/group/services/group-service'; +import { notifications } from '@mantine/notifications'; +import { QueryParams } from '@/lib/types.ts'; export function useGetGroupsQuery( - params?: QueryParams, + params?: QueryParams ): UseQueryResult { return useQuery({ - queryKey: ["groups", params], + queryKey: ['groups', params], queryFn: () => getGroups(params), }); } export function useGroupQuery(groupId: string): UseQueryResult { return useQuery({ - queryKey: ["groups", groupId], + queryKey: ['groups', groupId], queryFn: () => getGroupById(groupId), enabled: !!groupId, }); @@ -37,7 +37,7 @@ export function useGroupQuery(groupId: string): UseQueryResult { export function useGroupMembersQuery(groupId: string) { return useQuery({ - queryKey: ["groupMembers", groupId], + queryKey: ['groupMembers', groupId], queryFn: () => getGroupMembers(groupId), enabled: !!groupId, }); @@ -47,10 +47,10 @@ export function useCreateGroupMutation() { return useMutation>({ mutationFn: (data) => createGroup(data), onSuccess: () => { - notifications.show({ message: "Group created successfully" }); + notifications.show({ message: 'Group created successfully' }); }, onError: () => { - notifications.show({ message: "Failed to create group", color: "red" }); + notifications.show({ message: 'Failed to create group', color: 'red' }); }, }); } @@ -61,14 +61,14 @@ export function useUpdateGroupMutation() { return useMutation>({ mutationFn: (data) => updateGroup(data), onSuccess: (data, variables) => { - notifications.show({ message: "Group updated successfully" }); + notifications.show({ message: 'Group updated successfully' }); queryClient.invalidateQueries({ - queryKey: ["group", variables.groupId], + queryKey: ['group', variables.groupId], }); }, onError: (error) => { - const errorMessage = error["response"]?.data?.message; - notifications.show({ message: errorMessage, color: "red" }); + const errorMessage = error['response']?.data?.message; + notifications.show({ message: errorMessage, color: 'red' }); }, }); } @@ -79,17 +79,19 @@ export function useDeleteGroupMutation() { return useMutation({ mutationFn: (groupId: string) => deleteGroup({ groupId }), onSuccess: (data, variables) => { - notifications.show({ message: "Group deleted successfully" }); + notifications.show({ message: 'Group deleted successfully' }); - const groups = queryClient.getQueryData(["groups"]) as any; + const groups = queryClient.getQueryData(['groups']) as any; if (groups) { - groups.items?.filter((group: IGroup) => group.id !== variables); - queryClient.setQueryData(["groups"], groups); + groups.items = groups.items?.filter( + (group: IGroup) => group.id !== variables + ); + queryClient.setQueryData(['groups'], groups); } }, onError: (error) => { - const errorMessage = error["response"]?.data?.message; - notifications.show({ message: errorMessage, color: "red" }); + const errorMessage = error['response']?.data?.message; + notifications.show({ message: errorMessage, color: 'red' }); }, }); } @@ -100,15 +102,15 @@ export function useAddGroupMemberMutation() { return useMutation({ mutationFn: (data) => addGroupMember(data), onSuccess: (data, variables) => { - notifications.show({ message: "Added successfully" }); + notifications.show({ message: 'Added successfully' }); queryClient.invalidateQueries({ - queryKey: ["groupMembers", variables.groupId], + queryKey: ['groupMembers', variables.groupId], }); }, onError: () => { notifications.show({ - message: "Failed to add group members", - color: "red", + message: 'Failed to add group members', + color: 'red', }); }, }); @@ -127,14 +129,14 @@ export function useRemoveGroupMemberMutation() { >({ mutationFn: (data) => removeGroupMember(data), onSuccess: (data, variables) => { - notifications.show({ message: "Removed successfully" }); + notifications.show({ message: 'Removed successfully' }); queryClient.invalidateQueries({ - queryKey: ["groupMembers", variables.groupId], + queryKey: ['groupMembers', variables.groupId], }); }, onError: (error) => { - const errorMessage = error["response"]?.data?.message; - notifications.show({ message: errorMessage, color: "red" }); + const errorMessage = error['response']?.data?.message; + notifications.show({ message: errorMessage, color: 'red' }); }, }); } diff --git a/apps/client/src/features/space/components/delete-space-modal.tsx b/apps/client/src/features/space/components/delete-space-modal.tsx new file mode 100644 index 0000000..bc3ac38 --- /dev/null +++ b/apps/client/src/features/space/components/delete-space-modal.tsx @@ -0,0 +1,86 @@ +import { Button, Divider, Group, Modal, Text, TextInput } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { useDeleteSpaceMutation } from '../queries/space-query'; +import { useField } from '@mantine/form'; +import { ISpace } from '../types/space.types'; +import { useNavigate } from 'react-router-dom'; +import APP_ROUTE from '@/lib/app-route'; + +interface DeleteSpaceModalProps { + space: ISpace; +} + +export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) { + const [opened, { open, close }] = useDisclosure(false); + const deleteSpaceMutation = useDeleteSpaceMutation(); + const navigate = useNavigate(); + + const confirmNameField = useField({ + initialValue: '', + validateOnChange: true, + validate: (value) => + value.trim().toLowerCase() === space.name.trim().toLocaleLowerCase() + ? null + : 'Names do not match', + }); + + const handleDelete = async () => { + if ( + confirmNameField.getValue().trim().toLowerCase() !== + space.name.trim().toLowerCase() + ) { + confirmNameField.validate(); + return; + } + + try { + // pass slug too so we can clear the local cache + await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug }); + navigate(APP_ROUTE.HOME); + } catch (error) { + console.error('Failed to delete space', error); + } + }; + + return ( + <> + + + + + + All pages, comments, attachments and permissions in this space will be + deleted irreversibly. + + + Type the space name{' '} + + '{space.name}' + {' '} + to confirm your action. + + + + + + + + + ); +} diff --git a/apps/client/src/features/space/components/edit-space-form.tsx b/apps/client/src/features/space/components/edit-space-form.tsx index b076704..93b4e8b 100644 --- a/apps/client/src/features/space/components/edit-space-form.tsx +++ b/apps/client/src/features/space/components/edit-space-form.tsx @@ -8,6 +8,14 @@ import { ISpace } from "@/features/space/types/space.types.ts"; const formSchema = z.object({ name: z.string().min(2).max(50), description: z.string().max(250), + slug: z + .string() + .min(2) + .max(50) + .regex( + /^[a-zA-Z0-9]+$/, + "Space slug must be alphanumeric. No special characters", + ), }); type FormValues = z.infer; @@ -23,12 +31,14 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) { initialValues: { name: space?.name, description: space?.description || "", + slug: space.slug, }, }); const handleSubmit = async (values: { name?: string; description?: string; + slug?: string; }) => { const spaceData: Partial = { spaceId: space.id, @@ -40,6 +50,10 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) { spaceData.description = values.description; } + if (form.isDirty("slug")) { + spaceData.slug = values.slug; + } + await updateSpaceMutation.mutateAsync(spaceData); form.resetDirty(); }; @@ -62,8 +76,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) { id="slug" label="Slug" variant="filled" - readOnly - value={space.slug} + readOnly={readOnly} + {...form.getInputProps("slug")} />