From d37634e9c29d118d1103618864ebad25a684bee0 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Wed, 4 Sep 2024 13:31:32 -0700 Subject: [PATCH] feat: update harvest request license information --- client/src/api.ts | 10 + .../ControlledInputs/CtlTextInput.tsx | 1 + .../src/components/FilesManager/EditFile.tsx | 40 +- .../controlpanel/HarvestingRequests.jsx | 28 +- .../harvestrequest/HarvestRequest.jsx | 358 -------------- .../harvestrequest/HarvestRequest.tsx | 441 ++++++++++++++++++ .../src/hooks/useCentralIdentityLicenses.ts | 56 +++ client/src/types/HarvestRequest.ts | 15 + client/src/types/index.ts | 5 + server/api/harvestingrequests.js | 34 +- server/models/harvestingrequest.ts | 11 +- 11 files changed, 590 insertions(+), 409 deletions(-) delete mode 100644 client/src/components/harvestrequest/HarvestRequest.jsx create mode 100644 client/src/components/harvestrequest/HarvestRequest.tsx create mode 100644 client/src/hooks/useCentralIdentityLicenses.ts create mode 100644 client/src/types/HarvestRequest.ts diff --git a/client/src/api.ts b/client/src/api.ts index f3d6c07b..97317558 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -16,6 +16,7 @@ import { CollectionResource, ConductorBaseResponse, ConductorSearchResponse, + HarvestRequest, Homework, HomeworkSearchParams, PeerReview, @@ -437,6 +438,15 @@ class API { return res; } + // Harvest Requests + async createHarvestRequest(data: HarvestRequest) { + const res = await axios.post( + "/harvestingrequest", + data + ); + return res; + } + // Search async getAutoCompleteSuggestions(query: string, limit?: number) { const res = await axios.get< diff --git a/client/src/components/ControlledInputs/CtlTextInput.tsx b/client/src/components/ControlledInputs/CtlTextInput.tsx index fe3a8b3f..ba4139a9 100644 --- a/client/src/components/ControlledInputs/CtlTextInput.tsx +++ b/client/src/components/ControlledInputs/CtlTextInput.tsx @@ -7,6 +7,7 @@ interface CtlTextInputProps extends FormInputProps { required?: boolean; showErrorMsg?: boolean; helpText?: string; + placeholder?: string; } /** diff --git a/client/src/components/FilesManager/EditFile.tsx b/client/src/components/FilesManager/EditFile.tsx index 1dc96ac5..01dd9b82 100644 --- a/client/src/components/FilesManager/EditFile.tsx +++ b/client/src/components/FilesManager/EditFile.tsx @@ -45,6 +45,7 @@ import AuthorsForm from "./AuthorsForm"; import FilePreview from "./FilePreview"; import ManageCaptionsModal from "./ManageCaptionsModal"; import { useQuery } from "@tanstack/react-query"; +import useCentralIdentityLicenses from "../../hooks/useCentralIdentityLicenses"; const FilesUploader = React.lazy(() => import("./FilesUploader")); const FileRenderer = React.lazy(() => import("./FileRenderer")); @@ -130,15 +131,8 @@ const EditFile: React.FC = ({ // Frameworks const [selectedFramework, setSelectedFramework] = useState(null); - - const { data: licenseOptions, isFetching: licensesLoading } = useQuery< - CentralIdentityLicense[] - >({ - queryKey: ["centralIdentityLicenses"], - queryFn: loadLicenseOptions, - staleTime: Infinity, - refetchOnWindowFocus: false, - }); + + const { licenseOptions, isFetching: licensesLoading } = useCentralIdentityLicenses(); const { data: projectLicenseSettings, @@ -292,34 +286,6 @@ const EditFile: React.FC = ({ } } - async function loadLicenseOptions() { - try { - 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); - }), - }; - }); - return versionsSorted; - } catch (err) { - handleGlobalError(err); - return []; - } - } - /** * Submits the file edit request to the server (if form is valid) and * closes the modal on completion. diff --git a/client/src/components/controlpanel/HarvestingRequests.jsx b/client/src/components/controlpanel/HarvestingRequests.jsx index 3e1e8bf7..54393622 100644 --- a/client/src/components/controlpanel/HarvestingRequests.jsx +++ b/client/src/components/controlpanel/HarvestingRequests.jsx @@ -200,13 +200,25 @@ const HarvestingRequests = (props) => { break; case 'license': sorted = [...harvestingRequests].sort((a, b) => { - if (a.license < b.license) { - return -1; - } - if (a.license > b.license) { - return 1; + if (typeof a.license === 'string' && typeof b.license === 'string') { + if (a.license < b.license) { + return -1; + } + if (a.license > b.license) { + return 1; + } + return 0; + } else if (typeof a.license === 'object' && typeof b.license === 'object') { + if (a.license.name < b.license.name) { + return -1; + } + if (a.license.name > b.license.name) { + return 1; + } + return 0; + } else { + return 0; } - return 0; }); break; case 'status': @@ -496,7 +508,7 @@ const HarvestingRequests = (props) => { {getLibraryName(item.library)} - {getLicenseText(item.license)} + {typeof item.license === 'object' ? `${item.license.name} ${item.license.version}` : item.license} {item.institution} @@ -548,7 +560,7 @@ const HarvestingRequests = (props) => {
Resource License
-

{getLicenseText(currentRequest.license)}

+

{typeof currentRequest.license === 'object' ? `${currentRequest.license.name} ${currentRequest.license.version}` : currentRequest.license}

Resource URL
diff --git a/client/src/components/harvestrequest/HarvestRequest.jsx b/client/src/components/harvestrequest/HarvestRequest.jsx deleted file mode 100644 index 56bd4928..00000000 --- a/client/src/components/harvestrequest/HarvestRequest.jsx +++ /dev/null @@ -1,358 +0,0 @@ -import { - Grid, - Segment, - Button, - Form, - Input, - Image, - Modal, - Header, - Divider, - Message, - Icon -} from 'semantic-ui-react'; -import React, { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { useSelector } from 'react-redux'; -import axios from 'axios'; -import date from 'date-and-time'; - -import DateInput from '../DateInput/index.tsx'; - -import useGlobalError from '../error/ErrorHooks'; -import { isEmptyString } from '../util/HelperFunctions.js'; -import { libraryOptions } from '../util/LibraryOptions.js'; -import { licenseOptions } from '../util/LicenseOptions.js'; -import { textUseOptions } from '../util/HarvestingMasterOptions.js'; - -const HarvestRequest = (props) => { - - // Global State and Error - const { handleGlobalError } = useGlobalError(); - const user = useSelector((state) => state.user); - - // UI - const [showSuccessModal, setSuccessModal] = useState(false); - const [loadingData, setLoadingData] = useState(false); - - // Form Data - const [email, setEmail] = useState(''); - const [title, setTitle] = useState(''); - const [library, setLibrary] = useState(''); - const [url, setURL] = useState(''); - const [license, setLicense] = useState(''); - const [name, setName] = useState(''); - const [institution, setInstitution] = useState(''); - const [resourceUse, setResourceUse] = useState(''); - const [dateIntegrate, setDateIntegrate] = useState(''); - const [addToProject, setAddToProject] = useState(true); - const [comments, setComments] = useState(''); - - // Form Validation - const [emailErr, setEmailErr] = useState(false); - const [titleErr, setTitleErr] = useState(false); - const [libErr, setLibErr] = useState(false); - const [licErr, setLicErr] = useState(false); - - - /** - * Update page title. - */ - useEffect(() => { - document.title = "LibreTexts Conductor | Harvest Request"; - }, []); - - - /** Form input handlers **/ - const onChange = (e) => { - switch (e.target.id) { - case 'email': - setEmail(e.target.value); - break; - case 'title': - setTitle(e.target.value); - break; - case 'url': - setURL(e.target.value); - break; - case 'name': - setName(e.target.value); - break; - case 'institution': - setInstitution(e.target.value); - break; - case 'comments': - setComments(e.target.value); - break; - default: - break // silence React warning - } - }; - - const handleLibChange = (_e, { value }) => { - setLibrary(value); - }; - - const handleLicChange = (_e, { value }) => { - setLicense(value); - }; - - const handleUseChange = (_e, { value }) => { - setResourceUse(value); - }; - - const handleDateChange = (_e, { value }) => { - setDateIntegrate(value); - }; - - - /** - * Validate the form data, return - * 'true' if all fields are valid, - * 'false' otherwise - */ - const validateForm = () => { // returns true if form is ok - var valid = true; - if (!user.isAuthenticated && isEmptyString(email)) { - valid = false; - setEmailErr(true); - } - if (isEmptyString(title)) { - valid = false; - setTitleErr(true); - } - if (isEmptyString(library)) { - valid = false; - setLibErr(true); - } - if (isEmptyString(license)) { - valid = false; - setLicErr(true); - } - return valid; - }; - - - /** - * Reset all form error states. - */ - const resetForm = () => { // resets all field errors - setEmailErr(false); - setTitleErr(false); - setLibErr(false); - setLicErr(false); - }; - - - /** - * Submit data via POST to the server, then - * open the Success Modal. - */ - const onSubmit = () => { - resetForm(); - if (validateForm()) { - setLoadingData(true); - let dateString = ''; - if (dateIntegrate !== '') dateString = date.format(dateIntegrate, 'MM-DD-YYYY'); - const requestData = { - email: email, - title: title, - library: library, - url: url, - license: license, - name: name, - institution: institution, - resourceUse: resourceUse, - dateIntegrate: dateString, - comments: comments, - addToProject: addToProject - }; - axios.post('/harvestingrequest', requestData).then((res) => { - if (!res.data.err) { - setSuccessModal(true); - setLoadingData(false); - } else { - handleGlobalError(res.data.errMsg); - setLoadingData(false); - } - }).catch((err) => { - handleGlobalError(err); - setLoadingData(false); - }); - } - }; - - - /** - * Called when the Succes Modal - * is closed. Redirects user - * to home page. - */ - const successModalClosed = () => { - setSuccessModal(false); - if (user.isAuthenticated) { - props.history.push('/home'); - } else { - props.history.push('/'); - } - }; - - return( - - - - - - - { - window.open('https://libretexts.org', '_blank', 'noopener'); - }} - /> -
Request OER Integration
-
-
-
-
-
- - - -

If you want to request an existing openly licensed resource be integrated into a LibreTexts library, please fill out and submit this form.

- {user.isAuthenticated && - - - - Welcome, {user.firstName} -

This integration request will be tied to your Conductor account.

-
-
- } - {!user.isAuthenticated && - -

Are you a Conductor user? Log in to have this request tied to your account so you can track its status!

-
- } -
- {!user.isAuthenticated && - - - - - } - - - - - - -
Resource Format
-

We can integrate OER content from nearly any format, although content in some formats requires more effort to integrate than others. If the requested resource exists online please enter the URL below. If the content format requires submiting a file to us, let us know in the comments and we'll contact you with more details.

- - - - - - -
Priority Integration
-

We try to prioritize integrating OER texts that people are ready to adopt in their classes. If you would like to use this text in your class you can fill out this section for priority consideration.

- {!user.isAuthenticated && - - - - - } - - - - - - setDateIntegrate(value)} - label='Date integration has to be completed for adoption to be possible:*' - inlineLabel={false} - className='mr-2p' - /> -

- * - - We try to integrate projects by the date they are needed but cannot guarantee this. If you have questions, you can always - get in touch with the LibreTexts team. - -

- - {user.isAuthenticated && - setAddToProject(value)} - value={addToProject} - /> - } - - - - - - -
-
-
- - LibreTexts Conductor: Success - - -

Successfully submitted your request! You will now be redirected to the main page.

-
-
- - - -
-
- ); -}; - -export default HarvestRequest; diff --git a/client/src/components/harvestrequest/HarvestRequest.tsx b/client/src/components/harvestrequest/HarvestRequest.tsx new file mode 100644 index 00000000..5bdb155c --- /dev/null +++ b/client/src/components/harvestrequest/HarvestRequest.tsx @@ -0,0 +1,441 @@ +import { + Grid, + Segment, + Button, + Form, + Image, + Header, + Divider, + Message, + Icon, + Dropdown +} from 'semantic-ui-react'; +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import useGlobalError from '../error/ErrorHooks.js'; +import { libraryOptions } from '../util/LibraryOptions.js'; +import { textUseOptions } from '../util/HarvestingMasterOptions.js'; +import { useTypedSelector } from '../../state/hooks.js'; +import { Controller, useForm } from 'react-hook-form'; +import { HarvestRequest as HarvestRequestType } from '../../types/HarvestRequest.js'; +import useCentralIdentityLicenses from '../../hooks/useCentralIdentityLicenses.js'; +import CtlTextInput from '../ControlledInputs/CtlTextInput.js'; +import { required } from '../../utils/formRules.js'; +import CtlCheckbox from '../ControlledInputs/CtlCheckbox.js'; +import CtlTextArea from '../ControlledInputs/CtlTextArea.js'; +import CtlDateInput from '../ControlledInputs/CtlDateInput.js'; +import api from '../../api.js'; +import { useNotifications } from '../../context/NotificationContext.js'; +import { format } from 'date-fns'; +const DESCRIP_MAX_CHARS = 500; + +const HarvestRequest = () => { + + // Global State and Error + const { handleGlobalError } = useGlobalError(); + const user = useTypedSelector((state) => state.user); + const { addNotification } = useNotifications(); + + // UI + const { licenseOptions, isFetching: licensesLoading } = useCentralIdentityLicenses() + const [loadingData, setLoadingData] = useState(false); + + const { control, getValues, setValue, watch, reset, formState, trigger } = useForm({ + defaultValues: { + name: '', + email: '', + title: '', + library: '', + url: '', + license: { + name: '', + version: '', + url: '', + sourceURL: '', + modifiedFromSource: false, + additionalTerms: '' + }, + institution: '', + resourceUse: '', + dateIntegrate: '', + addToProject: true, + comments: '' + } + }) + + /** + * Update page title. + */ + useEffect(() => { + document.title = "LibreTexts Conductor | Harvest Request"; + }, []); + + /** + * Submit data via POST to the server, then + * open the Success Modal. + */ + async function submitRequest() { + try { + setLoadingData(true); + let dateString = ''; + + if (getValues('dateIntegrate') === '') { + const toDateObj = new Date(getValues('dateIntegrate')); + dateString = format(toDateObj, 'MM-dd-yyyy'); + } + + const requestData = { ...getValues(), dateIntegrate: dateString }; + + const res = await api.createHarvestRequest(requestData); + if (res.data.err) { + throw new Error(res.data.errMsg); + } + + addNotification({ + message: 'Successfully submitted your request!', + type: 'success', + }); + + // Slight delay to show success message before redirecting + setTimeout(() => { + onSuccess(); + }, 2000); + } catch (err) { + handleGlobalError(err); + } finally { + setLoadingData(false); + } + }; + + /** + * Called when the request is sucessfully submitted. + * Redirects user + * to home page. + */ + const onSuccess = () => { + if (user.isAuthenticated) { + window.location.href = '/home'; + } else { + window.location.href = '/'; + } + }; + + // Return new license version options when license name changes + const selectedLicenseVersions = useCallback(() => { + const license = licenseOptions?.find( + (l) => l.name === getValues("license.name") + ); + if (!license) return []; + return license.versions ?? []; + }, [watch("license.name"), licenseOptions]); + + return ( + + + + + + + { + window.open('https://libretexts.org', '_blank', 'noopener'); + }} + /> +
Request OER Integration
+
+
+
+
+
+ + + +

If you want to request an existing openly licensed resource be integrated into a LibreTexts library, please fill out and submit this form.

+ {user.isAuthenticated && + + + + Welcome, {user.firstName} +

This integration request will be tied to your Conductor account.

+
+
+ } + {!user.isAuthenticated && + +

Are you a Conductor user? Log in to have this request tied to your account so you can track its status!

+
+ } +
e.preventDefault()}> + {!user.isAuthenticated && + + } + +
+ + ( + { + field.onChange( + data.value?.toString() ?? "" + ); + }} + fluid + selection + placeholder="Select libray" + error={ + formState.errors.library + ? true + : false + } + /> + )} + name="library" + control={control} + rules={required} + /> +
+ +
Resource Format
+

We can integrate OER content from nearly any format, although content in some formats requires more effort to integrate than others. If the requested resource exists online please enter the URL below. If the content format requires submiting a file to us, let us know in the comments and we'll contact you with more details.

+ +
+ + ( + ({ + key: l.name, + value: l.name, + text: l.name, + }))} + {...field} + onChange={(e, data) => { + field.onChange(data.value?.toString() ?? ""); + }} + fluid + selection + placeholder="Select a license..." + error={ + formState.errors.license?.name ? true : false + } + /> + )} + name="license.name" + control={control} + rules={required} + /> +
+ {selectedLicenseVersions().length > 0 && ( +
+ + ( + ({ + key: v, + value: v, + text: v, + }) + )} + {...field} + onChange={(e, data) => { + field.onChange( + data.value?.toString() ?? "" + ); + }} + fluid + selection + placeholder="Select license version" + error={ + formState.errors.license?.version + ? true + : false + } + loading={licensesLoading} + /> + )} + name="license.version" + control={control} + rules={required} + /> +
+ )} + {/* */} +
+ +
+ + +
Priority Integration
+

We try to prioritize integrating OER texts that people are ready to adopt in their classes. If you would like to use this text in your class you can fill out this section for priority consideration.

+ {!user.isAuthenticated && + + } + +
+ + ( + { + field.onChange( + data.value?.toString() ?? "" + ); + }} + fluid + selection + placeholder="Select use..." + error={ + formState.errors.library + ? true + : false + } + /> + )} + name="resourceUse" + control={control} + /> +
+ { + setValue("dateIntegrate", e.target.value); + }} + /> +

+ * + + We try to integrate projects by the date they are needed but cannot guarantee this. If you have questions, you can always + get in touch with the LibreTexts team. + +

+ + {user.isAuthenticated && +
+ +
+ } + + + +
+
+
+
+ ); +}; + +export default HarvestRequest; diff --git a/client/src/hooks/useCentralIdentityLicenses.ts b/client/src/hooks/useCentralIdentityLicenses.ts new file mode 100644 index 00000000..d9ad39dc --- /dev/null +++ b/client/src/hooks/useCentralIdentityLicenses.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { CentralIdentityLicense } from "../types"; +import api from "../api"; +import useGlobalError from "../components/error/ErrorHooks"; + +const useCentralIdentityLicenses = () => { + const { handleGlobalError } = useGlobalError(); + const [licenseOptions, setLicenseOptions] = useState([]); + const [isFetching, setIsFetching] = useState(false); + + useEffect(() => { + loadLicenseOptions(); + }, []); + + async function loadLicenseOptions() { + try { + setIsFetching(true); + 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); + setLicenseOptions([]); + } finally { + setIsFetching(false); + } + } + + async function refetch() { + setIsFetching(true); + await loadLicenseOptions(); + setIsFetching(false); + } + + return { licenseOptions, isFetching, refetch }; +} + +export default useCentralIdentityLicenses; \ No newline at end of file diff --git a/client/src/types/HarvestRequest.ts b/client/src/types/HarvestRequest.ts new file mode 100644 index 00000000..3331af0d --- /dev/null +++ b/client/src/types/HarvestRequest.ts @@ -0,0 +1,15 @@ +import { License } from "./Misc"; + +export type HarvestRequest = { + name: string; + email: string; + title: string; + library: string; + url: string; + license: License; + institution: string; + resourceUse: string; + dateIntegrate: string; + addToProject: boolean; + comments: string; +} \ No newline at end of file diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 96fbd3b5..d8a9b29c 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -131,6 +131,10 @@ import { UserSearchParams, } from "./Search"; +import { + HarvestRequest, +} from "./HarvestRequest"; + export type { AtlasSearchHighlight, AssetFilters, @@ -168,6 +172,7 @@ export type { CommonsModuleSettings, ControlledInputProps, GenericKeyTextValueObj, + HarvestRequest, TimeZoneOption, MongoBaseDocument, CollectionDirectoryPathObj, diff --git a/server/api/harvestingrequests.js b/server/api/harvestingrequests.js index 54ec7686..cadc697d 100644 --- a/server/api/harvestingrequests.js +++ b/server/api/harvestingrequests.js @@ -106,7 +106,6 @@ const addRequest = (req, res) => { */ const getRequests = (req, res) => { try { - console.log(req.query) var sComp = String(req.query.startDate).split('-'); var eComp = String(req.query.endDate).split('-'); var sM, sD, sY; @@ -296,7 +295,8 @@ const convertRequest = (req, res) => { auditors: [], libreLibrary: harvestReq.library, license: { - name: harvestReq.license, + name: typeof harvestReq.license === 'object' ? harvestReq.license.name : harvestReq.license, + version: typeof harvestReq.license === 'object' ? harvestReq.license.version : '', sourceURL: harvestReq.url }, harvestReqID: harvestReq._id @@ -380,6 +380,34 @@ const convertRequest = (req, res) => { }); }; +function validateLicense(license){ + if (typeof license !== 'object') { + return false; + } + if(license.hasOwnProperty('name')){ + if (typeof license.name !== 'string') { + return false; + } + if(license.name.length > 255){ + return false + } + } + if(license.hasOwnProperty('url') && typeof license.url !== 'string'){ + return false; + } + if(license.hasOwnProperty('version') && typeof license.version !== 'string'){ + return false; + } + if(license.hasOwnProperty('additionalTerms')){ + if (typeof license.additionalTerms !== 'string') { + return false; + } + if(license.additionalTerms.length > 500){ + return false + } + } + return true; + } const validate = (method) => { switch (method) { @@ -388,7 +416,7 @@ const validate = (method) => { body('email', conductorErrors.err1).optional({ checkFalsy: true }).isEmail(), body('title', conductorErrors.err1).exists().isLength({ min: 1 }), body('library', conductorErrors.err1).exists().isLength({ min: 1 }), - body('license', conductorErrors.err1).exists().isLength({ min: 1 }), + body('license', conductorErrors.err1).optional({ checkFalsy: true }).isObject().custom(validateLicense), body('dateIntegrate').optional({ checkFalsy: true }).custom(threePartDateStringValidator), body('addToProject').optional({ checkFalsy: true }).isBoolean().toBoolean() ] diff --git a/server/models/harvestingrequest.ts b/server/models/harvestingrequest.ts index 1e07cf17..28ae5159 100644 --- a/server/models/harvestingrequest.ts +++ b/server/models/harvestingrequest.ts @@ -1,4 +1,5 @@ import { model, Schema, Document } from "mongoose"; +import { License } from "../types"; export interface HarvestingRequestInterface extends Document { email: string; @@ -6,7 +7,7 @@ export interface HarvestingRequestInterface extends Document { status: "open" | "converted" | "declined"; library: string; url?: string; - license: string; + license: License; name?: string; institution?: string; resourceUse?: string; @@ -38,8 +39,12 @@ const HarvestingRequestSchema = new Schema( }, url: String, license: { - type: String, - required: true, + name: String, + url: String, + version: String, + sourceURL: String, + modifiedFromSource: Boolean, + additionalTerms: String, }, name: String, institution: String,