mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-10 04:22:00 +10:00
Allow creation of space
* other fixes and cleanups
This commit is contained in:
@ -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>
|
||||
|
||||
BIN
apps/client/public/favicon-16x16.png
Normal file
BIN
apps/client/public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 562 B |
BIN
apps/client/public/favicon-32x32.png
Normal file
BIN
apps/client/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@ -64,7 +64,7 @@ export default function RecentChanges({ spaceId }: Props) {
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="md" ta="center">
|
||||
No records to show
|
||||
No pages yet
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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({
|
||||
|
||||
110
apps/client/src/features/space/components/create-space-form.tsx
Normal file
110
apps/client/src/features/space/components/create-space-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -32,8 +32,6 @@ export default function AccountAvatar() {
|
||||
}
|
||||
};
|
||||
|
||||
console.log(currentUser?.user.avatarUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileButton onChange={handleFileChange} accept="image/png,image/jpeg">
|
||||
|
||||
@ -58,6 +58,7 @@ export default function WorkspaceNameForm() {
|
||||
label="Name"
|
||||
placeholder="e.g ACME"
|
||||
variant="filled"
|
||||
readOnly={!isAdmin}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
|
||||
@ -82,7 +82,7 @@ export function useCreateInvitationMutation() {
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: "Invitation sent" });
|
||||
// TODO: mutate cache
|
||||
queryClient.invalidateQueries({
|
||||
queryClient.refetchQueries({
|
||||
queryKey: ["invitations"],
|
||||
});
|
||||
},
|
||||
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user