diff --git a/firebase-functions/functions/index.js b/firebase-functions/functions/index.js index 0323dceb..d94af8f7 100644 --- a/firebase-functions/functions/index.js +++ b/firebase-functions/functions/index.js @@ -1,5 +1,6 @@ const admin = require("firebase-admin"); const { translate } = require("./translate"); +const { checkURLActive } = require("./serverUtils"); const { createDraftDoi, updateDraftDoi, deleteDraftDoi, getDoiStatus } = require("./datacite"); const { notifyReviewer, notifyUser } = require("./notify"); const { @@ -24,3 +25,4 @@ exports.createDraftDoi = createDraftDoi; exports.deleteDraftDoi = deleteDraftDoi; exports.updateDraftDoi = updateDraftDoi; exports.getDoiStatus = getDoiStatus; +exports.checkURLActive = checkURLActive; diff --git a/firebase-functions/functions/serverUtils.js b/firebase-functions/functions/serverUtils.js new file mode 100644 index 00000000..7ba0676e --- /dev/null +++ b/firebase-functions/functions/serverUtils.js @@ -0,0 +1,26 @@ +const functions = require("firebase-functions"); +const fetch = require('node-fetch'); + +// Function to check if a given URL is active +exports.checkURLActive = functions.https.onCall(async (data) => { + let url = data; + functions.logger.log('Received URL:', url); + + if (!url) { + throw new functions.https.HttpsError('invalid-argument', 'The function must be called with one argument "url".'); + } + + // Prepend 'http://' if the URL does not start with 'http://' or 'https://' + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'http://' + url; + } + + try { + const response = await fetch(url, {method: "HEAD" }); + functions.logger.log(`Fetch response status for ${url}:`, response.status); + return response.ok; // Return true if response is OK, otherwise false + } catch (error) { + functions.logger.error('Error in checkURLActive for URL:', url, error); + return false; // Return false if an error occurs + } +}) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6b999f47..d280eec4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "firebase-admin": "^11.5.0", "firebase-functions": "^4.2.1", "javascript-time-ago": "^2.3.4", + "just-debounce-it": "1.0.1", "leaflet": "^1.6.0", "leaflet-draw": "^1.0.4", "nunjucks": "^3.2.3", @@ -12697,6 +12698,11 @@ "node": ">=4.0" } }, + "node_modules/just-debounce-it": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-1.0.1.tgz", + "integrity": "sha512-82mt758EWKlkdGHodniikRrbtcUfYYH91d5Vt1sVYHt+RQqvv0EqQXpiZ3Q3BasqH59YaxUXPpPjUPOL2NRF5g==" + }, "node_modules/jwa": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", @@ -32397,6 +32403,11 @@ "object.assign": "^4.1.0" } }, + "just-debounce-it": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-1.0.1.tgz", + "integrity": "sha512-82mt758EWKlkdGHodniikRrbtcUfYYH91d5Vt1sVYHt+RQqvv0EqQXpiZ3Q3BasqH59YaxUXPpPjUPOL2NRF5g==" + }, "jwa": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", diff --git a/package.json b/package.json index 508adbea..5b0965ee 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "firebase-admin": "^11.5.0", "firebase-functions": "^4.2.1", "javascript-time-ago": "^2.3.4", + "just-debounce-it": "1.0.1", "leaflet": "^1.6.0", "leaflet-draw": "^1.0.4", "nunjucks": "^3.2.3", diff --git a/src/components/FormComponents/ContactEditor.jsx b/src/components/FormComponents/ContactEditor.jsx index 49501806..e6e6653a 100644 --- a/src/components/FormComponents/ContactEditor.jsx +++ b/src/components/FormComponents/ContactEditor.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { TextField, @@ -45,6 +45,7 @@ const ContactEditor = ({ updateContactRor, updateContactOrcid, }) => { + const mounted = useRef(false); const orgEmailValid = validateEmail(value.orgEmail); const indEmailValid = validateEmail(value.indEmail); const orgURLValid = validateURL(value.orgURL); @@ -64,19 +65,26 @@ const ContactEditor = ({ newInputValue.startsWith("http") && !newInputValue.includes("ror.org") ) { - setRorSearchActive(false); + if (mounted.current) setRorSearchActive(false); } else { fetch(`https://api.ror.org/organizations?query=${newInputValue}`) .then((response) => response.json()) - .then((response) => setRorOptions(response.items)) - .then(() => setRorSearchActive(false)); + .then((response) => {if (mounted.current) setRorOptions(response.items)}) + .then(() => {if (mounted.current) setRorSearchActive(false)}); } } useEffect(() => { + + mounted.current = true; + if (debouncedRorInputValue) { updateRorOptions(debouncedRorInputValue); } + + return () => { + mounted.current = false; + }; }, [debouncedRorInputValue]); return ( diff --git a/src/components/FormComponents/Resources.jsx b/src/components/FormComponents/Resources.jsx index 3030b31e..05682365 100644 --- a/src/components/FormComponents/Resources.jsx +++ b/src/components/FormComponents/Resources.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useContext, useEffect, useState, useRef } from "react"; import { Add, Delete, @@ -7,18 +7,49 @@ import { } from "@material-ui/icons"; import { Button, Grid, Paper, TextField } from "@material-ui/core"; import validator from "validator"; +import debounce from "just-debounce-it"; import { En, Fr, I18n } from "../I18n"; import BilingualTextInput from "./BilingualTextInput"; import RequiredMark from "./RequiredMark"; import { deepCopy } from "../../utils/misc"; +import { validateURL } from "../../utils/validate"; import { QuestionText, paperClass, SupplementalText } from "./QuestionStyles"; - -const validateURL = (url) => !url || validator.isURL(url); +import { UserContext } from "../../providers/UserProvider"; const Resources = ({ updateResources, resources, disabled }) => { + + const mounted = useRef(false); + const { checkURLActive } = useContext(UserContext); + const [urlIsActive, setUrlIsActive] = useState({}); const emptyResource = { url: "", name: "", description: { en: "", fr: "" } }; + const debouncePool = useRef({}); + + useEffect( () => { + + mounted.current = true + + resources.forEach( (resource, idx) => { + + if (resource.url && validateURL(resource.url)) { + if (!debouncePool.current[idx]){ + debouncePool.current[idx] = debounce( async (innerResource) => { + const response = await checkURLActive(innerResource.url) + if (mounted.current){ + setUrlIsActive((prevStatus) => ({ ...prevStatus, [innerResource.url]: response.data })) + } + }, 500); + } + debouncePool.current[idx](resource); + } + }); + + return () => { + mounted.current = false; + }; + }, [resources, checkURLActive]); + function addResource() { updateResources(resources.concat(deepCopy(emptyResource))); } @@ -35,13 +66,15 @@ const Resources = ({ updateResources, resources, disabled }) => { resources.splice(newIndex, 0, element); updateResources(resources); } + const nameLabel = ; const descriptionLabel = ; return (
- {resources.map((dist = deepCopy(emptyResource), i) => { - const urlIsValid = !dist.url || validateURL(dist.url); + {resources.map((resourceItem = deepCopy(emptyResource), i) => { + const urlIsValid = !resourceItem.url || validateURL(resourceItem.url); + function handleResourceChange(key) { return (e) => { const newValue = [...resources]; @@ -58,11 +91,11 @@ const Resources = ({ updateResources, resources, disabled }) => { Enter a name for the resource Entrez un titre pour la ressource - + { Entrez l'URL de la ressource - + @@ -99,11 +132,13 @@ const Resources = ({ updateResources, resources, disabled }) => { + (!urlIsValid && ) + || (resourceItem.url && urlIsActive[resourceItem.url] === false && ) + || (resourceItem.url && urlIsActive[resourceItem.url] === true && ) } error={!urlIsValid} label="URL" - value={dist.url} + value={resourceItem.url} onChange={handleResourceChange("url")} fullWidth disabled={disabled} @@ -119,7 +154,7 @@ const Resources = ({ updateResources, resources, disabled }) => { diff --git a/src/components/Tabs/SubmitTab.jsx b/src/components/Tabs/SubmitTab.jsx index e8f108c3..275fe4de 100644 --- a/src/components/Tabs/SubmitTab.jsx +++ b/src/components/Tabs/SubmitTab.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { Paper, @@ -16,13 +16,20 @@ import { useParams } from "react-router-dom"; import { paperClass } from "../FormComponents/QuestionStyles"; import { En, Fr, I18n } from "../I18n"; -import { getErrorsByTab, recordIsValid } from "../../utils/validate"; +import { + getErrorsByTab, + recordIsValid, + warnings, + validateFieldWarning, +} from "../../utils/validate"; import tabs from "../../utils/tabs"; import GetRegionInfo from "../FormComponents/Regions"; const SubmitTab = ({ record, submitRecord }) => { + const mounted = useRef(false); const [isSubmitting, setSubmitting] = useState(false); + const [validationWarnings, setValidationWarnings] = useState(false); const { language } = useParams(); @@ -30,6 +37,52 @@ const SubmitTab = ({ record, submitRecord }) => { const submitted = record.status === "submitted"; const regionInfo = GetRegionInfo(); + useEffect(() => { + + mounted.current = true + + const getUrlWarningsByTab = async (recordObj) => { + const fields = Object.keys(warnings); + + const validationPromises = fields.map((field) => + validateFieldWarning(recordObj, field) + ); + + const validationResults = await Promise.all(validationPromises); + + const validatedFields = fields.reduce((acc, field, index) => { + acc[field] = validationResults[index]; + return acc; + }, {}); + + const inactiveUrls = fields.filter((field) => { + return validatedFields[field]; + }); + const fieldWarningInfo = inactiveUrls.map((field) => { + const { error, tab } = warnings[field]; + return { error, tab }; + }); + + const fieldWarningInfoReduced = fieldWarningInfo.reduce( + (acc, { error, tab }) => { + if (!acc[tab]) acc[tab] = []; + acc[tab].push(error); + return acc; + }, + {} + ); + if (mounted.current) + setValidationWarnings(fieldWarningInfoReduced); + }; + + getUrlWarningsByTab(record); + + return () => { + mounted.current = false; + }; + + }, [record]); + return ( @@ -121,6 +174,16 @@ const SubmitTab = ({ record, submitRecord }) => { ) : ( <> + {/* Errors Section */} + + + + Errors + Erreurs + + + + @@ -157,6 +220,60 @@ const SubmitTab = ({ record, submitRecord }) => { )} + + {validationWarnings && + Object.keys(validationWarnings).length > 0 ? ( + <> + {/* Warnings Section Heading */} + + + + Warnings + Avertissements + + + + + + + + + Some warnings were generated for the following fields. + Please review and fix the warnings as needed befor + submitting the record. + + + Certains avertissements ont été générés pour les champs + suivants. Veuillez examiner et corriger les + avertissements si nécessaire avant de soumettre + l'enregistrement. + + + + + + + {Object.keys(validationWarnings).map((tab) => ( +
+ + {tabs[tab][language]} + + + {validationWarnings[tab].map( + ({ [language]: error }, i) => ( + + + + ) + )} + +
+ ))} +
+ + ) : ( + " " + )} )}
diff --git a/src/providers/UserProvider.jsx b/src/providers/UserProvider.jsx index 6f58e1cd..8f32ad58 100644 --- a/src/providers/UserProvider.jsx +++ b/src/providers/UserProvider.jsx @@ -88,6 +88,7 @@ class UserProvider extends FormClassTemplate { const updateDraftDoi = firebase.functions().httpsCallable("updateDraftDoi"); const deleteDraftDoi = firebase.functions().httpsCallable("deleteDraftDoi"); const getDoiStatus = firebase.functions().httpsCallable("getDoiStatus"); + const checkURLActive = firebase.functions().httpsCallable("checkURLActive"); return ( {children} diff --git a/src/utils/misc.js b/src/utils/misc.js index de72be82..558045e1 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -4,6 +4,7 @@ export function deepCopy(obj) { export function deepEquals(obj1, obj2) { return JSON.stringify(obj1) === JSON.stringify(obj2); } + /* Convert firebase to javascript, mostly just used to get real array elements */ diff --git a/src/utils/validate.js b/src/utils/validate.js index c8f51b30..240f25ac 100644 --- a/src/utils/validate.js +++ b/src/utils/validate.js @@ -1,8 +1,12 @@ import validator from "validator"; +import firebase from "../firebase"; + export const validateEmail = (email) => !email || validator.isEmail(email); export const validateURL = (url) => !url || validator.isURL(url); +const checkURLActive = firebase.functions().httpsCallable('checkURLActive'); + // See https://stackoverflow.com/a/48524047/7416701 export const doiRegexp = /^(https:\/\/doi.org\/)?10\.\d{4,9}\/[-._;()/:A-Z0-9]+$/i; function isValidHttpUrl(string) { @@ -235,6 +239,7 @@ const validators = { }, }, }; + export const validateField = (record, fieldName) => { const valueToValidate = record[fieldName]; // if no validator funciton exists, then it is not a required field @@ -259,6 +264,42 @@ export const getErrorsByTab = (record) => { }, {}); }; + +export const warnings = { + distribution: { + tab: "resources", + validation: async (val) => { + const processedVal = await Promise.all( + val.map(async (dist) => { + const res = await checkURLActive(dist.url); + return {...dist, status:res.data}; + }) + ); + const filterVal = processedVal.filter((dist) => !dist.status) + return filterVal.length + }, + error: { + en: + "Resource URL is not accessible. This could be because it has not been created yet or is otherwise unreachable", + fr: + "L'URL de la ressource n'est pas accessible. Cela peut être dû au fait qu'il n'a pas encore été créé ou qu'il est autrement inaccessible.", + }, + }, +}; + + +export const validateFieldWarning = async (record, fieldName) => { + const valueToValidate = record[fieldName]; + // if no validator funciton exists, then it is not a required field + const validationFunction = + (warnings[fieldName] && warnings[fieldName].validation) || (() => true); + + const res = await validationFunction(valueToValidate, record); + return validationFunction && res; +}; + + + export const percentValid = (record) => { const fields = Object.keys(validators); const numTotalRequired = fields.filter((field) => !validators[field].optional)