From 1667c10dd763bbe7189c7cef076b01f1b6966356 Mon Sep 17 00:00:00 2001 From: olloz26 Date: Wed, 30 Oct 2024 13:40:38 +0100 Subject: [PATCH 1/3] 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 | 40 +++-- .../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, 387 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 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/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 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.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 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..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 b20c917ca5..0ec54a89c7 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", From 8da0b9c991ac29937f0a885edc304d380f6f7efb Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Fri, 10 Jan 2025 14:59:35 +0100 Subject: [PATCH 2/3] revert markdown renderer change and ckeditor upgrade + minor changes for UI consistency --- client/src/components/buttons/Button.tsx | 98 +----- .../form-field/LazyCkEditorRenderer.tsx | 38 -- .../components/form-field/TextAreaInput.tsx | 26 +- .../src/components/formlabels/FormLabels.tsx | 4 +- .../Documentation/Documentation.module.scss | 3 + .../Documentation/Documentation.tsx | 333 ++++++++++++------ tests/cypress/e2e/projectDatasets.spec.ts | 5 +- tests/cypress/e2e/projectV2.spec.ts | 47 +++ .../fixtures/projectV2/read-projectV2.json | 2 +- tests/cypress/support/commands/datasets.ts | 5 +- .../support/renkulab-fixtures/projectV2.ts | 1 + 11 files changed, 295 insertions(+), 267 deletions(-) delete mode 100644 client/src/components/form-field/LazyCkEditorRenderer.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.module.scss diff --git a/client/src/components/buttons/Button.tsx b/client/src/components/buttons/Button.tsx index 25a7e142da..7818b7b2dc 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, useEffect, useRef, useState } from "react"; +import { Fragment, ReactNode, useRef, useState } from "react"; import { ArrowRight, ChevronDown, @@ -42,7 +42,6 @@ import { Col, DropdownMenu, DropdownToggle, - Tooltip, UncontrolledDropdown, UncontrolledTooltip, } from "reactstrap"; @@ -445,100 +444,6 @@ 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, @@ -573,7 +478,6 @@ 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 deleted file mode 100644 index 3a34ff1867..0000000000 --- a/client/src/components/form-field/LazyCkEditorRenderer.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/*! - * 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 04c6cf926b..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; - wordCount?: (stats: { - exact: boolean; - characters: number; - words: number; - }) => void; } function TextAreaInput(props: TextAreaInputProps) { @@ -124,12 +120,14 @@ function TextAreaInput(props: TextAreaInputProps) { return (
-
- +
+ {props.label && ( + + )}
diff --git a/client/src/components/formlabels/FormLabels.tsx b/client/src/components/formlabels/FormLabels.tsx index ada77d03a5..e9d5ab31eb 100644 --- a/client/src/components/formlabels/FormLabels.tsx +++ b/client/src/components/formlabels/FormLabels.tsx @@ -61,9 +61,7 @@ const InputLabel = ({ text, isRequired = false }: InputLabelProps) => { - ) : ( - <> - ); + ) : null; }; const LoadingLabel = ({ className, text }: LabelProps) => { 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 index 1cba70189f..88c0fc9bc3 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx @@ -17,142 +17,261 @@ */ import cx from "classnames"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; -import { FileEarmarkText } from "react-bootstrap-icons"; -import { Card, CardBody, CardHeader, ListGroup } from "reactstrap"; +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 { EditSaveButton } from "../../../../components/buttons/Button"; +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 TextAreaInput from "../../../../components/form-field/TextAreaInput.tsx"; -import { useForm } from "react-hook-form"; -import { LazyCkEditorRenderer } from "../../../../components/form-field/LazyCkEditorRenderer.tsx"; + +import useProjectPermissions from "../../utils/useProjectPermissions.hook"; + +// Taken from src/features/projectsV2/api/projectV2.openapi.json +const DESCRIPTION_MAX_LENGTH = 5000; interface DocumentationForm { - description: string; + documentation: 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] - ); +interface DocumentationProps { + project: Project; +} - 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); - }; - } +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

- - {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.` +
+ + + } + requestedPermission="write" + userPermissions={permissions} /> - +
- {showEditor ? ( - - - control={control} - getValue={() => getValues("description")} - name="description" - label="Description" - register={descriptionField} - wordCount={wordCount} - /> - +
+ {project.documentation != null && + project.documentation.length > 0 ? ( + + ) : ( +

+ 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 { isSuccess, isLoading, error } = result; + + const { + control, + formState: { errors }, + handleSubmit, + getValues, + register, + reset, + setValue, + watch, + } = useForm({ + defaultValues: { + documentation: project.documentation || "", + }, + }); + + useEffect(() => { + setValue("documentation", project.documentation || ""); + }, [project.documentation, setValue]); + + 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 + )} +
) : ( - -
- -
+
)} - - -
+ {isSuccess != null && !isSuccess && ( + + )} + + + + + + + ); +} + +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/tests/cypress/e2e/projectDatasets.spec.ts b/tests/cypress/e2e/projectDatasets.spec.ts index ea5acb74ab..f18db0937a 100644 --- a/tests/cypress/e2e/projectDatasets.spec.ts +++ b/tests/cypress/e2e/projectDatasets.spec.ts @@ -155,10 +155,7 @@ describe("Project dataset", () => { cy.getDataCy("ckeditor-description") .find(".ck-content[contenteditable=true]") .click() - // eslint-disable-next-line max-nested-callbacks - .then((element) => - element[0].ckeditorInstance.setData(". New description") - ); + .type(". New description"); cy.get("div.tree-container").contains("air_quality_no2.txt"); cy.get('[data-cy="dropzone"]').attachFile( 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/fixtures/projectV2/read-projectV2.json b/tests/cypress/fixtures/projectV2/read-projectV2.json index 8298b9dad7..55f0b02524 100644 --- a/tests/cypress/fixtures/projectV2/read-projectV2.json +++ b/tests/cypress/fixtures/projectV2/read-projectV2.json @@ -12,5 +12,5 @@ "visibility": "public", "description": "Project 2 description", "secrets_mount_directory": "/secrets", - "documentation": "$\\sqrt(2)$" + "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 e1455320b8..2e27a75340 100644 --- a/tests/cypress/support/commands/datasets.ts +++ b/tests/cypress/support/commands/datasets.ts @@ -72,9 +72,8 @@ function newDataset(newDataset: Dataset) { cy.getDataCy("ckeditor-description") .find(".ck-content[contenteditable=true]") .click() - .then((element) => - element[0].ckeditorInstance.setData(newDataset.description) - ); + .clear() + .type(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 0ec54a89c7..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; } /** From a074ae94e54145a4cfab1edc5c2dd1b622bb19f7 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Fri, 17 Jan 2025 15:25:46 +0100 Subject: [PATCH 3/3] review fixes --- .../Documentation/Documentation.tsx | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx index 88c0fc9bc3..4789721c49 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx @@ -89,6 +89,7 @@ export default function Documentation({ project }: DocumentationProps) { size="sm" > + Edit } requestedPermission="write" @@ -99,8 +100,7 @@ export default function Documentation({ project }: DocumentationProps) {
- {project.documentation != null && - project.documentation.length > 0 ? ( + {project.documentation ? ( ) : (

@@ -131,16 +131,15 @@ function DocumentationModal({ toggle, }: DocumentationModalProps) { const [updateProject, result] = usePatchProjectsByProjectIdMutation(); - const { isSuccess, isLoading, error } = result; + const { isLoading } = result; const { control, - formState: { errors }, + formState: { errors, isDirty }, handleSubmit, getValues, register, reset, - setValue, watch, } = useForm({ defaultValues: { @@ -149,8 +148,10 @@ function DocumentationModal({ }); useEffect(() => { - setValue("documentation", project.documentation || ""); - }, [project.documentation, setValue]); + reset({ + documentation: project.documentation || "", + }); + }, [project.documentation, reset]); const onSubmit = useCallback( (data: DocumentationForm) => { @@ -215,7 +216,7 @@ function DocumentationModal({ className="border-top" data-cy="project-documentation-modal-footer" > - {errors.documentation ? ( + {errors.documentation && (

{errors.documentation.message ? ( <>{errors.documentation.message} @@ -223,12 +224,8 @@ function DocumentationModal({ <>Documentation text is invalid )}
- ) : ( -
- )} - {isSuccess != null && !isSuccess && ( - )} + {result.error && } -