From a465c67eec5207881de52d4bfb765a59bc1692d6 Mon Sep 17 00:00:00 2001 From: olloz26 Date: Wed, 30 Oct 2024 13:40:38 +0100 Subject: [PATCH] feat!: add documentation to projects --- client/.eslintrc.json | 4 + client/src/components/buttons/Button.tsx | 98 ++++++++++- .../form-field/LazyCkEditorRenderer.tsx | 38 +++++ .../components/form-field/TextAreaInput.tsx | 5 + client/src/components/form-field/ckEditor.css | 8 + .../src/components/formlabels/FormLabels.tsx | 4 +- .../ProjectPageContainer.tsx | 1 + .../Documentation/Documentation.tsx | 158 ++++++++++++++++++ .../ProjectOverviewPage.tsx | 4 + tests/cypress/e2e/projectDatasets.spec.ts | 7 +- tests/cypress/e2e/projectV2Session.spec.ts | 1 + tests/cypress/e2e/projectV2setup.spec.ts | 39 +++-- .../read-projectV2-without-documentation.json | 14 ++ .../fixtures/projectV2/read-projectV2.json | 3 +- tests/cypress/support/commands/datasets.ts | 8 +- .../support/renkulab-fixtures/projectV2.ts | 16 ++ 16 files changed, 386 insertions(+), 22 deletions(-) create mode 100644 client/src/components/form-field/LazyCkEditorRenderer.tsx create mode 100644 client/src/components/form-field/ckEditor.css create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx create mode 100644 tests/cypress/fixtures/projectV2/read-projectV2-without-documentation.json diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 544d011637..5894160a34 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", @@ -255,6 +257,7 @@ "stdout", "stockimages", "storages", + "strikethrough", "swiper", "tada", "telepresence", @@ -262,6 +265,7 @@ "thead", "toastify", "toggler", + "tokenizer", "tolerations", "toml", "tooltip", diff --git a/client/src/components/buttons/Button.tsx b/client/src/components/buttons/Button.tsx index 7818b7b2dc..25a7e142da 100644 --- a/client/src/components/buttons/Button.tsx +++ b/client/src/components/buttons/Button.tsx @@ -26,7 +26,7 @@ import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import cx from "classnames"; -import { Fragment, ReactNode, useRef, useState } from "react"; +import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { ArrowRight, ChevronDown, @@ -42,6 +42,7 @@ import { Col, DropdownMenu, DropdownToggle, + Tooltip, UncontrolledDropdown, UncontrolledTooltip, } from "reactstrap"; @@ -444,6 +445,100 @@ function EditButtonLink({ ); } +interface EditSaveButtonProps { + "data-cy"?: string; + disabled?: boolean; + toggle: () => void; + tooltipMessage?: string | null; + checksBeforeSave?: () => boolean; + checksBeforeSaveTooltipMessage?: () => string | null; +} +function EditSaveButton({ + "data-cy": dataCy, + disabled, + toggle, + tooltipMessage = null, + checksBeforeSave = () => false, + checksBeforeSaveTooltipMessage = () => null, +}: EditSaveButtonProps) { + const [localDisabled, setLocalDisabled] = useState(disabled); + const ref = useRef(null); + const saveButtonRef = useRef(null); + const [editMode, setEditMode] = useState(false); + const [checksBeforeSaveTooltip, setChecksBeforeSaveTooltip] = useState(false); + + useEffect(() => { + setLocalDisabled(disabled); + }, [disabled]); + + return ( + <> + + {!editMode && localDisabled ? ( + + ) : editMode ? ( + + + {checksBeforeSaveTooltip && localDisabled ? ( + + {checksBeforeSaveTooltipMessage()} + + ) : tooltipMessage ? ( + + {tooltipMessage} + + ) : ( + <> + )} + + + ) : ( + + )} + + + ); +} + export function PlusRoundButton({ "data-cy": dataCy, handler, @@ -478,6 +573,7 @@ export { ButtonWithMenu, CardButton, EditButtonLink, + EditSaveButton, GoBackButton, InlineSubmitButton, RefreshButton, diff --git a/client/src/components/form-field/LazyCkEditorRenderer.tsx b/client/src/components/form-field/LazyCkEditorRenderer.tsx new file mode 100644 index 0000000000..3a34ff1867 --- /dev/null +++ b/client/src/components/form-field/LazyCkEditorRenderer.tsx @@ -0,0 +1,38 @@ +/*! + * Copyright 2023 - 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 { lazy, Suspense } from "react"; +import { Loader } from "../Loader"; + +const CkEditor = lazy(() => import("./CkEditor")); + +export function LazyCkEditorRenderer(props: { name: string; data: string }) { + return ( + }> + {}} + /> + + ); +} diff --git a/client/src/components/form-field/TextAreaInput.tsx b/client/src/components/form-field/TextAreaInput.tsx index d53d34244b..04c6cf926b 100644 --- a/client/src/components/form-field/TextAreaInput.tsx +++ b/client/src/components/form-field/TextAreaInput.tsx @@ -111,6 +111,11 @@ interface TextAreaInputProps { name: string; register: UseFormRegisterReturn; required?: boolean; + wordCount?: (stats: { + exact: boolean; + characters: number; + words: number; + }) => void; } function TextAreaInput(props: TextAreaInputProps) { 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..ada77d03a5 100644 --- a/client/src/components/formlabels/FormLabels.tsx +++ b/client/src/components/formlabels/FormLabels.tsx @@ -57,10 +57,12 @@ interface InputLabelProps extends LabelProps { } const InputLabel = ({ text, isRequired = false }: InputLabelProps) => { - return ( + return text ? ( + ) : ( + <> ); }; diff --git a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx b/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx index f8d2c88f93..449f2cbaf7 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx @@ -41,6 +41,7 @@ export default function ProjectPageContainer() { useGetNamespacesByNamespaceProjectsAndSlugQuery({ namespace: namespace ?? "", slug: slug ?? "", + withDocumentation: true, }); if (isLoading) return ; 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..1cba70189f --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx @@ -0,0 +1,158 @@ +/*! + * 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, useState } from "react"; + +import { FileEarmarkText } from "react-bootstrap-icons"; +import { Card, CardBody, CardHeader, ListGroup } from "reactstrap"; + +import { EditSaveButton } from "../../../../components/buttons/Button"; + +import { Project } from "../../../projectsV2/api/projectV2.api"; +import { usePatchProjectsByProjectIdMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; +import TextAreaInput from "../../../../components/form-field/TextAreaInput.tsx"; +import { useForm } from "react-hook-form"; +import { LazyCkEditorRenderer } from "../../../../components/form-field/LazyCkEditorRenderer.tsx"; + +interface DocumentationForm { + description: string; +} + +export default function Documentation({ project }: { project: Project }) { + const [updateProject] = usePatchProjectsByProjectIdMutation(); + const [description, setDescription] = useState(project.documentation || ""); + + const { control, handleSubmit, setValue, getValues, register } = + useForm(); + const onSubmit = useCallback( + (data: DocumentationForm) => { + setDescription(data.description); + setShowEditor(false); + updateProject({ + "If-Match": project.etag ? project.etag : "", + projectId: project.id, + projectPatch: { documentation: data.description }, + }); + }, + [project.etag, project.id, updateProject] + ); + + const [showEditor, setShowEditor] = useState(false); + const toggle = () => { + setShowEditor(!showEditor); + setValue("description", description); + }; + + const markdownCharacterLimit = 5000; + const aboutCharacterLimit = + Math.floor(((2 / 3) * markdownCharacterLimit) / 10) * 10; + const [characterLimit, setCharacterLimit] = useState(aboutCharacterLimit); + const [character, setCharacter] = useState(0); + const [disabledSaveButton, setDisabledSaveButton] = useState(false); + + const wordCount = (stats: { + exact: boolean; + characters: number; + words: number; + }) => { + stats.exact + ? setCharacterLimit(markdownCharacterLimit) + : setCharacterLimit(aboutCharacterLimit); + setCharacter(stats.characters); + }; + + const descriptionField = register("description"); + { + const descriptionFieldTmp = descriptionField.onChange; + descriptionField.onChange = (value) => { + setDisabledSaveButton(false); + return descriptionFieldTmp(value); + }; + } + + return ( + +
+ +
+

+ + Documentation +

+ + {showEditor ? ( + + {character} of + {characterLimit == aboutCharacterLimit ? " about " : " "} + {characterLimit} characters   + + ) : ( + <> + )} + { + if ( + getValues("description").length <= markdownCharacterLimit + ) { + return true; + } + setDisabledSaveButton(true); + return false; + }} + checksBeforeSaveTooltipMessage={() => + `Documentation is too long.\n The document can not be longer\nthan ${markdownCharacterLimit} characters.` + } + /> + +
+
+ + {showEditor ? ( + + + control={control} + getValue={() => getValues("description")} + name="description" + label="Description" + register={descriptionField} + wordCount={wordCount} + /> + + ) : ( + +
+ +
+ )} +
+
+
+ ); +} 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..ea5acb74ab 100644 --- a/tests/cypress/e2e/projectDatasets.spec.ts +++ b/tests/cypress/e2e/projectDatasets.spec.ts @@ -153,9 +153,12 @@ 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"); + // eslint-disable-next-line max-nested-callbacks + .then((element) => + element[0].ckeditorInstance.setData(". New description") + ); cy.get("div.tree-container").contains("air_quality_no2.txt"); cy.get('[data-cy="dropzone"]').attachFile( diff --git a/tests/cypress/e2e/projectV2Session.spec.ts b/tests/cypress/e2e/projectV2Session.spec.ts index 7bd7f67039..36e2be48e7 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 3f5b71a5d7..17dbf1ff0a 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 @@ -236,12 +242,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 @@ -255,11 +263,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 @@ -275,13 +285,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(); @@ -304,7 +316,6 @@ describe("Set up data connectors", () => { it("unlink data connector not allowed", () => { fixtures - .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) .listProjectDataConnectors() .getDataConnector() .getProjectV2Permissions({ 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..8298b9dad7 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": "$\\sqrt(2)$" } diff --git a/tests/cypress/support/commands/datasets.ts b/tests/cypress/support/commands/datasets.ts index 8aceb47c8d..e1455320b8 100644 --- a/tests/cypress/support/commands/datasets.ts +++ b/tests/cypress/support/commands/datasets.ts @@ -69,10 +69,12 @@ 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() - .type(newDataset.description); + .then((element) => + element[0].ckeditorInstance.setData(newDataset.description) + ); if (newDataset.file) { cy.get('[data-cy="dropzone"]').attachFile( diff --git a/tests/cypress/support/renkulab-fixtures/projectV2.ts b/tests/cypress/support/renkulab-fixtures/projectV2.ts index 2a8842c35e..609ecc96e4 100644 --- a/tests/cypress/support/renkulab-fixtures/projectV2.ts +++ b/tests/cypress/support/renkulab-fixtures/projectV2.ts @@ -380,6 +380,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",