Allow creation of space

* other fixes and cleanups
This commit is contained in:
Philipinho
2024-06-24 20:06:57 +01:00
parent 562abb0413
commit f2a193ac8d
22 changed files with 289 additions and 32 deletions

View File

@ -2,7 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Docmost</title> <title>Docmost</title>
</head> </head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -64,7 +64,7 @@ export default function RecentChanges({ spaceId }: Props) {
</Table> </Table>
) : ( ) : (
<Text size="md" ta="center"> <Text size="md" ta="center">
No records to show No pages yet
</Text> </Text>
); );
} }

View File

@ -29,10 +29,11 @@ export default function TopMenu() {
<Menu.Target> <Menu.Target>
<UnstyledButton> <UnstyledButton>
<Group gap={7} wrap={"nowrap"}> <Group gap={7} wrap={"nowrap"}>
<Avatar <UserAvatar
src={workspace.logo} avatarUrl={workspace.logo}
alt={workspace.name} name={workspace.name}
radius="xl" radius="xl"
color="blue"
size={20} size={20}
/> />
<Text fw={500} size="sm" lh={1} mr={3}> <Text fw={500} size="sm" lh={1} mr={3}>

View File

@ -1,22 +1,17 @@
import * as React from "react"; import * as React from "react";
import * as z from "zod"; import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth"; import useAuth from "@/features/auth/hooks/use-auth";
import { ILogin } from "@/features/auth/types/auth.types"; import { ILogin } from "@/features/auth/types/auth.types";
import { import {
Container, Container,
Title, Title,
Anchor,
TextInput, TextInput,
Button, Button,
Text,
PasswordInput, PasswordInput,
Box, Box,
} from "@mantine/core"; } from "@mantine/core";
import { Link, useNavigate } from "react-router-dom";
import classes from "./auth.module.css"; import classes from "./auth.module.css";
import { useEffect, useState } from "react";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({ const formSchema = z.object({

View File

@ -0,0 +1,110 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useEffect } from "react";
import { useForm, zodResolver } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { useCreateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { computeSpaceSlug } from "@/lib";
import { getSpaceUrl } from "@/lib/config.ts";
const formSchema = z.object({
name: z.string().min(2).max(50),
slug: z
.string()
.min(2)
.max(50)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
),
description: z.string().max(500),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateSpaceForm() {
const createSpaceMutation = useCreateSpaceMutation();
const navigate = useNavigate();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validateInputOnChange: ["slug"],
initialValues: {
name: "",
slug: "",
description: "",
},
});
useEffect(() => {
const name = form.values.name;
const words = name.trim().split(/\s+/);
// Check if the last character is a space or if the last word is a single character (indicating it's in progress)
const lastChar = name[name.length - 1];
const lastWordIsIncomplete =
words.length > 1 && words[words.length - 1].length === 1;
if (lastChar !== " " || lastWordIsIncomplete) {
const slug = computeSpaceSlug(name);
form.setFieldValue("slug", slug);
}
}, [form.values.name]);
const handleSubmit = async (data: {
name?: string;
slug?: string;
description?: string;
}) => {
const spaceData = {
name: data.name,
slug: data.slug,
description: data.description,
};
const createdSpace = await createSpaceMutation.mutateAsync(spaceData);
navigate(getSpaceUrl(createdSpace.slug));
};
return (
<>
<Box maw="500" mx="auto">
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack>
<TextInput
withAsterisk
id="name"
label="Space name"
placeholder="e.g Product Team"
variant="filled"
{...form.getInputProps("name")}
/>
<TextInput
withAsterisk
id="slug"
label="Space slug"
placeholder="e.g product"
variant="filled"
{...form.getInputProps("slug")}
/>
<Textarea
id="description"
label="Space description"
placeholder="e.g Space for product team"
variant="filled"
autosize
minRows={2}
maxRows={8}
{...form.getInputProps("description")}
/>
</Stack>
<Group justify="flex-end" mt="md">
<Button type="submit">Create</Button>
</Group>
</form>
</Box>
</>
);
}

View File

@ -0,0 +1,18 @@
import { Button, Divider, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { CreateSpaceForm } from "@/features/space/components/create-space-form.tsx";
export default function CreateSpaceModal() {
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button onClick={open}>Create space</Button>
<Modal opened={opened} onClose={close} title="Create space">
<Divider size="xs" mb="xs" />
<CreateSpaceForm />
</Modal>
</>
);
}

View File

@ -54,14 +54,24 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
label="Name" label="Name"
placeholder="e.g Sales" placeholder="e.g Sales"
variant="filled" variant="filled"
readOnly={readOnly}
{...form.getInputProps("name")} {...form.getInputProps("name")}
/> />
<TextInput
id="slug"
label="Slug"
variant="filled"
readOnly
value={space.slug}
/>
<Textarea <Textarea
id="description" id="description"
label="Description" label="Description"
placeholder="e.g Space for sales team to collaborate" placeholder="e.g Space for sales team to collaborate"
variant="filled" variant="filled"
readOnly={readOnly}
autosize autosize
minRows={1} minRows={1}
maxRows={3} maxRows={3}

View File

@ -59,7 +59,6 @@ export function SpaceSidebar() {
className={classes.section} className={classes.section}
style={{ style={{
border: "none", border: "none",
paddingTop: "8px",
marginBottom: "0", marginBottom: "0",
}} }}
> >
@ -109,6 +108,25 @@ export function SpaceSidebar() {
<span>Space settings</span> <span>Space settings</span>
</div> </div>
</UnstyledButton> </UnstyledButton>
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) && (
<UnstyledButton
className={classes.menu}
onClick={handleCreatePage}
>
<div className={classes.menuItemInner}>
<IconPlus
size={18}
className={classes.menuItemIcon}
stroke={2}
/>
<span>New page</span>
</div>
</UnstyledButton>
)}
</div> </div>
</div> </div>

View File

@ -18,6 +18,7 @@ import {
getSpaceMembers, getSpaceMembers,
getSpaces, getSpaces,
removeSpaceMember, removeSpaceMember,
createSpace,
updateSpace, updateSpace,
} from "@/features/space/services/space-service.ts"; } from "@/features/space/services/space-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
@ -35,18 +36,36 @@ export function useGetSpacesQuery(): UseQueryResult<
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> { export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
return useQuery({ return useQuery({
queryKey: ["space", spaceId], queryKey: ["spaces", spaceId],
queryFn: () => getSpaceById(spaceId), queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useCreateSpaceMutation() {
const queryClient = useQueryClient();
return useMutation<ISpace, Error, Partial<ISpace>>({
mutationFn: (data) => createSpace(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["spaces"],
});
notifications.show({ message: "Space created successfully" });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useGetSpaceBySlugQuery( export function useGetSpaceBySlugQuery(
spaceId: string, spaceId: string,
): UseQueryResult<ISpace, Error> { ): UseQueryResult<ISpace, Error> {
return useQuery({ return useQuery({
queryKey: ["space", spaceId], queryKey: ["spaces", spaceId],
queryFn: () => getSpaceById(spaceId), queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@ -103,11 +122,9 @@ export function useAddSpaceMemberMutation() {
queryKey: ["spaceMembers", variables.spaceId], queryKey: ["spaceMembers", variables.spaceId],
}); });
}, },
onError: () => { onError: (error) => {
notifications.show({ const errorMessage = error["response"]?.data?.message;
message: "Failed to add space members", notifications.show({ message: errorMessage, color: "red" });
color: "red",
});
}, },
}); });
} }

View File

@ -18,6 +18,11 @@ export async function getSpaceById(spaceId: string): Promise<ISpace> {
return req.data; return req.data;
} }
export async function createSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>("/spaces/create", data);
return req.data;
}
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> { export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>("/spaces/update", data); const req = await api.post<ISpace>("/spaces/update", data);
return req.data; return req.data;

View File

@ -32,8 +32,6 @@ export default function AccountAvatar() {
} }
}; };
console.log(currentUser?.user.avatarUrl);
return ( return (
<> <>
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg"> <FileButton onChange={handleFileChange} accept="image/png,image/jpeg">

View File

@ -58,6 +58,7 @@ export default function WorkspaceNameForm() {
label="Name" label="Name"
placeholder="e.g ACME" placeholder="e.g ACME"
variant="filled" variant="filled"
readOnly={!isAdmin}
{...form.getInputProps("name")} {...form.getInputProps("name")}
/> />

View File

@ -82,7 +82,7 @@ export function useCreateInvitationMutation() {
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Invitation sent" }); notifications.show({ message: "Invitation sent" });
// TODO: mutate cache // TODO: mutate cache
queryClient.invalidateQueries({ queryClient.refetchQueries({
queryKey: ["invitations"], queryKey: ["invitations"],
}); });
}, },

View File

@ -1,5 +1,3 @@
import { UserRole } from "@/lib/types.ts";
export function formatMemberCount(memberCount: number): string { export function formatMemberCount(memberCount: number): string {
if (memberCount === 1) { if (memberCount === 1) {
return "1 member"; return "1 member";
@ -15,3 +13,15 @@ export function extractPageSlugId(input: string): string {
const parts = input.split("-"); const parts = input.split("-");
return parts.length > 1 ? parts[parts.length - 1] : input; return parts.length > 1 ? parts[parts.length - 1] : input;
} }
export const computeSpaceSlug = (name: string) => {
const alphanumericName = name.replace(/[^a-zA-Z0-9\s]/g, "");
if (alphanumericName.includes(" ")) {
return alphanumericName
.split(" ")
.map((word) => word.charAt(0).toUpperCase())
.join("");
} else {
return alphanumericName.toLowerCase();
}
};

View File

@ -1,10 +1,20 @@
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import SpaceList from "@/features/space/components/space-list.tsx"; import SpaceList from "@/features/space/components/space-list.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { Group } from "@mantine/core";
import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx";
export default function Spaces() { export default function Spaces() {
const { isAdmin } = useUserRole();
return ( return (
<> <>
<SettingsTitle title="Spaces" /> <SettingsTitle title="Spaces" />
<Group my="md" justify="flex-end">
{isAdmin && <CreateSpaceModal />}
</Group>
<SpaceList /> <SpaceList />
</> </>
); );

View File

@ -3,7 +3,6 @@ import { TextAlign } from '@tiptap/extension-text-align';
import { TaskList } from '@tiptap/extension-task-list'; import { TaskList } from '@tiptap/extension-task-list';
import { TaskItem } from '@tiptap/extension-task-item'; import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline'; import { Underline } from '@tiptap/extension-underline';
import { Link } from '@tiptap/extension-link';
import { Superscript } from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import { Highlight } from '@tiptap/extension-highlight'; import { Highlight } from '@tiptap/extension-highlight';
@ -17,6 +16,7 @@ import {
Details, Details,
DetailsContent, DetailsContent,
DetailsSummary, DetailsSummary,
LinkExtension,
MathBlock, MathBlock,
MathInline, MathInline,
Table, Table,
@ -37,7 +37,7 @@ export const tiptapExtensions = [
TaskList, TaskList,
TaskItem, TaskItem,
Underline, Underline,
Link, LinkExtension,
Superscript, Superscript,
SubScript, SubScript,
Highlight, Highlight,

View File

@ -2,7 +2,7 @@ import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateSpaceDto { export class CreateSpaceDto {
@MinLength(2) @MinLength(2)
@MaxLength(64) @MaxLength(50)
@IsString() @IsString()
name: string; name: string;
@ -11,7 +11,7 @@ export class CreateSpaceDto {
description?: string; description?: string;
@MinLength(2) @MinLength(2)
@MaxLength(64) @MaxLength(50)
@IsString() @IsString()
slug: string; slug: string;
} }

View File

@ -6,14 +6,54 @@ import {
import { CreateSpaceDto } from '../dto/create-space.dto'; import { CreateSpaceDto } from '../dto/create-space.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Space } from '@docmost/db/types/entity.types'; import { Space, User } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination'; import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateSpaceDto } from '../dto/update-space.dto'; import { UpdateSpaceDto } from '../dto/update-space.dto';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { SpaceMemberService } from './space-member.service';
import { SpaceRole } from '../../../common/helpers/types/permission';
@Injectable() @Injectable()
export class SpaceService { export class SpaceService {
constructor(private spaceRepo: SpaceRepo) {} constructor(
private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService,
@InjectKysely() private readonly db: KyselyDB,
) {}
async createSpace(
authUser: User,
workspaceId: string,
createSpaceDto: CreateSpaceDto,
trx?: KyselyTransaction,
): Promise<Space> {
let space = null;
await executeTx(
this.db,
async (trx) => {
space = await this.create(
authUser.id,
workspaceId,
createSpaceDto,
trx,
);
await this.spaceMemberService.addUserToSpace(
authUser.id,
space.id,
SpaceRole.ADMIN,
workspaceId,
trx,
);
},
trx,
);
return { ...space, memberCount: 1 };
}
async create( async create(
userId: string, userId: string,
@ -28,7 +68,7 @@ export class SpaceService {
); );
if (slugExists) { if (slugExists) {
throw new BadRequestException( throw new BadRequestException(
'Slug exists. Please use a unique space slug', 'Space slug exists. Please use a unique space slug',
); );
} }

View File

@ -28,6 +28,12 @@ import {
import { UpdateSpaceDto } from './dto/update-space.dto'; import { UpdateSpaceDto } from './dto/update-space.dto';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { CreateSpaceDto } from './dto/create-space.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('spaces') @Controller('spaces')
@ -37,6 +43,7 @@ export class SpaceController {
private readonly spaceMemberService: SpaceMemberService, private readonly spaceMemberService: SpaceMemberService,
private readonly spaceMemberRepo: SpaceMemberRepo, private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly workspaceAbility: WorkspaceAbilityFactory,
) {} ) {}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ -86,6 +93,22 @@ export class SpaceController {
return { ...space, membership }; return { ...space, membership };
} }
@HttpCode(HttpStatus.OK)
@Post('create')
createGroup(
@Body() createSpaceDto: CreateSpaceDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Space)
) {
throw new ForbiddenException();
}
return this.spaceService.createSpace(user, workspace.id, createSpaceDto);
}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
async updateGroup( async updateGroup(

View File

@ -33,7 +33,7 @@ export class SpaceRepo {
if (isValidUUID(spaceId)) { if (isValidUUID(spaceId)) {
query = query.where('id', '=', spaceId); query = query.where('id', '=', spaceId);
} else { } else {
query = query.where('slug', '=', spaceId); query = query.where(sql`LOWER(slug)`, '=', sql`LOWER(${spaceId})`);
} }
return query.executeTakeFirst(); return query.executeTakeFirst();
} }