{typeof item.visibility === "string" &&
- item.visibility !== "" ? (
+ item.visibility ? (
{getVisibilityText(item.visibility)}
) : (
diff --git a/client/src/components/projects/DeleteProjectModal.tsx b/client/src/components/projects/DeleteProjectModal.tsx
new file mode 100644
index 00000000..66921fcd
--- /dev/null
+++ b/client/src/components/projects/DeleteProjectModal.tsx
@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { Button, Icon, Modal, ModalProps } from "semantic-ui-react";
+import api from "../../api";
+import useGlobalError from "../error/ErrorHooks";
+
+interface DeleteProjectModalProps extends ModalProps {
+ show: boolean;
+ projectID: string;
+ onCancel: () => void;
+}
+
+const DeleteProjectModal: React.FC = ({
+ show,
+ projectID,
+ onCancel,
+ ...rest
+}) => {
+ const { handleGlobalError } = useGlobalError();
+ const [loading, setLoading] = useState(false);
+
+ /**
+ * Submits a DELETE request to the server to delete the project,
+ * then redirects to the Projects dashboard on success.
+ */
+ async function submitDeleteProject() {
+ try {
+ setLoading(true);
+ if (!projectID) return;
+ const res = await api.deleteProject(projectID);
+
+ if (res.data.err) {
+ throw new Error(res.data.err);
+ }
+ window.location.assign("/projects?projectDeleted=true");
+ } catch (err) {
+ handleGlobalError(err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ Confirm Project Deletion
+
+
+ Are you sure you want to delete this project?{" "}
+ This cannot be undone.
+
+
+
+
+
+
+
+ );
+};
+
+export default DeleteProjectModal;
diff --git a/client/src/components/projects/ProjectPropertiesModal.tsx b/client/src/components/projects/ProjectPropertiesModal.tsx
new file mode 100644
index 00000000..8c8b9ffc
--- /dev/null
+++ b/client/src/components/projects/ProjectPropertiesModal.tsx
@@ -0,0 +1,885 @@
+import {
+ Accordion,
+ Button,
+ Divider,
+ Form,
+ Header,
+ Icon,
+ Modal,
+ ModalProps,
+ Popup,
+ Dropdown,
+} from "semantic-ui-react";
+import {
+ CentralIdentityLicense,
+ GenericKeyTextValueObj,
+ Project,
+ ProjectClassification,
+ ProjectStatus,
+} from "../../types";
+import { Controller, useForm } from "react-hook-form";
+import useGlobalError from "../error/ErrorHooks";
+import { lazy, useEffect, useState, useCallback } from "react";
+import CtlTextInput from "../ControlledInputs/CtlTextInput";
+import { required } from "../../utils/formRules";
+import CtlTextArea from "../ControlledInputs/CtlTextArea";
+import {
+ classificationOptions,
+ statusOptions,
+ visibilityOptions,
+} from "../util/ProjectHelpers";
+import api from "../../api";
+import axios from "axios";
+import useDebounce from "../../hooks/useDebounce";
+import CtlCheckbox from "../ControlledInputs/CtlCheckbox";
+const DeleteProjectModal = lazy(() => import("./DeleteProjectModal"));
+
+interface ProjectPropertiesModalProps extends ModalProps {
+ show: boolean;
+ onClose: () => void;
+ projectID: string;
+}
+
+type CIDDescriptorOption = GenericKeyTextValueObj & {
+ content?: JSX.Element;
+};
+
+const ProjectPropertiesModal: React.FC = ({
+ show,
+ onClose,
+ projectID,
+}) => {
+ const DESCRIP_MAX_CHARS = 500;
+ // Global state & hooks
+ const { handleGlobalError } = useGlobalError();
+ const { debounce } = useDebounce();
+ const {
+ control,
+ getValues,
+ setValue,
+ watch,
+ formState,
+ reset,
+ trigger: triggerValidation,
+ } = useForm({
+ defaultValues: {
+ title: "",
+ tags: [],
+ cidDescriptors: [],
+ currentProgress: 0,
+ peerProgress: 0,
+ a11yProgress: 0,
+ classification: ProjectClassification.HARVESTING,
+ status: ProjectStatus.OPEN,
+ visibility: "private",
+ projectURL: "",
+ adaptURL: "",
+ author: "",
+ authorEmail: "",
+ license: "",
+ resourceURL: "",
+ notes: "",
+ associatedOrgs: [],
+ defaultFileLicense: {},
+ },
+ });
+
+ // UI & Data
+ const [loading, setLoading] = useState(false);
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [tagOptions, setTagOptions] = useState<
+ GenericKeyTextValueObj[]
+ >([]);
+ const [loadedTags, setLoadedTags] = useState(false);
+ const [cidOptions, setCIDOptions] = useState([]);
+ const [loadedCIDs, setLoadedCIDs] = useState(false);
+ const [licenseOptions, setLicenseOptions] = useState<
+ CentralIdentityLicense[]
+ >([]);
+ const [loadedLicenses, setLoadedLicenses] = useState(false);
+ const [orgOptions, setOrgOptions] = useState<
+ GenericKeyTextValueObj[]
+ >([]);
+ const [loadedOrgs, setLoadedOrgs] = useState(false);
+
+ useEffect(() => {
+ if (show && projectID) {
+ loadProject();
+ getTags();
+ getCIDDescriptors();
+ getLicenseOptions();
+ getOrgs();
+ }
+ }, [show, projectID]);
+
+ // Update license URL and (version as appropriate) when license name changes
+ useEffect(() => {
+ if (getValues("defaultFileLicense.name") === undefined) return;
+
+ // If license name is cleared, clear license URL and version
+ if (getValues("defaultFileLicense.name") === "") {
+ setValue("defaultFileLicense.url", "");
+ setValue("defaultFileLicense.version", "");
+ return;
+ }
+
+ const license = licenseOptions.find(
+ (l) => l.name === getValues("defaultFileLicense.name")
+ );
+ if (!license) return;
+
+ // If license no longer has versions, clear license version
+ if (!license.versions || license.versions.length === 0) {
+ setValue("defaultFileLicense.version", "");
+ }
+ setValue("defaultFileLicense.url", license.url);
+ }, [watch("defaultFileLicense.name")]);
+
+ // Return new license version options when license name changes
+ const selectedLicenseVersions = useCallback(() => {
+ const license = licenseOptions.find(
+ (l) => l.name === getValues("defaultFileLicense.name")
+ );
+ if (!license) return [];
+ return license.versions ?? [];
+ }, [watch("defaultFileLicense.name"), licenseOptions]);
+
+ async function loadProject() {
+ try {
+ setLoading(true);
+ const res = await api.getProject(projectID);
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+ reset(res.data.project);
+ } catch (err) {
+ handleGlobalError(err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ /**
+ * Load existing Project Tags from the server
+ * via GET request, then sort, format, and save
+ * them to state for use in the Dropdown.
+ */
+ async function getTags() {
+ try {
+ setLoadedTags(false);
+ const res = await api.getTags();
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+
+ if (!res.data.tags || !Array.isArray(res.data.tags)) {
+ throw new Error("Invalid response from server.");
+ }
+
+ res.data.tags.sort((tagA, tagB) => {
+ var aNorm = String(tagA.title).toLowerCase();
+ var bNorm = String(tagB.title).toLowerCase();
+ if (aNorm < bNorm) return -1;
+ if (aNorm > bNorm) return 1;
+ return 0;
+ });
+
+ const newTagOptions = res.data.tags.flatMap((t) => {
+ return t.title ? { text: t.title, value: t.title, key: t.title } : [];
+ });
+ setTagOptions(newTagOptions);
+ setLoadedTags(true);
+ } catch (err) {
+ handleGlobalError(err);
+ }
+ }
+
+ /**
+ * Loads C-ID Descriptors from the server, transforms them for use in UI,
+ * then saves them to state.
+ */
+ async function getCIDDescriptors() {
+ try {
+ setLoadedCIDs(false);
+ const res = await api.getCIDDescriptors();
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+
+ if (!res.data.descriptors || !Array.isArray(res.data.descriptors)) {
+ throw new Error("Invalid response from server.");
+ }
+
+ const descriptors = [
+ { value: "", key: "clear", text: "Clear..." },
+ ...res.data.descriptors.map((item) => {
+ return {
+ value: item.descriptor,
+ key: item.descriptor,
+ text: `${item.descriptor}: ${item.title}`,
+ content: (
+
+
+ {item.descriptor}: {item.title}
+
+
+ {item.description}
+
+
+ ),
+ };
+ }),
+ ];
+ setCIDOptions(descriptors);
+ setLoadedCIDs(true);
+ } catch (err) {
+ handleGlobalError(err);
+ }
+ }
+
+ async function getLicenseOptions() {
+ try {
+ setLoadedLicenses(false);
+ const res = await api.getCentralIdentityLicenses();
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+ if (!res.data.licenses) {
+ throw new Error("Failed to load license options");
+ }
+
+ const versionsSorted = res.data.licenses.map((l) => {
+ return {
+ ...l,
+ versions: l.versions?.sort((a, b) => {
+ if (a === b) return 0;
+ if (!a) return -1;
+ if (!b) return 1;
+ return b.localeCompare(a);
+ }),
+ };
+ });
+ setLicenseOptions(versionsSorted);
+ } catch (err) {
+ handleGlobalError(err);
+ } finally {
+ setLoadedLicenses(true);
+ }
+ }
+
+ async function getOrgs(searchQuery?: string) {
+ try {
+ setLoadedOrgs(false);
+ const res = await api.getCentralIdentityOrgs({
+ query: searchQuery ?? undefined,
+ });
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+ if (!res.data.orgs || !Array.isArray(res.data.orgs)) {
+ throw new Error("Invalid response from server.");
+ }
+
+ const orgs = res.data.orgs.map((org) => {
+ return {
+ value: org.name,
+ key: org.id.toString(),
+ text: org.name,
+ };
+ });
+
+ // We also want to include any existing orgs that are already associated with this project
+ const existingOrgs = watch("associatedOrgs").map((name) => {
+ return {
+ key: crypto.randomUUID(),
+ text: name,
+ value: name,
+ };
+ });
+
+ setOrgOptions([...orgs, ...existingOrgs]);
+ } catch (err) {
+ handleGlobalError(err);
+ } finally {
+ setLoadedOrgs(true);
+ }
+ }
+
+ const getOrgsDebounced = debounce(
+ (inputVal: string) => getOrgs(inputVal),
+ 200
+ );
+
+ /**
+ * Ensure the form data is valid, then submit the
+ * data to the server via PUT request.
+ */
+ const submitEditInfoForm = async () => {
+ try {
+ setLoading(true);
+ if (!(await triggerValidation())) {
+ throw new Error("Please fix the errors in the form before submitting.");
+ }
+
+ const res = await axios.put("/project", getValues());
+ if (res.data.err) {
+ throw new Error(res.data.errMsg);
+ }
+ onClose();
+ return;
+ } catch (err) {
+ // if (err.toJSON().status === 409) {
+ // handleGlobalError(
+ // err,
+ // "View Project",
+ // err.response.data.projectID ?? "unknown"
+ // );
+ // }
+ handleGlobalError(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ Edit Project Properties
+
+
+
+
+
+
+
+
+ (
+ // @ts-expect-error
+ {
+ field.onChange(value as string);
+ }}
+ fluid
+ selection
+ multiple
+ search
+ allowAdditions
+ loading={!loadedTags}
+ onAddItem={(e, { value }) => {
+ if (value) {
+ tagOptions.push({
+ text: value.toString(),
+ value: value.toString(),
+ key: value.toString(),
+ });
+ field.onChange([
+ ...(field.value as GenericKeyTextValueObj[]),
+ value.toString(),
+ ]);
+ }
+ }}
+ renderLabel={(tag) => ({
+ color: "blue",
+ content: tag.text,
+ })}
+ />
+ )}
+ name="tags"
+ control={control}
+ />
+
+
+
+ (
+ ({
+ color: "blue",
+ content: cid.key,
+ })}
+ />
+ )}
+ />
+
+
+
+ (
+ {
+ field.onChange(value as string);
+ }}
+ fluid
+ selection
+ multiple
+ search
+ onSearchChange={(e, { searchQuery }) => {
+ getOrgsDebounced(searchQuery);
+ }}
+ additionLabel="Add organization: "
+ allowAdditions
+ loading={!loadedOrgs}
+ onAddItem={(e, { value }) => {
+ if (value) {
+ orgOptions.push({
+ text: value.toString(),
+ value: value.toString(),
+ key: value.toString(),
+ });
+ field.onChange([
+ ...(field.value as string[]),
+ value.toString(),
+ ]);
+ }
+ }}
+ renderLabel={(tag) => ({
+ color: "blue",
+ content: tag.text,
+ })}
+ />
+ )}
+ name="associatedOrgs"
+ control={control}
+ />
+
+
+
+ For settings and properties related to Peer Reviews, please use
+ the Settings tool on this project's Peer Review page.
+
+
+
+
+
+
+ {`Use this section to link your project's Commons page (if applicable) to an `}
+
+ ADAPT
+
+ {` assessment system course. `}
+ Ensure the course allows anonymous users.
+
+
+
+
+
+
+
+
+
+
+ Use this section if your project pertains to a particular resource
+ or tool.
+
+
+
+
+
+
+
+
+
+ (
+ {
+ field.onChange(data.value?.toString() ?? "text");
+ }}
+ fluid
+ selection
+ className="mr-8"
+ placeholder="License..."
+ />
+ )}
+ />
+
+
+
+
+
+
+ Default License
+
+
+
+ Use this section to set the default license information for
+ uploaded assets. Users will be able to override this setting on a
+ per-file basis.
+
+
+
+
+ (
+ ({
+ key: crypto.randomUUID(),
+ value: l.name,
+ text: l.name,
+ }))}
+ {...field}
+ onChange={(e, data) => {
+ field.onChange(data.value?.toString() ?? "");
+ }}
+ fluid
+ selection
+ placeholder="Select a license..."
+ />
+ )}
+ name="defaultFileLicense.name"
+ control={control}
+ />
+
+ {selectedLicenseVersions().length > 0 && (
+
+
+ (
+ ({
+ key: crypto.randomUUID(),
+ value: v,
+ text: v,
+ }))}
+ {...field}
+ onChange={(e, data) => {
+ field.onChange(data.value?.toString() ?? "");
+ }}
+ fluid
+ selection
+ placeholder="Select license version"
+ />
+ )}
+ name="defaultFileLicense.version"
+ control={control}
+ rules={required}
+ />
+
+ )}
+
+
+
+
+
+
+
+
+
+