diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 47799514f3..3dccc2141e 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -93,6 +93,7 @@ "apiversion", "ascii", "asciimath", + "autoformat", "autosave", "autosaved", "autosaves", @@ -106,6 +107,7 @@ "bool", "booleans", "borderless", + "bulleted", "calc", "cancellable", "cancelled", @@ -257,6 +259,7 @@ "stdout", "stockimages", "storages", + "strikethrough", "swiper", "tada", "telepresence", @@ -264,6 +267,7 @@ "thead", "toastify", "toggler", + "tokenizer", "tolerations", "toml", "tooltip", diff --git a/client/src/components/form-field/TextAreaInput.tsx b/client/src/components/form-field/TextAreaInput.tsx index d53d34244b..d283156c1d 100644 --- a/client/src/components/form-field/TextAreaInput.tsx +++ b/client/src/components/form-field/TextAreaInput.tsx @@ -17,6 +17,7 @@ */ // TODO: Upgrade to ckeditor5 v6.0.0 to get TS support +import cx from "classnames"; import React from "react"; import { Controller } from "react-hook-form"; import type { @@ -42,9 +43,9 @@ function EditMarkdownSwitch(props: EditMarkdownSwitchProps) { const outputType = "markdown"; const switchLabel = outputType === "markdown" ? "Raw Markdown" : "Raw HTML"; return ( -
+
{ error?: FieldError; getValue: () => string; help?: string | React.ReactNode; - label: string; + label?: string; name: string; register: UseFormRegisterReturn; required?: boolean; @@ -119,12 +120,14 @@ function TextAreaInput(props: TextAreaInputProps) { return (
-
- +
+ {props.label && ( + + )}
diff --git a/client/src/components/form-field/ckEditor.css b/client/src/components/form-field/ckEditor.css new file mode 100644 index 0000000000..2af6ea90ef --- /dev/null +++ b/client/src/components/form-field/ckEditor.css @@ -0,0 +1,8 @@ +.ck.ck-editor__main > .ck-editor__editable:not(.ck-focused) { + border: 0px; +} +.ck.ck-editor__top + .ck-sticky-panel:not(.ck-focused) + .ck-sticky-panel__content:not(.ck-focused) { + border: 0px; +} diff --git a/client/src/components/formlabels/FormLabels.tsx b/client/src/components/formlabels/FormLabels.tsx index 592cad3642..e9d5ab31eb 100644 --- a/client/src/components/formlabels/FormLabels.tsx +++ b/client/src/components/formlabels/FormLabels.tsx @@ -57,11 +57,11 @@ interface InputLabelProps extends LabelProps { } const InputLabel = ({ text, isRequired = false }: InputLabelProps) => { - return ( + return text ? ( - ); + ) : null; }; const LoadingLabel = ({ className, text }: LabelProps) => { diff --git a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx b/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx index dbd6d1d144..e652c528f7 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx @@ -44,6 +44,7 @@ export default function ProjectPageContainer() { useGetNamespacesByNamespaceProjectsAndSlugQuery({ namespace: namespace ?? "", slug: slug ?? "", + withDocumentation: true, }); const navigate = useNavigate(); diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.module.scss b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.module.scss new file mode 100644 index 0000000000..10b337f3b9 --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.module.scss @@ -0,0 +1,3 @@ +.modalBody { + max-height: 75vh; +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx new file mode 100644 index 0000000000..4789721c49 --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx @@ -0,0 +1,279 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { useCallback, useEffect, useState } from "react"; + +import { FileEarmarkText, Pencil, XLg } from "react-bootstrap-icons"; +import { + Button, + Card, + CardBody, + CardHeader, + Form, + ModalBody, + ModalHeader, + ModalFooter, +} from "reactstrap"; +import { useForm } from "react-hook-form"; + +import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; +import TextAreaInput from "../../../../components/form-field/TextAreaInput.tsx"; +import { Loader } from "../../../../components/Loader"; +import LazyRenkuMarkdown from "../../../../components/markdown/LazyRenkuMarkdown"; +import ScrollableModal from "../../../../components/modal/ScrollableModal"; + +import styles from "./Documentation.module.scss"; +import PermissionsGuard from "../../../permissionsV2/PermissionsGuard"; +import { Project } from "../../../projectsV2/api/projectV2.api"; +import { usePatchProjectsByProjectIdMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; + +import useProjectPermissions from "../../utils/useProjectPermissions.hook"; + +// Taken from src/features/projectsV2/api/projectV2.openapi.json +const DESCRIPTION_MAX_LENGTH = 5000; + +interface DocumentationForm { + documentation: string; +} + +interface DocumentationProps { + project: Project; +} + +export default function Documentation({ project }: DocumentationProps) { + const permissions = useProjectPermissions({ projectId: project.id }); + const [isModalOpen, setModalOpen] = useState(false); + const toggleOpen = useCallback(() => { + setModalOpen((open) => !open); + }, []); + + return ( + <> + + +
+

+ + Documentation +

+
+ + + Edit + + } + requestedPermission="write" + userPermissions={permissions} + /> +
+
+
+ +
+ {project.documentation ? ( + + ) : ( +

+ Describe your project, so others can understand what it does and + how to use it. +

+ )} +
+
+
+ + + ); +} + +interface DocumentationModalProps extends DocumentationProps { + isOpen: boolean; + toggle: () => void; +} + +function DocumentationModal({ + isOpen, + project, + toggle, +}: DocumentationModalProps) { + const [updateProject, result] = usePatchProjectsByProjectIdMutation(); + const { isLoading } = result; + + const { + control, + formState: { errors, isDirty }, + handleSubmit, + getValues, + register, + reset, + watch, + } = useForm({ + defaultValues: { + documentation: project.documentation || "", + }, + }); + + useEffect(() => { + reset({ + documentation: project.documentation || "", + }); + }, [project.documentation, reset]); + + const onSubmit = useCallback( + (data: DocumentationForm) => { + updateProject({ + "If-Match": project.etag ? project.etag : "", + projectId: project.id, + projectPatch: { documentation: data.documentation }, + }); + }, + [project.etag, project.id, updateProject] + ); + + useEffect(() => { + if (!isOpen) { + reset({ documentation: project.documentation || "" }); + result.reset(); + } + }, [isOpen, project.documentation, reset, result]); + + useEffect(() => { + if (result.isSuccess) { + toggle(); + } + }, [result.isSuccess, toggle]); + + const documentationField = register("documentation", { + maxLength: { + message: `Documentation is limited to ${DESCRIPTION_MAX_LENGTH} characters.`, + value: DESCRIPTION_MAX_LENGTH, + }, + }); + return ( + + +
+ + Documentation +
+
+
+ +
+ + control={control} + getValue={() => getValues("documentation")} + name="documentation" + register={documentationField} + /> +
+
+ + {errors.documentation && ( +
+ {errors.documentation.message ? ( + <>{errors.documentation.message} + ) : ( + <>Documentation text is invalid + )} +
+ )} + {result.error && } + + + +
+
+
+ ); +} + +function DocumentationWordCount({ + watch, +}: { + watch: ReturnType>["watch"]; +}) { + const documentation = watch("documentation"); + const charCount = documentation.length; + const isCloseToLimit = charCount >= DESCRIPTION_MAX_LENGTH - 10; + return ( +
+ + {charCount} + {" "} + of {DESCRIPTION_MAX_LENGTH} characters +
+ ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx index bdcb693f48..edbed9494b 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx @@ -22,6 +22,7 @@ import SessionsV2 from "../../sessionsV2/SessionsV2"; import { useProject } from "../ProjectPageContainer/ProjectPageContainer"; import { CodeRepositoriesDisplay } from "./CodeRepositories/RepositoriesBox"; import ProjectDataConnectorsBox from "./DataConnectors/ProjectDataConnectorsBox"; +import Documentation from "./Documentation/Documentation"; import ProjectInformation from "./ProjectInformation/ProjectInformation"; export default function ProjectOverviewPage() { @@ -40,6 +41,9 @@ export default function ProjectOverviewPage() { + + + diff --git a/tests/cypress/e2e/projectDatasets.spec.ts b/tests/cypress/e2e/projectDatasets.spec.ts index c699e3c72a..f18db0937a 100644 --- a/tests/cypress/e2e/projectDatasets.spec.ts +++ b/tests/cypress/e2e/projectDatasets.spec.ts @@ -153,7 +153,7 @@ describe("Project dataset", () => { cy.get("div.ck-editor__main").contains("Dataset for testing purposes"); cy.getDataCy("ckeditor-description") - .find("p") + .find(".ck-content[contenteditable=true]") .click() .type(". New description"); diff --git a/tests/cypress/e2e/projectV2.spec.ts b/tests/cypress/e2e/projectV2.spec.ts index e23c4db68f..62b6cef61e 100644 --- a/tests/cypress/e2e/projectV2.spec.ts +++ b/tests/cypress/e2e/projectV2.spec.ts @@ -130,6 +130,30 @@ describe("Navigate to project", () => { ); cy.getDataCy("project-info-card").contains("public"); cy.getDataCy("project-info-card").contains("user1-uuid"); + cy.getDataCy("project-documentation-text").should("be.visible"); + cy.getDataCy("project-documentation-text") + .contains( + "A description of this project, supporting markdown and math symbols" + ) + .should("be.visible"); + cy.getDataCy("project-documentation-edit").should("not.exist"); + }); + + it("show project empty documentation", () => { + fixtures.readProjectV2({ + overrides: { + documentation: undefined, + }, + }); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + // check project data + cy.getDataCy("project-documentation-text").should("be.visible"); + cy.getDataCy("project-documentation-text") + .contains( + "Describe your project, so others can understand what it does and how to use it." + ) + .should("be.visible"); }); it("shows at most 5 members, owners first", () => { @@ -199,6 +223,29 @@ describe("Edit v2 project", () => { cy.contains("new name").should("be.visible"); }); + it("changes project documentation", () => { + fixtures.readProjectV2().updateProjectV2().listNamespaceV2(); + cy.contains("My projects").should("be.visible"); + cy.getDataCy("dashboard-project-list") + .contains("a", "test 2 v2-project") + .should("be.visible") + .click(); + cy.wait("@readProjectV2"); + cy.getDataCy("project-documentation-edit").click(); + cy.getDataCy("project-documentation-modal-body") + .contains( + "A description of this project, supporting markdown and math symbols" + ) + .should("be.visible"); + cy.getDataCy("project-documentation-modal-body") + .find(".ck-content") + .click() + .clear() + .type("new description"); + cy.getDataCy("project-documentation-modal-footer").contains("Save").click(); + cy.getDataCy("project-documentation-modal-body").should("not.be.visible"); + }); + it("changes project namespace", () => { fixtures .readProjectV2() diff --git a/tests/cypress/e2e/projectV2Session.spec.ts b/tests/cypress/e2e/projectV2Session.spec.ts index 39fdd9a241..63671ae3bc 100644 --- a/tests/cypress/e2e/projectV2Session.spec.ts +++ b/tests/cypress/e2e/projectV2Session.spec.ts @@ -35,6 +35,7 @@ describe("launch sessions with data connectors", () => { .readGroupV2Namespace({ groupSlug: "user1-uuid" }) .landingUserProjects() .readProjectV2() + .readProjectV2WithoutDocumentation() .getStorageSchema({ fixture: "cloudStorage/storage-schema-s3.json" }) .resourcePoolsTest() .getResourceClass() diff --git a/tests/cypress/e2e/projectV2setup.spec.ts b/tests/cypress/e2e/projectV2setup.spec.ts index ec8bd6abf1..80f8be2d0a 100644 --- a/tests/cypress/e2e/projectV2setup.spec.ts +++ b/tests/cypress/e2e/projectV2setup.spec.ts @@ -39,14 +39,16 @@ describe("Set up project components", () => { it("set up repositories", () => { fixtures - .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) + .readProjectV2WithoutDocumentation({ + fixture: "projectV2/read-projectV2-empty.json", + }) .listProjectDataConnectors() .getDataConnector() .updateProjectV2({ fixture: "projectV2/update-projectV2-one-repository.json", }); cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); - cy.wait("@readProjectV2"); + cy.wait("@readProjectV2WithoutDocumentation"); // add code repositories fixtures.readProjectV2({ name: "getProjectAfterUpdate", @@ -63,7 +65,7 @@ describe("Set up project components", () => { cy.wait("@getProjectAfterUpdate"); // edit code repository - cy.getDataCy("code-repository-edit").click(); + cy.getDataCy("code-repository-edit").first().click(); cy.getDataCy("project-edit-repository-url").type("2"); cy.getDataCy("edit-code-repository-modal-button").click(); cy.wait("@updateProjectV2"); @@ -80,7 +82,9 @@ describe("Set up project components", () => { body: [], }).as("getSessionsV2"); fixtures - .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) + .readProjectV2WithoutDocumentation({ + fixture: "projectV2/read-projectV2-empty.json", + }) .getProjectV2Permissions({ projectId: "01HYJE5FR1JV4CWFMBFJQFQ4RM" }) .listProjectDataConnectors() .getDataConnector() @@ -91,7 +95,7 @@ describe("Set up project components", () => { .getResourceClass() .environments(); cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); - cy.wait("@readProjectV2"); + cy.wait("@readProjectV2WithoutDocumentation"); cy.wait("@getSessionsV2"); cy.wait("@sessionLaunchers"); // ADD SESSION CUSTOM IMAGE @@ -186,7 +190,9 @@ describe("Set up data connectors", () => { it("create a simple data connector", () => { fixtures - .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) + .readProjectV2WithoutDocumentation({ + fixture: "projectV2/read-projectV2-empty.json", + }) .listProjectDataConnectors() .getDataConnector() .getStorageSchema({ fixture: "cloudStorage/storage-schema-s3.json" }) @@ -194,7 +200,7 @@ describe("Set up data connectors", () => { .postDataConnector({ namespace: "user1-uuid", visibility: "public" }) .postDataConnectorProjectLink({ dataConnectorId: "ULID-5" }); cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); - cy.wait("@readProjectV2"); + cy.wait("@readProjectV2WithoutDocumentation"); cy.wait("@listProjectDataConnectors"); // add data connector @@ -251,12 +257,14 @@ describe("Set up data connectors", () => { it("link a data connector", () => { fixtures - .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) + .readProjectV2WithoutDocumentation({ + fixture: "projectV2/read-projectV2-empty.json", + }) .listProjectDataConnectors() .getDataConnectorByNamespaceAndSlug() .postDataConnectorProjectLink({ dataConnectorId: "ULID-1" }); cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); - cy.wait("@readProjectV2"); + cy.wait("@readProjectV2WithoutDocumentation"); cy.wait("@listProjectDataConnectors"); // add data connector @@ -270,11 +278,13 @@ describe("Set up data connectors", () => { it("link a data connector not found", () => { fixtures - .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) + .readProjectV2WithoutDocumentation({ + fixture: "projectV2/read-projectV2-empty.json", + }) .listProjectDataConnectors() .getDataConnectorByNamespaceAndSlugNotFound(); cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); - cy.wait("@readProjectV2"); + cy.wait("@readProjectV2WithoutDocumentation"); cy.wait("@listProjectDataConnectors"); // add data connector @@ -290,13 +300,15 @@ describe("Set up data connectors", () => { it("unlink a data connector", () => { fixtures - .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) + .readProjectV2WithoutDocumentation({ + fixture: "projectV2/read-projectV2-empty.json", + }) .listProjectDataConnectors() .getDataConnector() .deleteDataConnectorProjectLink(); cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); - cy.wait("@readProjectV2"); + cy.wait("@readProjectV2WithoutDocumentation"); cy.wait("@listProjectDataConnectors"); cy.contains("example storage").should("be.visible").click(); @@ -319,7 +331,6 @@ describe("Set up data connectors", () => { it("unlink data connector not allowed", () => { fixtures - .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) .listProjectDataConnectors() .getDataConnector() .getProjectV2Permissions({ @@ -416,6 +427,7 @@ describe("Set up data connectors", () => { .getDataConnectorPermissions() .patchDataConnector({ namespace: "user1-uuid" }) .patchDataConnectorSecrets({ + content: [], shouldNotBeCalled: true, }); diff --git a/tests/cypress/fixtures/projectV2/read-projectV2-without-documentation.json b/tests/cypress/fixtures/projectV2/read-projectV2-without-documentation.json new file mode 100644 index 0000000000..01fdc4de64 --- /dev/null +++ b/tests/cypress/fixtures/projectV2/read-projectV2-without-documentation.json @@ -0,0 +1,14 @@ +{ + "id": "THEPROJECTULID26CHARACTERS", + "name": "test 2 v2-project", + "slug": "test-2-v2-project", + "namespace": "user1-uuid", + "creation_date": "2023-11-15T09:55:59Z", + "created_by": "user1-uuid", + "repositories": [ + "https://domain.name/repo1.git", + "https://domain.name/repo2.git" + ], + "visibility": "public", + "description": "Project 2 description" +} diff --git a/tests/cypress/fixtures/projectV2/read-projectV2.json b/tests/cypress/fixtures/projectV2/read-projectV2.json index 03d34935da..55f0b02524 100644 --- a/tests/cypress/fixtures/projectV2/read-projectV2.json +++ b/tests/cypress/fixtures/projectV2/read-projectV2.json @@ -11,5 +11,6 @@ ], "visibility": "public", "description": "Project 2 description", - "secrets_mount_directory": "/secrets" + "secrets_mount_directory": "/secrets", + "documentation": "A description of this project, supporting **markdown** and math symbols like: $\\sqrt 2$." } diff --git a/tests/cypress/support/commands/datasets.ts b/tests/cypress/support/commands/datasets.ts index 8aceb47c8d..2e27a75340 100644 --- a/tests/cypress/support/commands/datasets.ts +++ b/tests/cypress/support/commands/datasets.ts @@ -69,9 +69,10 @@ function newDataset(newDataset: Dataset) { } if (newDataset.description) - cy.get("[data-cy='ckeditor-description']") - .find("p") + cy.getDataCy("ckeditor-description") + .find(".ck-content[contenteditable=true]") .click() + .clear() .type(newDataset.description); if (newDataset.file) { diff --git a/tests/cypress/support/renkulab-fixtures/projectV2.ts b/tests/cypress/support/renkulab-fixtures/projectV2.ts index b20c917ca5..36682f059d 100644 --- a/tests/cypress/support/renkulab-fixtures/projectV2.ts +++ b/tests/cypress/support/renkulab-fixtures/projectV2.ts @@ -29,6 +29,7 @@ interface ProjectOverrides { keywords?: string[]; template_id?: string; is_template?: boolean; + documentation: string | null | undefined; } /** @@ -380,6 +381,22 @@ export function ProjectV2(Parent: T) { return this; } + readProjectV2WithoutDocumentation(args?: ProjectV2NameArgs) { + const { + fixture = "projectV2/read-projectV2-without-documentation.json", + name = "readProjectV2WithoutDocumentation", + namespace = "user1-uuid", + projectSlug = "test-2-v2-project", + } = args ?? {}; + const response = { fixture }; + cy.intercept( + "GET", + `/ui-server/api/data/namespaces/${namespace}/projects/${projectSlug}`, + response + ).as(name); + return this; + } + readProjectV2ById(args?: ProjectV2IdArgs) { const { fixture = "projectV2/read-projectV2.json",