Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to modify document titles #342

Merged
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion backend/docker/docker-compose-full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ services:
environment:
DATABASE_URL: "mongodb://mongo:27017/codepair"
# Environment variables need to be passed to the container
# You can find the description of each environment variable in the backend/.env.development file
hugosandsjo marked this conversation as resolved.
Show resolved Hide resolved
GITHUB_CLIENT_ID: "your_github_client_id_here"
GITHUB_CLIENT_SECRET: "your_github_client_secret_here"
GITHUB_CALLBACK_URL: "http://localhost:3000/auth/login/github"
Expand Down
13 changes: 13 additions & 0 deletions backend/src/workspace-documents/dto/update-document-title.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsNotEmpty } from "class-validator";

export class UpdateDocumentTitleDto {
@ApiProperty({
description: "The new title of the document",
example: "Updated Document Title",
type: String,
})
@IsString()
@IsNotEmpty()
title: string;
}
35 changes: 32 additions & 3 deletions backend/src/workspace-documents/workspace-documents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Param,
ParseIntPipe,
Post,
Put,
Query,
Req,
} from "@nestjs/common";
Expand All @@ -24,6 +25,7 @@ import {
import { AuthroizedRequest } from "src/utils/types/req.type";
import { CreateWorkspaceDocumentDto } from "./dto/create-workspace-document.dto";
import { CreateWorkspaceDocumentResponse } from "./types/create-workspace-document-response.type";
import { UpdateDocumentTitleDto } from "./dto/update-document-title.dto";
import { HttpExceptionResponse } from "src/utils/types/http-exception-response.type";
import { FindWorkspaceDocumentsResponse } from "./types/find-workspace-documents-response.type";
import { CreateWorkspaceDocumentShareTokenResponse } from "./types/create-workspace-document-share-token-response.type";
Expand All @@ -36,6 +38,36 @@ import { FindWorkspaceDocumentResponse } from "./types/find-workspace-document-r
export class WorkspaceDocumentsController {
constructor(private workspaceDocumentsService: WorkspaceDocumentsService) {}

@Put(":document_id")
hugosandsjo marked this conversation as resolved.
Show resolved Hide resolved
@ApiOperation({
summary: "Update the title of a document in the workspace",
description: "If the user has the access permissions, update the document's title.",
})
@ApiOkResponse({
description: "Document title updated successfully",
})
@ApiNotFoundResponse({
type: HttpExceptionResponse,
description:
"The workspace or document does not exist, or the user lacks the appropriate permissions.",
})
@ApiBody({
description: "The new title of the document",
type: UpdateDocumentTitleDto,
})
async updateTitle(
@Param("workspace_id") workspaceId: string,
@Param("document_id") documentId: string,
@Body() updateDocumentTitleDto: UpdateDocumentTitleDto,
@Req() req: AuthroizedRequest
): Promise<void> {
await this.workspaceDocumentsService.updateTitle(
req.user.id,
workspaceId,
documentId,
updateDocumentTitleDto.title
);
}
@Get("")
@ApiOperation({
summary: "Retrieve the Documents in Workspace",
Expand Down Expand Up @@ -67,7 +99,6 @@ export class WorkspaceDocumentsController {
): Promise<FindWorkspaceDocumentsResponse> {
return this.workspaceDocumentsService.findMany(req.user.id, workspaceId, pageSize, cursor);
}

@Get(":document_id")
@ApiOperation({
summary: "Retrieve a Document in the Workspace",
Expand All @@ -86,7 +117,6 @@ export class WorkspaceDocumentsController {
): Promise<FindWorkspaceDocumentResponse> {
return this.workspaceDocumentsService.findOne(req.user.id, workspaceId, documentId);
}

@Post()
@ApiOperation({
summary: "Create a Document in a Workspace",
Expand All @@ -109,7 +139,6 @@ export class WorkspaceDocumentsController {
createWorkspaceDocumentDto.title
);
}

@Post(":document_id/share-token")
@ApiOperation({
summary: "Retrieve a Share Token for the Document",
Expand Down
36 changes: 36 additions & 0 deletions backend/src/workspace-documents/workspace-documents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,42 @@ export class WorkspaceDocumentsService {
private configService: ConfigService
) {}

async updateTitle(
userId: string,
workspaceId: string,
documentId: string,
title: string
): Promise<void> {
try {
await this.prismaService.userWorkspace.findFirstOrThrow({
where: {
userId,
workspaceId,
},
});
} catch (e) {
throw new NotFoundException(
"The workspace does not exist, or the user lacks the appropriate permissions."
);
}

const document = await this.prismaService.document.findFirst({
devleejb marked this conversation as resolved.
Show resolved Hide resolved
where: {
id: documentId,
workspaceId: workspaceId,
},
});

if (!document) {
throw new NotFoundException("Document not found");
}
hugosandsjo marked this conversation as resolved.
Show resolved Hide resolved

await this.prismaService.document.update({
where: { id: documentId },
data: { title: title },
});
}

async create(userId: string, workspaceId: string, title: string) {
try {
await this.prismaService.userWorkspace.findFirstOrThrow({
Expand Down
92 changes: 91 additions & 1 deletion frontend/src/components/headers/DocumentHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
ToggleButtonGroup,
Toolbar,
Tooltip,
Button,
FormControl,
Typography,
} from "@mui/material";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { useUserPresence } from "../../hooks/useUserPresence";
Expand All @@ -23,13 +26,29 @@ import DownloadMenu from "../common/DownloadMenu";
import ShareButton from "../common/ShareButton";
import ThemeButton from "../common/ThemeButton";
import UserPresenceList from "./UserPresenceList";
import { FormContainer, TextFieldElement } from "react-hook-form-mui";
import { selectDocument, setDocumentData } from "../../store/documentSlice";
import { useUpdateDocumentTitleMutation } from "../../hooks/api/workspaceDocument";
import { UpdateDocumentRequest } from "../../hooks/api/types/document";

function DocumentHeader() {
const dispatch = useDispatch();
const navigate = useNavigate();
const editorState = useSelector(selectEditor);
const workspaceState = useSelector(selectWorkspace);
const documentStore = useSelector(selectDocument);
const { presenceList } = useUserPresence(editorState.doc);
const [focused, setFocused] = useState(false);
const { mutateAsync: updateDocumentTitle } = useUpdateDocumentTitleMutation(
workspaceState.data?.id || "",
documentStore.data?.id || ""
);

const isEditingDisabled = editorState.shareRole === "READ";
hugosandsjo marked this conversation as resolved.
Show resolved Hide resolved

const handleFocus = () => {
setFocused(true);
};

useEffect(() => {
if (editorState.shareRole === ShareRole.READ) {
Expand All @@ -46,6 +65,36 @@ function DocumentHeader() {
navigate(`/${workspaceState.data?.slug}`);
};

const handleDocumentTitleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
if (documentStore.data) {
dispatch(
setDocumentData({
...documentStore.data,
title: e.target.value,
})
);
}
};
hugosandsjo marked this conversation as resolved.
Show resolved Hide resolved

const handleUpdateDocumentTitle = async (data: UpdateDocumentRequest) => {
console.log(data);
hugosandsjo marked this conversation as resolved.
Show resolved Hide resolved
await updateDocumentTitle(data);
setFocused(false);
};

const validationRules = {
required: "Title is required",
maxLength: {
value: 255,
message: "Title must be less than 255 characters",
},
validate: {
notEmpty: (value: string) => value.trim() !== "" || "Title cannot be just whitespace",
},
};

return (
<AppBar position="static" sx={{ zIndex: 100 }}>
<Toolbar>
Expand Down Expand Up @@ -85,7 +134,48 @@ function DocumentHeader() {
)}
</Paper>
<DownloadMenu />
<Stack alignItems="center">
{isEditingDisabled ? (
<Typography variant="h5">{documentStore.data?.title}</Typography>
) : (
<FormControl>
<FormContainer
defaultValues={{ title: documentStore.data?.title }}
onSuccess={handleUpdateDocumentTitle}
>
<Stack gap={4} alignItems="flex-end" flexDirection="row">
<TextFieldElement
variant="standard"
name="title"
label={documentStore.data?.title}
required
fullWidth
inputProps={{
maxLength: 255,
}}
onChange={handleDocumentTitleChange}
onFocus={handleFocus}
rules={validationRules}
helperText={
focused ? "Please provide a valid title." : ""
}
/>
{focused && (
<Button
type="submit"
variant="contained"
size="large"
>
Update
</Button>
)}
</Stack>
</FormContainer>
</FormControl>
)}
</Stack>
</Stack>

<Stack direction="row" alignItems="center" gap={1}>
<UserPresenceList presenceList={presenceList} />
{!editorState.shareRole && <ShareButton />}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/hooks/api/types/document.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ export class Document {
export class GetDocumentBySharingTokenResponse extends Document {
role: ShareRole;
}

export class UpdateDocumentRequest {
title: string;
}
24 changes: 23 additions & 1 deletion frontend/src/hooks/api/workspaceDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
GetWorkspaceDocumentResponse,
GetWorkspaceDocumentListResponse,
} from "./types/workspaceDocument";
import { useDispatch } from "react-redux";

import { useEffect } from "react";
import { setDocumentData } from "../../store/documentSlice";
import { UpdateDocumentRequest } from "./types/document";
import { useDispatch } from "react-redux";

export const generateGetWorkspaceDocumentListQueryKey = (workspaceId: string) => {
return ["workspaces", workspaceId, "documents"];
Expand Down Expand Up @@ -105,3 +107,23 @@ export const useCreateWorkspaceSharingTokenMutation = (workspaceId: string, docu
},
});
};

export const useUpdateDocumentTitleMutation = (workspaceId: string, documentId: string) => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (data: UpdateDocumentRequest) => {
const res = await axios.put<void>(
`/workspaces/${workspaceId}/documents/${documentId}/`,
data
);

return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: generateGetDocumentQueryKey(workspaceId, documentId),
});
},
});
};