diff --git a/client/src/components/form-field/ckEditor.css b/client/src/components/form-field/ckEditor.css
new file mode 100644
index 0000000000..2af6ea90ef
--- /dev/null
+++ b/client/src/components/form-field/ckEditor.css
@@ -0,0 +1,8 @@
+.ck.ck-editor__main > .ck-editor__editable:not(.ck-focused) {
+ border: 0px;
+}
+.ck.ck-editor__top
+ .ck-sticky-panel:not(.ck-focused)
+ .ck-sticky-panel__content:not(.ck-focused) {
+ border: 0px;
+}
diff --git a/client/src/components/formlabels/FormLabels.tsx b/client/src/components/formlabels/FormLabels.tsx
index 592cad3642..e9d5ab31eb 100644
--- a/client/src/components/formlabels/FormLabels.tsx
+++ b/client/src/components/formlabels/FormLabels.tsx
@@ -57,11 +57,11 @@ interface InputLabelProps extends LabelProps {
}
const InputLabel = ({ text, isRequired = false }: InputLabelProps) => {
- return (
+ return text ? (
- );
+ ) : null;
};
const LoadingLabel = ({ className, text }: LabelProps) => {
diff --git a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx b/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx
index dbd6d1d144..e652c528f7 100644
--- a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx
+++ b/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx
@@ -44,6 +44,7 @@ export default function ProjectPageContainer() {
useGetNamespacesByNamespaceProjectsAndSlugQuery({
namespace: namespace ?? "",
slug: slug ?? "",
+ withDocumentation: true,
});
const navigate = useNavigate();
diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.module.scss b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.module.scss
new file mode 100644
index 0000000000..10b337f3b9
--- /dev/null
+++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.module.scss
@@ -0,0 +1,3 @@
+.modalBody {
+ max-height: 75vh;
+}
diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx
new file mode 100644
index 0000000000..4789721c49
--- /dev/null
+++ b/client/src/features/ProjectPageV2/ProjectPageContent/Documentation/Documentation.tsx
@@ -0,0 +1,279 @@
+/*!
+ * Copyright 2024 - Swiss Data Science Center (SDSC)
+ * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
+ * Eidgenössische Technische Hochschule Zürich (ETHZ).
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import cx from "classnames";
+import { useCallback, useEffect, useState } from "react";
+
+import { FileEarmarkText, Pencil, XLg } from "react-bootstrap-icons";
+import {
+ Button,
+ Card,
+ CardBody,
+ CardHeader,
+ Form,
+ ModalBody,
+ ModalHeader,
+ ModalFooter,
+} from "reactstrap";
+import { useForm } from "react-hook-form";
+
+import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert";
+import TextAreaInput from "../../../../components/form-field/TextAreaInput.tsx";
+import { Loader } from "../../../../components/Loader";
+import LazyRenkuMarkdown from "../../../../components/markdown/LazyRenkuMarkdown";
+import ScrollableModal from "../../../../components/modal/ScrollableModal";
+
+import styles from "./Documentation.module.scss";
+import PermissionsGuard from "../../../permissionsV2/PermissionsGuard";
+import { Project } from "../../../projectsV2/api/projectV2.api";
+import { usePatchProjectsByProjectIdMutation } from "../../../projectsV2/api/projectV2.enhanced-api";
+
+import useProjectPermissions from "../../utils/useProjectPermissions.hook";
+
+// Taken from src/features/projectsV2/api/projectV2.openapi.json
+const DESCRIPTION_MAX_LENGTH = 5000;
+
+interface DocumentationForm {
+ documentation: string;
+}
+
+interface DocumentationProps {
+ project: Project;
+}
+
+export default function Documentation({ project }: DocumentationProps) {
+ const permissions = useProjectPermissions({ projectId: project.id });
+ const [isModalOpen, setModalOpen] = useState(false);
+ const toggleOpen = useCallback(() => {
+ setModalOpen((open) => !open);
+ }, []);
+
+ return (
+ <>
+
+
+
+
+
+ Documentation
+
+
+
+
+ Edit
+
+ }
+ requestedPermission="write"
+ userPermissions={permissions}
+ />
+
+
+
+
+
+ {project.documentation ? (
+
+ ) : (
+
+ Describe your project, so others can understand what it does and
+ how to use it.
+
+ )}
+
+
+
+
+ >
+ );
+}
+
+interface DocumentationModalProps extends DocumentationProps {
+ isOpen: boolean;
+ toggle: () => void;
+}
+
+function DocumentationModal({
+ isOpen,
+ project,
+ toggle,
+}: DocumentationModalProps) {
+ const [updateProject, result] = usePatchProjectsByProjectIdMutation();
+ const { isLoading } = result;
+
+ const {
+ control,
+ formState: { errors, isDirty },
+ handleSubmit,
+ getValues,
+ register,
+ reset,
+ watch,
+ } = useForm
({
+ defaultValues: {
+ documentation: project.documentation || "",
+ },
+ });
+
+ useEffect(() => {
+ reset({
+ documentation: project.documentation || "",
+ });
+ }, [project.documentation, reset]);
+
+ const onSubmit = useCallback(
+ (data: DocumentationForm) => {
+ updateProject({
+ "If-Match": project.etag ? project.etag : "",
+ projectId: project.id,
+ projectPatch: { documentation: data.documentation },
+ });
+ },
+ [project.etag, project.id, updateProject]
+ );
+
+ useEffect(() => {
+ if (!isOpen) {
+ reset({ documentation: project.documentation || "" });
+ result.reset();
+ }
+ }, [isOpen, project.documentation, reset, result]);
+
+ useEffect(() => {
+ if (result.isSuccess) {
+ toggle();
+ }
+ }, [result.isSuccess, toggle]);
+
+ const documentationField = register("documentation", {
+ maxLength: {
+ message: `Documentation is limited to ${DESCRIPTION_MAX_LENGTH} characters.`,
+ value: DESCRIPTION_MAX_LENGTH,
+ },
+ });
+ return (
+
+
+
+
+ Documentation
+
+
+
+
+ );
+}
+
+function DocumentationWordCount({
+ watch,
+}: {
+ watch: ReturnType>["watch"];
+}) {
+ const documentation = watch("documentation");
+ const charCount = documentation.length;
+ const isCloseToLimit = charCount >= DESCRIPTION_MAX_LENGTH - 10;
+ return (
+
+
+ {charCount}
+ {" "}
+ of {DESCRIPTION_MAX_LENGTH} characters
+
+ );
+}
diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx
index bdcb693f48..edbed9494b 100644
--- a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx
+++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx
@@ -22,6 +22,7 @@ import SessionsV2 from "../../sessionsV2/SessionsV2";
import { useProject } from "../ProjectPageContainer/ProjectPageContainer";
import { CodeRepositoriesDisplay } from "./CodeRepositories/RepositoriesBox";
import ProjectDataConnectorsBox from "./DataConnectors/ProjectDataConnectorsBox";
+import Documentation from "./Documentation/Documentation";
import ProjectInformation from "./ProjectInformation/ProjectInformation";
export default function ProjectOverviewPage() {
@@ -40,6 +41,9 @@ export default function ProjectOverviewPage() {
+
+
+
diff --git a/tests/cypress/e2e/projectDatasets.spec.ts b/tests/cypress/e2e/projectDatasets.spec.ts
index c699e3c72a..f18db0937a 100644
--- a/tests/cypress/e2e/projectDatasets.spec.ts
+++ b/tests/cypress/e2e/projectDatasets.spec.ts
@@ -153,7 +153,7 @@ describe("Project dataset", () => {
cy.get("div.ck-editor__main").contains("Dataset for testing purposes");
cy.getDataCy("ckeditor-description")
- .find("p")
+ .find(".ck-content[contenteditable=true]")
.click()
.type(". New description");
diff --git a/tests/cypress/e2e/projectV2.spec.ts b/tests/cypress/e2e/projectV2.spec.ts
index e23c4db68f..62b6cef61e 100644
--- a/tests/cypress/e2e/projectV2.spec.ts
+++ b/tests/cypress/e2e/projectV2.spec.ts
@@ -130,6 +130,30 @@ describe("Navigate to project", () => {
);
cy.getDataCy("project-info-card").contains("public");
cy.getDataCy("project-info-card").contains("user1-uuid");
+ cy.getDataCy("project-documentation-text").should("be.visible");
+ cy.getDataCy("project-documentation-text")
+ .contains(
+ "A description of this project, supporting markdown and math symbols"
+ )
+ .should("be.visible");
+ cy.getDataCy("project-documentation-edit").should("not.exist");
+ });
+
+ it("show project empty documentation", () => {
+ fixtures.readProjectV2({
+ overrides: {
+ documentation: undefined,
+ },
+ });
+ cy.visit("/v2/projects/user1-uuid/test-2-v2-project");
+ cy.wait("@readProjectV2");
+ // check project data
+ cy.getDataCy("project-documentation-text").should("be.visible");
+ cy.getDataCy("project-documentation-text")
+ .contains(
+ "Describe your project, so others can understand what it does and how to use it."
+ )
+ .should("be.visible");
});
it("shows at most 5 members, owners first", () => {
@@ -199,6 +223,29 @@ describe("Edit v2 project", () => {
cy.contains("new name").should("be.visible");
});
+ it("changes project documentation", () => {
+ fixtures.readProjectV2().updateProjectV2().listNamespaceV2();
+ cy.contains("My projects").should("be.visible");
+ cy.getDataCy("dashboard-project-list")
+ .contains("a", "test 2 v2-project")
+ .should("be.visible")
+ .click();
+ cy.wait("@readProjectV2");
+ cy.getDataCy("project-documentation-edit").click();
+ cy.getDataCy("project-documentation-modal-body")
+ .contains(
+ "A description of this project, supporting markdown and math symbols"
+ )
+ .should("be.visible");
+ cy.getDataCy("project-documentation-modal-body")
+ .find(".ck-content")
+ .click()
+ .clear()
+ .type("new description");
+ cy.getDataCy("project-documentation-modal-footer").contains("Save").click();
+ cy.getDataCy("project-documentation-modal-body").should("not.be.visible");
+ });
+
it("changes project namespace", () => {
fixtures
.readProjectV2()
diff --git a/tests/cypress/e2e/projectV2Session.spec.ts b/tests/cypress/e2e/projectV2Session.spec.ts
index 39fdd9a241..63671ae3bc 100644
--- a/tests/cypress/e2e/projectV2Session.spec.ts
+++ b/tests/cypress/e2e/projectV2Session.spec.ts
@@ -35,6 +35,7 @@ describe("launch sessions with data connectors", () => {
.readGroupV2Namespace({ groupSlug: "user1-uuid" })
.landingUserProjects()
.readProjectV2()
+ .readProjectV2WithoutDocumentation()
.getStorageSchema({ fixture: "cloudStorage/storage-schema-s3.json" })
.resourcePoolsTest()
.getResourceClass()
diff --git a/tests/cypress/e2e/projectV2setup.spec.ts b/tests/cypress/e2e/projectV2setup.spec.ts
index ec8bd6abf1..80f8be2d0a 100644
--- a/tests/cypress/e2e/projectV2setup.spec.ts
+++ b/tests/cypress/e2e/projectV2setup.spec.ts
@@ -39,14 +39,16 @@ describe("Set up project components", () => {
it("set up repositories", () => {
fixtures
- .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" })
+ .readProjectV2WithoutDocumentation({
+ fixture: "projectV2/read-projectV2-empty.json",
+ })
.listProjectDataConnectors()
.getDataConnector()
.updateProjectV2({
fixture: "projectV2/update-projectV2-one-repository.json",
});
cy.visit("/v2/projects/user1-uuid/test-2-v2-project");
- cy.wait("@readProjectV2");
+ cy.wait("@readProjectV2WithoutDocumentation");
// add code repositories
fixtures.readProjectV2({
name: "getProjectAfterUpdate",
@@ -63,7 +65,7 @@ describe("Set up project components", () => {
cy.wait("@getProjectAfterUpdate");
// edit code repository
- cy.getDataCy("code-repository-edit").click();
+ cy.getDataCy("code-repository-edit").first().click();
cy.getDataCy("project-edit-repository-url").type("2");
cy.getDataCy("edit-code-repository-modal-button").click();
cy.wait("@updateProjectV2");
@@ -80,7 +82,9 @@ describe("Set up project components", () => {
body: [],
}).as("getSessionsV2");
fixtures
- .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" })
+ .readProjectV2WithoutDocumentation({
+ fixture: "projectV2/read-projectV2-empty.json",
+ })
.getProjectV2Permissions({ projectId: "01HYJE5FR1JV4CWFMBFJQFQ4RM" })
.listProjectDataConnectors()
.getDataConnector()
@@ -91,7 +95,7 @@ describe("Set up project components", () => {
.getResourceClass()
.environments();
cy.visit("/v2/projects/user1-uuid/test-2-v2-project");
- cy.wait("@readProjectV2");
+ cy.wait("@readProjectV2WithoutDocumentation");
cy.wait("@getSessionsV2");
cy.wait("@sessionLaunchers");
// ADD SESSION CUSTOM IMAGE
@@ -186,7 +190,9 @@ describe("Set up data connectors", () => {
it("create a simple data connector", () => {
fixtures
- .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" })
+ .readProjectV2WithoutDocumentation({
+ fixture: "projectV2/read-projectV2-empty.json",
+ })
.listProjectDataConnectors()
.getDataConnector()
.getStorageSchema({ fixture: "cloudStorage/storage-schema-s3.json" })
@@ -194,7 +200,7 @@ describe("Set up data connectors", () => {
.postDataConnector({ namespace: "user1-uuid", visibility: "public" })
.postDataConnectorProjectLink({ dataConnectorId: "ULID-5" });
cy.visit("/v2/projects/user1-uuid/test-2-v2-project");
- cy.wait("@readProjectV2");
+ cy.wait("@readProjectV2WithoutDocumentation");
cy.wait("@listProjectDataConnectors");
// add data connector
@@ -251,12 +257,14 @@ describe("Set up data connectors", () => {
it("link a data connector", () => {
fixtures
- .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" })
+ .readProjectV2WithoutDocumentation({
+ fixture: "projectV2/read-projectV2-empty.json",
+ })
.listProjectDataConnectors()
.getDataConnectorByNamespaceAndSlug()
.postDataConnectorProjectLink({ dataConnectorId: "ULID-1" });
cy.visit("/v2/projects/user1-uuid/test-2-v2-project");
- cy.wait("@readProjectV2");
+ cy.wait("@readProjectV2WithoutDocumentation");
cy.wait("@listProjectDataConnectors");
// add data connector
@@ -270,11 +278,13 @@ describe("Set up data connectors", () => {
it("link a data connector not found", () => {
fixtures
- .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" })
+ .readProjectV2WithoutDocumentation({
+ fixture: "projectV2/read-projectV2-empty.json",
+ })
.listProjectDataConnectors()
.getDataConnectorByNamespaceAndSlugNotFound();
cy.visit("/v2/projects/user1-uuid/test-2-v2-project");
- cy.wait("@readProjectV2");
+ cy.wait("@readProjectV2WithoutDocumentation");
cy.wait("@listProjectDataConnectors");
// add data connector
@@ -290,13 +300,15 @@ describe("Set up data connectors", () => {
it("unlink a data connector", () => {
fixtures
- .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" })
+ .readProjectV2WithoutDocumentation({
+ fixture: "projectV2/read-projectV2-empty.json",
+ })
.listProjectDataConnectors()
.getDataConnector()
.deleteDataConnectorProjectLink();
cy.visit("/v2/projects/user1-uuid/test-2-v2-project");
- cy.wait("@readProjectV2");
+ cy.wait("@readProjectV2WithoutDocumentation");
cy.wait("@listProjectDataConnectors");
cy.contains("example storage").should("be.visible").click();
@@ -319,7 +331,6 @@ describe("Set up data connectors", () => {
it("unlink data connector not allowed", () => {
fixtures
- .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" })
.listProjectDataConnectors()
.getDataConnector()
.getProjectV2Permissions({
@@ -416,6 +427,7 @@ describe("Set up data connectors", () => {
.getDataConnectorPermissions()
.patchDataConnector({ namespace: "user1-uuid" })
.patchDataConnectorSecrets({
+ content: [],
shouldNotBeCalled: true,
});
diff --git a/tests/cypress/fixtures/projectV2/read-projectV2-without-documentation.json b/tests/cypress/fixtures/projectV2/read-projectV2-without-documentation.json
new file mode 100644
index 0000000000..01fdc4de64
--- /dev/null
+++ b/tests/cypress/fixtures/projectV2/read-projectV2-without-documentation.json
@@ -0,0 +1,14 @@
+{
+ "id": "THEPROJECTULID26CHARACTERS",
+ "name": "test 2 v2-project",
+ "slug": "test-2-v2-project",
+ "namespace": "user1-uuid",
+ "creation_date": "2023-11-15T09:55:59Z",
+ "created_by": "user1-uuid",
+ "repositories": [
+ "https://domain.name/repo1.git",
+ "https://domain.name/repo2.git"
+ ],
+ "visibility": "public",
+ "description": "Project 2 description"
+}
diff --git a/tests/cypress/fixtures/projectV2/read-projectV2.json b/tests/cypress/fixtures/projectV2/read-projectV2.json
index 03d34935da..55f0b02524 100644
--- a/tests/cypress/fixtures/projectV2/read-projectV2.json
+++ b/tests/cypress/fixtures/projectV2/read-projectV2.json
@@ -11,5 +11,6 @@
],
"visibility": "public",
"description": "Project 2 description",
- "secrets_mount_directory": "/secrets"
+ "secrets_mount_directory": "/secrets",
+ "documentation": "A description of this project, supporting **markdown** and math symbols like: $\\sqrt 2$."
}
diff --git a/tests/cypress/support/commands/datasets.ts b/tests/cypress/support/commands/datasets.ts
index 8aceb47c8d..2e27a75340 100644
--- a/tests/cypress/support/commands/datasets.ts
+++ b/tests/cypress/support/commands/datasets.ts
@@ -69,9 +69,10 @@ function newDataset(newDataset: Dataset) {
}
if (newDataset.description)
- cy.get("[data-cy='ckeditor-description']")
- .find("p")
+ cy.getDataCy("ckeditor-description")
+ .find(".ck-content[contenteditable=true]")
.click()
+ .clear()
.type(newDataset.description);
if (newDataset.file) {
diff --git a/tests/cypress/support/renkulab-fixtures/projectV2.ts b/tests/cypress/support/renkulab-fixtures/projectV2.ts
index b20c917ca5..36682f059d 100644
--- a/tests/cypress/support/renkulab-fixtures/projectV2.ts
+++ b/tests/cypress/support/renkulab-fixtures/projectV2.ts
@@ -29,6 +29,7 @@ interface ProjectOverrides {
keywords?: string[];
template_id?: string;
is_template?: boolean;
+ documentation: string | null | undefined;
}
/**
@@ -380,6 +381,22 @@ export function ProjectV2(Parent: T) {
return this;
}
+ readProjectV2WithoutDocumentation(args?: ProjectV2NameArgs) {
+ const {
+ fixture = "projectV2/read-projectV2-without-documentation.json",
+ name = "readProjectV2WithoutDocumentation",
+ namespace = "user1-uuid",
+ projectSlug = "test-2-v2-project",
+ } = args ?? {};
+ const response = { fixture };
+ cy.intercept(
+ "GET",
+ `/ui-server/api/data/namespaces/${namespace}/projects/${projectSlug}`,
+ response
+ ).as(name);
+ return this;
+ }
+
readProjectV2ById(args?: ProjectV2IdArgs) {
const {
fixture = "projectV2/read-projectV2.json",