From f2a193ac8da71b7f1ba1685086b3465ce3c6cf73 Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Mon, 24 Jun 2024 20:06:57 +0100
Subject: [PATCH] Allow creation of space * other fixes and cleanups
---
apps/client/index.html | 3 +-
apps/client/public/favicon-16x16.png | Bin 0 -> 562 bytes
apps/client/public/favicon-32x32.png | Bin 0 -> 1064 bytes
.../src/components/common/recent-changes.tsx | 2 +-
.../components/layouts/global/top-menu.tsx | 7 +-
.../features/auth/components/login-form.tsx | 5 -
.../space/components/create-space-form.tsx | 110 ++++++++++++++++++
.../space/components/create-space-modal.tsx | 18 +++
.../space/components/edit-space-form.tsx | 10 ++
.../components/sidebar/space-sidebar.tsx | 20 +++-
.../src/features/space/queries/space-query.ts | 31 +++--
.../features/space/services/space-service.ts | 5 +
.../user/components/account-avatar.tsx | 2 -
.../components/workspace-name-form.tsx | 1 +
.../workspace/queries/workspace-query.ts | 2 +-
apps/client/src/lib/utils.ts | 14 ++-
.../src/pages/settings/space/spaces.tsx | 10 ++
.../src/collaboration/collaboration.util.ts | 4 +-
.../src/core/space/dto/create-space.dto.ts | 4 +-
.../src/core/space/services/space.service.ts | 48 +++++++-
.../server/src/core/space/space.controller.ts | 23 ++++
.../src/database/repos/space/space.repo.ts | 2 +-
22 files changed, 289 insertions(+), 32 deletions(-)
create mode 100644 apps/client/public/favicon-16x16.png
create mode 100644 apps/client/public/favicon-32x32.png
create mode 100644 apps/client/src/features/space/components/create-space-form.tsx
create mode 100644 apps/client/src/features/space/components/create-space-modal.tsx
diff --git a/apps/client/index.html b/apps/client/index.html
index e4528b7c..98e02c8b 100644
--- a/apps/client/index.html
+++ b/apps/client/index.html
@@ -2,7 +2,8 @@
-
+
+
Docmost
diff --git a/apps/client/public/favicon-16x16.png b/apps/client/public/favicon-16x16.png
new file mode 100644
index 0000000000000000000000000000000000000000..6298fe8a9ab3a722257a19179ca368f761d97374
GIT binary patch
literal 562
zcmV-20?qx2P)Px$>`6pHR5(v6|n7iom%+&cJwxfH9PpfEX|W=(>CA9oQP-
zLf>CNJi*v4`6m0+V!d*vtuG-4wmCZ6pRo&=tItbRpenSyjGqDAS9ln5H5grd(t#TWRFI(LGPqTtzM%Qws@L9?R0mcK(Vdx
zh@0;>Mf++{i#X=??v~3hm#o(7qH&7p5W(!sP&09jhsqi?#={xl-%;|3wh*HAR$a
zZMkD`1=-6^>*+8F=NQg(B3-oqAK!l&vPHP~3sigV1?f|%AOHXW07*qoM6N<$f}Pq9
AW&i*H
literal 0
HcmV?d00001
diff --git a/apps/client/public/favicon-32x32.png b/apps/client/public/favicon-32x32.png
new file mode 100644
index 0000000000000000000000000000000000000000..40d6a30e9cbdf66d344942fb9ba0d8378e6a92c6
GIT binary patch
literal 1064
zcmV+@1lRkCP)Px&;z>k7R9HuqSHDXmK@|Qbm4ASsR^kQmVj)}tf*@z1TuwZ&w6oG8T11OPlT$2W
z0+-~<1Fy2s%2K?LLn{SEPQ^wLZ1pakVC6+{j+xmXGrKdpdt2yc_RV|WydU3tLjc4;
zPz1;#p|l$SDEmX~7xqd(#VUXNzlA)PaKt19)-LgnGSL>L_y(ZDq}BkGWEshpV^V1L
zDs?caFI>zotJ%HI*8TwCCy%CHFBNUC4{%G}7eR|pge?fq`W>e^k}fcAa19={BhU3Q
zGgS2!Ve$8BaV#mx%%dwUyweSLg6lgVIf>$}WM+E8j8WG@N;1)*^z`sN=g}4i{bSMCQ4dML!9K*xI
z+zVY@U3hwW@_?|2omn?=7~5eAL11D7C=?2)R4Ux))YK<58b8coP)JOsGy;@2tumxQ
zV$e;(pb)TclarHZG#XqipUf_3L_)u0vuvy;3XGTOC4;YaXAo(td%gak{vsSBdFPNgOA!HY;ISEyu>bL@=(;v`m
z{^W{QS6A`nOV#27b)T;ncvIER0ahl15)p;vKrWX(~-{03)yr=@-1m@@u
zE<*K8nZHk)R*d-{j>j-eoC<+QMnvCiSw=^e?Aj*j?1BA?sP
zuN-9|9tv}yy}|?xBHfMB$sgMZfbr$R2;&7$k(taUI^pH;FNH#
zW=;YG*ff6
) : (
- No records to show
+ No pages yet
);
}
diff --git a/apps/client/src/components/layouts/global/top-menu.tsx b/apps/client/src/components/layouts/global/top-menu.tsx
index 07e87f12..7c82f231 100644
--- a/apps/client/src/components/layouts/global/top-menu.tsx
+++ b/apps/client/src/components/layouts/global/top-menu.tsx
@@ -29,10 +29,11 @@ export default function TopMenu() {
-
diff --git a/apps/client/src/features/auth/components/login-form.tsx b/apps/client/src/features/auth/components/login-form.tsx
index 92b2c8fa..9433b1af 100644
--- a/apps/client/src/features/auth/components/login-form.tsx
+++ b/apps/client/src/features/auth/components/login-form.tsx
@@ -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({
diff --git a/apps/client/src/features/space/components/create-space-form.tsx b/apps/client/src/features/space/components/create-space-form.tsx
new file mode 100644
index 00000000..29fe46f2
--- /dev/null
+++ b/apps/client/src/features/space/components/create-space-form.tsx
@@ -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;
+
+export function CreateSpaceForm() {
+ const createSpaceMutation = useCreateSpaceMutation();
+ const navigate = useNavigate();
+
+ const form = useForm({
+ 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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/apps/client/src/features/space/components/create-space-modal.tsx b/apps/client/src/features/space/components/create-space-modal.tsx
new file mode 100644
index 00000000..63c8c4ee
--- /dev/null
+++ b/apps/client/src/features/space/components/create-space-modal.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
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 c94ee295..b076704f 100644
--- a/apps/client/src/features/space/components/edit-space-form.tsx
+++ b/apps/client/src/features/space/components/edit-space-form.tsx
@@ -54,14 +54,24 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
label="Name"
placeholder="e.g Sales"
variant="filled"
+ readOnly={readOnly}
{...form.getInputProps("name")}
/>
+
+
+
+ {spaceAbility.can(
+ SpaceCaslAction.Manage,
+ SpaceCaslSubject.Page,
+ ) && (
+
+
+
+ New page
+
+
+ )}
diff --git a/apps/client/src/features/space/queries/space-query.ts b/apps/client/src/features/space/queries/space-query.ts
index ad8c3ac9..285baeb8 100644
--- a/apps/client/src/features/space/queries/space-query.ts
+++ b/apps/client/src/features/space/queries/space-query.ts
@@ -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 {
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>({
+ 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 {
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" });
},
});
}
diff --git a/apps/client/src/features/space/services/space-service.ts b/apps/client/src/features/space/services/space-service.ts
index 198b66ea..9b45e6d7 100644
--- a/apps/client/src/features/space/services/space-service.ts
+++ b/apps/client/src/features/space/services/space-service.ts
@@ -18,6 +18,11 @@ export async function getSpaceById(spaceId: string): Promise {
return req.data;
}
+export async function createSpace(data: Partial): Promise {
+ const req = await api.post("/spaces/create", data);
+ return req.data;
+}
+
export async function updateSpace(data: Partial): Promise {
const req = await api.post("/spaces/update", data);
return req.data;
diff --git a/apps/client/src/features/user/components/account-avatar.tsx b/apps/client/src/features/user/components/account-avatar.tsx
index f1d2e982..ecb65a42 100644
--- a/apps/client/src/features/user/components/account-avatar.tsx
+++ b/apps/client/src/features/user/components/account-avatar.tsx
@@ -32,8 +32,6 @@ export default function AccountAvatar() {
}
};
- console.log(currentUser?.user.avatarUrl);
-
return (
<>
diff --git a/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx b/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx
index a4f77fdb..8da34fb4 100644
--- a/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx
+++ b/apps/client/src/features/workspace/components/settings/components/workspace-name-form.tsx
@@ -58,6 +58,7 @@ export default function WorkspaceNameForm() {
label="Name"
placeholder="e.g ACME"
variant="filled"
+ readOnly={!isAdmin}
{...form.getInputProps("name")}
/>
diff --git a/apps/client/src/features/workspace/queries/workspace-query.ts b/apps/client/src/features/workspace/queries/workspace-query.ts
index 9a608eb4..a5710b05 100644
--- a/apps/client/src/features/workspace/queries/workspace-query.ts
+++ b/apps/client/src/features/workspace/queries/workspace-query.ts
@@ -82,7 +82,7 @@ export function useCreateInvitationMutation() {
onSuccess: (data, variables) => {
notifications.show({ message: "Invitation sent" });
// TODO: mutate cache
- queryClient.invalidateQueries({
+ queryClient.refetchQueries({
queryKey: ["invitations"],
});
},
diff --git a/apps/client/src/lib/utils.ts b/apps/client/src/lib/utils.ts
index fdb01ab5..fb04aab2 100644
--- a/apps/client/src/lib/utils.ts
+++ b/apps/client/src/lib/utils.ts
@@ -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();
+ }
+};
diff --git a/apps/client/src/pages/settings/space/spaces.tsx b/apps/client/src/pages/settings/space/spaces.tsx
index e54027c8..3cfc4e75 100644
--- a/apps/client/src/pages/settings/space/spaces.tsx
+++ b/apps/client/src/pages/settings/space/spaces.tsx
@@ -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 (
<>
+
+
+ {isAdmin && }
+
+
>
);
diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts
index 5104bc56..2b8917f6 100644
--- a/apps/server/src/collaboration/collaboration.util.ts
+++ b/apps/server/src/collaboration/collaboration.util.ts
@@ -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,
diff --git a/apps/server/src/core/space/dto/create-space.dto.ts b/apps/server/src/core/space/dto/create-space.dto.ts
index 252498ec..73e86317 100644
--- a/apps/server/src/core/space/dto/create-space.dto.ts
+++ b/apps/server/src/core/space/dto/create-space.dto.ts
@@ -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;
}
diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts
index d9da2ab9..f68e68d1 100644
--- a/apps/server/src/core/space/services/space.service.ts
+++ b/apps/server/src/core/space/services/space.service.ts
@@ -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 {
+ 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',
);
}
diff --git a/apps/server/src/core/space/space.controller.ts b/apps/server/src/core/space/space.controller.ts
index 0049f46e..133ee9f5 100644
--- a/apps/server/src/core/space/space.controller.ts
+++ b/apps/server/src/core/space/space.controller.ts
@@ -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(
diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts
index fc24bcb2..6405a31d 100644
--- a/apps/server/src/database/repos/space/space.repo.ts
+++ b/apps/server/src/database/repos/space/space.repo.ts
@@ -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();
}