Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support project documentation without CK Editor upgrade #3478

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions client/.eslintrc.json
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the new entries here? I'm not sure I see comments/code with these words.

Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"apiversion",
"ascii",
"asciimath",
"autoformat",
"autosave",
"autosaved",
"autosaves",
Expand All @@ -106,6 +107,7 @@
"bool",
"booleans",
"borderless",
"bulleted",
"calc",
"cancellable",
"cancelled",
Expand Down Expand Up @@ -257,13 +259,15 @@
"stdout",
"stockimages",
"storages",
"strikethrough",
"swiper",
"tada",
"telepresence",
"textarea",
"thead",
"toastify",
"toggler",
"tokenizer",
"tolerations",
"toml",
"tooltip",
Expand Down
21 changes: 12 additions & 9 deletions client/src/components/form-field/TextAreaInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -42,9 +43,9 @@ function EditMarkdownSwitch(props: EditMarkdownSwitchProps) {
const outputType = "markdown";
const switchLabel = outputType === "markdown" ? "Raw Markdown" : "Raw HTML";
return (
<div className="form-check form-switch float-end">
<div className={cx("form-check", "form-switch", "float-end")}>
<Input
className="form-check-input rounded-pill"
className={cx("form-check-input", "rounded-pill")}
type="switch"
id="CKEditorSwitch"
name="customSwitch"
Expand Down Expand Up @@ -107,7 +108,7 @@ interface TextAreaInputProps<T extends FieldValues> {
error?: FieldError;
getValue: () => string;
help?: string | React.ReactNode;
label: string;
label?: string;
name: string;
register: UseFormRegisterReturn;
required?: boolean;
Expand All @@ -119,12 +120,14 @@ function TextAreaInput<T extends FieldValues>(props: TextAreaInputProps<T>) {
return (
<div>
<FormGroup className="field-group">
<div className="pb-2">
<FormLabel
name={props.name}
label={props.label}
required={props.required ?? false}
/>
<div className={cx("pb-2", props.label == null && "mb-4")}>
{props.label && (
<FormLabel
name={props.name}
label={props.label}
required={props.required ?? false}
/>
)}
<EditMarkdownSwitch codeView={codeView} setCodeView={setCodeView} />
</div>
<div data-cy={`ckeditor-${props.name}`}>
Expand Down
8 changes: 8 additions & 0 deletions client/src/components/form-field/ckEditor.css
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions client/src/components/formlabels/FormLabels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ interface InputLabelProps extends LabelProps {
}

const InputLabel = ({ text, isRequired = false }: InputLabelProps) => {
return (
return text ? (
<Label>
{text} <RequiredLabel isRequired={isRequired} />
</Label>
);
) : null;
};

const LoadingLabel = ({ className, text }: LabelProps) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default function ProjectPageContainer() {
useGetNamespacesByNamespaceProjectsAndSlugQuery({
namespace: namespace ?? "",
slug: slug ?? "",
withDocumentation: true,
});

const navigate = useNavigate();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.modalBody {
max-height: 75vh;
}
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Card data-cy="project-documentation-card">
<CardHeader>
<div
className={cx(
"align-items-center",
"d-flex",
"justify-content-between"
)}
>
<h4 className="m-0">
<FileEarmarkText className={cx("me-1", "bi")} />
Documentation
</h4>
<div className="my-auto">
<PermissionsGuard
disabled={null}
enabled={
<Button
data-cy="project-documentation-edit"
color="outline-primary"
onClick={toggleOpen}
size="sm"
>
<Pencil className="bi" />
ciyer marked this conversation as resolved.
Show resolved Hide resolved
<span className="visually-hidden">Edit</span>
</Button>
}
requestedPermission="write"
userPermissions={permissions}
/>
</div>
</div>
</CardHeader>
<CardBody>
<div data-cy="project-documentation-text">
{project.documentation ? (
<LazyRenkuMarkdown markdownText={project.documentation} />
) : (
<p className={cx("m-0", "text-muted", "fst-italic")}>
Describe your project, so others can understand what it does and
how to use it.
</p>
)}
</div>
</CardBody>
</Card>
<DocumentationModal
isOpen={isModalOpen}
project={project}
toggle={toggleOpen}
/>
</>
);
}

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<DocumentationForm>({
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 (
<ScrollableModal
backdrop="static"
centered
data-cy="project-documentation-modal"
isOpen={isOpen}
size="lg"
toggle={toggle}
>
<ModalHeader toggle={toggle} data-cy="project-documentation-modal-header">
<div>
<FileEarmarkText className={cx("me-1", "bi")} />
Documentation
</div>
</ModalHeader>
<Form noValidate onSubmit={handleSubmit(onSubmit)}>
<ModalBody
data-cy="project-documentation-modal-body"
className={styles.modalBody}
>
<div className="mb-1">
<TextAreaInput<DocumentationForm>
control={control}
getValue={() => getValues("documentation")}
name="documentation"
register={documentationField}
/>
</div>
</ModalBody>
<ModalFooter
className="border-top"
data-cy="project-documentation-modal-footer"
>
{errors.documentation && (
<div className="text-danger">
{errors.documentation.message ? (
<>{errors.documentation.message}</>
) : (
<>Documentation text is invalid</>
)}
</div>
)}
{result.error && <RtkOrNotebooksError error={result.error} />}
<DocumentationWordCount watch={watch} />
<Button
color="outline-primary"
className="me-2"
onClick={() => {
toggle();
}}
>
<XLg className={cx("bi", "me-1")} />
Close
</Button>
<Button
color="primary"
disabled={isLoading || !isDirty}
type="submit"
>
{isLoading ? (
<Loader className="me-1" inline size={16} />
) : (
<Pencil className={cx("bi", "me-1")} />
)}
Save
</Button>
</ModalFooter>
</Form>
</ScrollableModal>
);
}

function DocumentationWordCount({
watch,
}: {
watch: ReturnType<typeof useForm<DocumentationForm>>["watch"];
}) {
const documentation = watch("documentation");
const charCount = documentation.length;
const isCloseToLimit = charCount >= DESCRIPTION_MAX_LENGTH - 10;
return (
<div>
<span
className={cx(
isCloseToLimit && "text-danger",
isCloseToLimit && "fw-bold"
)}
>
{charCount}
</span>{" "}
of {DESCRIPTION_MAX_LENGTH} characters
</div>
);
}
Loading
Loading