From 4f07dcd66250403754d878d51904c646b9239b51 Mon Sep 17 00:00:00 2001 From: olloz26 Date: Wed, 30 Oct 2024 13:40:38 +0100 Subject: [PATCH 01/13] 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 437d7e6810f697aec367c2419ec6730f6a2b6356 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Fri, 10 Jan 2025 14:59:35 +0100 Subject: [PATCH 02/13] 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 204064c7e3..572567b13e 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 413b889940e72f430722c83776186aaeb0d1dc25 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Fri, 17 Jan 2025 15:25:46 +0100 Subject: [PATCH 03/13] 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 && } - + + +
- - control={control} - getValue={getValue} - name="documentation" - register={documentationField} - /> + {displayMode === "preview" ? ( +
+ +
+ ) : ( + + control={control} + value={watch("documentation")} + name="documentation" + register={documentationField} + /> + )}
} - diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/DocumentationInput.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/DocumentationInput.tsx index 69249246fc..f7c14f9a13 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/DocumentationInput.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/DocumentationInput.tsx @@ -16,73 +16,27 @@ * limitations under the License. */ -import { Crepe } from "@milkdown/crepe"; -import { Milkdown, MilkdownProvider, useEditor } from "@milkdown/react"; import cx from "classnames"; -import { useCallback } from "react"; -import type { - Control, - FieldError, - FieldValues, - UseFormRegisterReturn, +import { + Controller, + type Control, + type FieldError, + type FieldValues, + type Path, + type UseFormRegisterReturn, } from "react-hook-form"; -import { FormGroup, FormText, Label } from "reactstrap"; +import { FormGroup, FormText, Input, Label } from "reactstrap"; import { ErrorLabel, InputLabel, } from "../../../../components/formlabels/FormLabels"; -import "@milkdown/crepe/theme/common/style.css"; -import "@milkdown/crepe/theme/frame.css"; -import styles from "./Documentation.module.scss"; - -function MilkdownEditor( - props: DocumentationInputProps -) { - const value = props.getValue(); - const onMarkdownUpdated = useCallback( - (_ctx: unknown, markdown: string) => { - props.register.onChange({ - target: { name: props.name, value: markdown }, - }); - }, - [props.register, props.name] - ); - useEditor((root) => { - const crepe = new Crepe({ root, defaultValue: value }); - crepe.on((listener) => { - listener.markdownUpdated(onMarkdownUpdated); - }); - return crepe; - }, []); - - return ; -} - -function MilkdownEditorWrapper( - props: DocumentationInputProps -) { - return ( - - - - ); - // -} - interface DocumentationInputProps { control: Control; error?: FieldError; - getValue: () => string; + value: string; help?: string | React.ReactNode; label?: string; name: string; @@ -93,6 +47,7 @@ interface DocumentationInputProps { function DocumentationInput( props: DocumentationInputProps ) { + const value = props.value; return (
@@ -106,11 +61,21 @@ function DocumentationInput( )}
-
- +
+ } + render={({ field }) => ( + + )} + />
{props.help && {props.help}} {props.error && ( diff --git a/tests/cypress/e2e/projectV2.spec.ts b/tests/cypress/e2e/projectV2.spec.ts index 267e0d8c1b..6ac415667a 100644 --- a/tests/cypress/e2e/projectV2.spec.ts +++ b/tests/cypress/e2e/projectV2.spec.ts @@ -234,14 +234,24 @@ describe("Edit v2 project", () => { cy.getDataCy("project-documentation-edit").click(); cy.getDataCy("project-documentation-modal-body") .contains( - "A description of this project, supporting markdown and math symbols" + "A description of this project, supporting **markdown** and math symbols" ) .should("be.visible"); + const newDescription = + "# Heading\nA new description with **bold** and _italics_."; cy.getDataCy("project-documentation-modal-body") - .find(".editor") + .find("#documentation-text-area") .click() .clear() - .type("new description"); + .type(newDescription); + cy.getDataCy("project-documentation-modal-body") + .find("#documentation-text-area") + .contains("A new description with **bold**") + .should("be.visible"); + cy.getDataCy("documentation-display-mode-preview").click(); + cy.getDataCy("project-documentation-modal-body") + .contains("A new description with bold") + .should("be.visible"); cy.getDataCy("project-documentation-modal-footer").contains("Save").click(); cy.getDataCy("project-documentation-modal-body").should("not.be.visible"); }); From 0e8359e580cd3dccbf095643ec508ebab7ab9971 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Thu, 27 Feb 2025 11:47:43 +0100 Subject: [PATCH 06/13] add markdown information --- .../Documentation/Documentation.tsx | 73 ++++++++++++------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx index c7664954ef..10606c35f0 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx @@ -19,7 +19,7 @@ import cx from "classnames"; import { useCallback, useEffect, useState } from "react"; -import { FileEarmarkText, Pencil, XLg } from "react-bootstrap-icons"; +import { FileEarmarkText, Markdown, Pencil, XLg } from "react-bootstrap-icons"; import { Button, ButtonGroup, @@ -258,33 +258,48 @@ function DocumentationModal({ className="border-top" data-cy="project-documentation-modal-footer" > - {errors.documentation && ( -
- {errors.documentation.message ? ( - <>{errors.documentation.message} +
+
+ {errors.documentation ? ( +
+ {errors.documentation.message ? ( + <>{errors.documentation.message} + ) : ( + <>Documentation text is invalid + )} +
) : ( - <>Documentation text is invalid + )} + {result.error && }
- )} - {result.error && } - - - +
+ + + +
+
@@ -313,3 +328,11 @@ function DocumentationWordCount({
); } + +function MarkdownHelp() { + return ( +
+ Markdown supported +
+ ); +} From f7cdde9e1548319928c4492cd7abd9cc26e653d7 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Thu, 27 Feb 2025 16:43:19 +0100 Subject: [PATCH 07/13] better docs editing experience --- .../Documentation/Documentation.tsx | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx index 10606c35f0..3c0931e5ce 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx @@ -239,19 +239,35 @@ function DocumentationModal({
-
- {displayMode === "preview" ? ( -
- -
- ) : ( +
+
control={control} value={watch("documentation")} name="documentation" register={documentationField} /> - )} +
+
+ +
Date: Thu, 27 Feb 2025 17:18:07 +0100 Subject: [PATCH 08/13] link for markdown documentation --- .../ProjectPageContent/Documentation/Documentation.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx index 3c0931e5ce..576faf369f 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx @@ -46,6 +46,7 @@ import useProjectPermissions from "../../utils/useProjectPermissions.hook"; import DocumentationInput from "./DocumentationInput"; import styles from "./Documentation.module.scss"; +import { ExternalLink } from "../../../../components/ExternalLinks"; // Taken from src/features/projectsV2/api/projectV2.openapi.json const DESCRIPTION_MAX_LENGTH = 5000; @@ -348,7 +349,13 @@ function DocumentationWordCount({ function MarkdownHelp() { return (
- Markdown supported + + Markdown supported +
); } From 8f26cea4d704af0a079b2e386fdc532d7066f9d0 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Fri, 28 Feb 2025 15:49:40 +0100 Subject: [PATCH 09/13] remove unused file --- client/src/components/form-field/ckEditor.css | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 client/src/components/form-field/ckEditor.css diff --git a/client/src/components/form-field/ckEditor.css b/client/src/components/form-field/ckEditor.css deleted file mode 100644 index 2af6ea90ef..0000000000 --- a/client/src/components/form-field/ckEditor.css +++ /dev/null @@ -1,8 +0,0 @@ -.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; -} From 9b3ce9c5bb194aac9e8e908fbfe8734f700216c2 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Fri, 28 Feb 2025 15:56:54 +0100 Subject: [PATCH 10/13] remove unused widget from DOM (not hidden) --- .../Documentation/Documentation.tsx | 27 +++---------------- .../Documentation/DocumentationInput.tsx | 10 +++---- 2 files changed, 8 insertions(+), 29 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx index 576faf369f..26b2cf80e2 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx @@ -240,35 +240,16 @@ function DocumentationModal({
-
-
+
+ {displayMode === "edit" ? ( control={control} value={watch("documentation")} name="documentation" - register={documentationField} /> -
-
+ ) : ( -
+ )}
{ help?: string | React.ReactNode; label?: string; name: string; - register: UseFormRegisterReturn; required?: boolean; } @@ -51,16 +49,16 @@ function DocumentationInput( return (
-
- {props.label && ( + {props.label && ( +
- )} -
+
+ )}
Date: Fri, 28 Feb 2025 16:03:23 +0100 Subject: [PATCH 11/13] get field information from control prop --- .../Documentation/Documentation.tsx | 3 +- .../Documentation/DocumentationInput.tsx | 45 +++++++++---------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx index 26b2cf80e2..cd5a4b94da 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx @@ -192,7 +192,7 @@ function DocumentationModal({ } }, [result.isSuccess, toggle]); - const documentationField = register("documentation", { + register("documentation", { maxLength: { message: `Documentation is limited to ${DESCRIPTION_MAX_LENGTH} characters.`, value: DESCRIPTION_MAX_LENGTH, @@ -244,7 +244,6 @@ function DocumentationModal({ {displayMode === "edit" ? ( control={control} - value={watch("documentation")} name="documentation" /> ) : ( diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/DocumentationInput.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/DocumentationInput.tsx index 87fad47de4..02978c7dce 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/DocumentationInput.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/DocumentationInput.tsx @@ -21,7 +21,6 @@ import cx from "classnames"; import { Controller, type Control, - type FieldError, type FieldValues, type Path, } from "react-hook-form"; @@ -34,53 +33,51 @@ import { interface DocumentationInputProps { control: Control; - error?: FieldError; - value: string; help?: string | React.ReactNode; label?: string; name: string; required?: boolean; } -function DocumentationInput( - props: DocumentationInputProps -) { - const value = props.value; +function DocumentationInput({ + control, + help, + label, + name, + required, +}: DocumentationInputProps) { + const { error } = control.getFieldState(name as Path); return (
- {props.label && ( -
-