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">
<head>
<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" />
<title>Docmost</title>
</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>
) : (
<Text size="md" ta="center">
No records to show
No pages yet
</Text>
);
}

View File

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

View File

@ -1,22 +1,17 @@
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth";
import { ILogin } from "@/features/auth/types/auth.types";
import {
Container,
Title,
Anchor,
TextInput,
Button,
Text,
PasswordInput,
Box,
} from "@mantine/core";
import { Link, useNavigate } from "react-router-dom";
import classes from "./auth.module.css";
import { useEffect, useState } from "react";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
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"
placeholder="e.g Sales"
variant="filled"
readOnly={readOnly}
{...form.getInputProps("name")}
/>
<TextInput
id="slug"
label="Slug"
variant="filled"
readOnly
value={space.slug}
/>
<Textarea
id="description"
label="Description"
placeholder="e.g Space for sales team to collaborate"
variant="filled"
readOnly={readOnly}
autosize
minRows={1}
maxRows={3}

View File

@ -59,7 +59,6 @@ export function SpaceSidebar() {
className={classes.section}
style={{
border: "none",
paddingTop: "8px",
marginBottom: "0",
}}
>
@ -109,6 +108,25 @@ export function SpaceSidebar() {
<span>Space settings</span>
</div>
</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>

View File

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

View File

@ -18,6 +18,11 @@ export async function getSpaceById(spaceId: string): Promise<ISpace> {
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> {
const req = await api.post<ISpace>("/spaces/update", data);
return req.data;

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
import { UserRole } from "@/lib/types.ts";
export function formatMemberCount(memberCount: number): string {
if (memberCount === 1) {
return "1 member";
@ -15,3 +13,15 @@ export function extractPageSlugId(input: string): string {
const parts = input.split("-");
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 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() {
const { isAdmin } = useUserRole();
return (
<>
<SettingsTitle title="Spaces" />
<Group my="md" justify="flex-end">
{isAdmin && <CreateSpaceModal />}
</Group>
<SpaceList />
</>
);

View File

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

View File

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

View File

@ -6,14 +6,54 @@ import {
import { CreateSpaceDto } from '../dto/create-space.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Space } from '@docmost/db/types/entity.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Space, User } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination';
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()
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(
userId: string,
@ -28,7 +68,7 @@ export class SpaceService {
);
if (slugExists) {
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 { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
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)
@Controller('spaces')
@ -37,6 +43,7 @@ export class SpaceController {
private readonly spaceMemberService: SpaceMemberService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly workspaceAbility: WorkspaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@ -86,6 +93,22 @@ export class SpaceController {
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)
@Post('update')
async updateGroup(

View File

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