diff --git a/client/package.json b/client/package.json index 8ccc35a6..9e1aabcd 100644 --- a/client/package.json +++ b/client/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@bunchtogether/vite-plugin-flow": "^1.0.2", - "@clerk/react": "^6.0.1", + "@clerk/react": "^6.3.0", "@performant-software/geospatial": "^3.1.17", "@performant-software/semantic-components": "^3.1.17", "@performant-software/shared-components": "^3.1.17", diff --git a/client/src/components/ClerkAuthenticationContextProvider.js b/client/src/components/ClerkAuthenticationContextProvider.js index 7beea45b..0c59cf9d 100644 --- a/client/src/components/ClerkAuthenticationContextProvider.js +++ b/client/src/components/ClerkAuthenticationContextProvider.js @@ -1,4 +1,5 @@ // @flow + import { useAuth } from '@clerk/react'; import React, { useEffect, useMemo, useState } from 'react'; import { AuthenticationContext } from '../context/Authentication'; @@ -9,10 +10,8 @@ const PROVIDER = import.meta.env.VITE_AUTH_PROVIDER || 'local'; const ClerkAuthenticationContextProvider = (props: any) => { const clerkAuth = useAuth(); const [user, setUser] = useState(null); + const [ready, setReady] = useState(false); - /** - * Call the /me endpoint to get the current user's data. - */ useEffect(() => { if (clerkAuth.isSignedIn && !user) { UsersService.getMe() @@ -21,29 +20,30 @@ const ClerkAuthenticationContextProvider = (props: any) => { } }, [clerkAuth.isSignedIn]); - const data = useMemo(() => { - return { - authenticated: clerkAuth.isSignedIn, - logout: clerkAuth.signOut, - user - }; - }, [clerkAuth, user]); - - /** - * Block the initial render so the useEffect has a chance to set up the axios interceptor. - */ - const ready = useMemo(() => ( - clerkAuth.isLoaded && (user || !clerkAuth.isSignedIn)), - [clerkAuth.isLoaded, user, clerkAuth.isSignedIn] - ); + const authenticated = useMemo(() => { + if (!clerkAuth.isLoaded) return !!user; + return clerkAuth.isSignedIn; + }, [clerkAuth.isLoaded, clerkAuth.isSignedIn, user]); + + const data = useMemo(() => ({ + authenticated, + logout: clerkAuth.signOut, + user + }), [authenticated, clerkAuth.signOut, user]); + + useEffect(() => { + if (clerkAuth.isLoaded && !!(user || !clerkAuth.isSignedIn)) { + setReady(true); + } + }, [clerkAuth.isSignedIn, user]); return ( - { ready && props.children } + {ready && props.children} - ) -} + ); +}; export default ClerkAuthenticationContextProvider; diff --git a/client/src/components/ItemPage.js b/client/src/components/ItemPage.js index 1bbd619c..23fa8f9d 100644 --- a/client/src/components/ItemPage.js +++ b/client/src/components/ItemPage.js @@ -3,7 +3,6 @@ import { Toaster } from '@performant-software/semantic-components'; import cx from 'classnames'; import React, { - useCallback, useContext, useEffect, useMemo, @@ -31,8 +30,8 @@ import Relationships from './Relationships'; import SaveButton from './SaveButton'; import Section from './Section'; import styles from './ItemPage.module.css'; +import useReactRouterEditPage from '../hooks/useReactRouterEditPage'; import Validation from '../utils/Validation'; -import withReactRouterEditPage from '../hooks/ReactRouterEditPage'; type Props = { form: Element, @@ -44,166 +43,172 @@ type Props = { type ComponentProps = { errors?: Array, + form: any, item: any, + loading: boolean, + onCreateManifests: (item: any) => Promise, + onSave: (item: any) => Promise, onSaved: (item: any) => void, - saved?: boolean + saved?: boolean, + saving?: boolean }; -const ItemPage = (props: Props) => { - const { - form: Form, - onCreateManifests, - onInitialize, - onSave - } = props; - +const Component = (props: ComponentProps) => { + const [saved, setSaved] = useState(false); + const { label, name, url } = initialize(props); + const { projectModel } = useContext(ProjectContext); const { t } = useTranslation(); - const Component = useCallback((props: ComponentProps) => { - const [saved, setSaved] = useState(false); - const { label, name, url } = initialize(props); - const { projectModel } = useContext(ProjectContext); - - /** - * Memo-izes the ItemLayoutContext value. - * - * @type {{saved: boolean, setSaved: function(): void}} - */ - const layoutValue = useMemo(() => ({ saved, setSaved }), [saved, setSaved]); + /** + * Memo-izes the ItemLayoutContext value. + * + * @type {{saved: boolean, setSaved: function(): void}} + */ + const layoutValue = useMemo(() => ({ saved, setSaved }), [saved, setSaved]); - /** - * Memo-izes the ItemContext value. - * - * @type {{uuid: *}} - */ - const itemValue = useMemo(() => ({ uuid: props.item.uuid }), [props.item?.uuid]); + /** + * Memo-izes the ItemContext value. + * + * @type {{uuid: *}} + */ + const itemValue = useMemo(() => ({ uuid: props.item.uuid }), [props.item?.uuid]); - /** - * Sets the saved prop on the state when the component is mounted. - */ - useEffect(() => { - if (props.saved) { - setSaved(true); - } - }, []); + /** + * Sets the saved prop on the state when the component is mounted. + */ + useEffect(() => { + if (props.saved) { + setSaved(true); + } + }, [props.saved]); - return ( - + - - setSaved(false)} + type={Toaster.MessageTypes.positive} + visible={saved} > - setSaved(false)} - type={Toaster.MessageTypes.positive} - visible={saved} + + + + + + + + + + + + + + + - - - - + +
- - - - - - - - - - - - - -
- -
-
- -
- - { projectModel?.allow_identifiers && props.item.id && ( -
- -
- -
- )} +
+ + { projectModel?.allow_identifiers && props.item.id && (
- +
-
-
-
-
- ); - }, []); + )} +
+ +
+ +
+ + + + + ); +}; + +const ItemPage = (props: Props) => { + const { + onCreateManifests, + onInitialize, + onSave + } = props; - const Page = withReactRouterEditPage(Component, { + const editPageProps = useReactRouterEditPage({ id: 'itemId', onCreateManifests, onSave, onInitialize, - resolveValidationError: Validation.resolveUpdateError.bind(this) + resolveValidationError: Validation.resolveUpdateError }); return ( - + ); }; -export default ItemPage; +export default ItemPage; \ No newline at end of file diff --git a/client/src/hooks/ReactRouterEditPage.js b/client/src/hooks/ReactRouterEditPage.js deleted file mode 100644 index eeb435d9..00000000 --- a/client/src/hooks/ReactRouterEditPage.js +++ /dev/null @@ -1,88 +0,0 @@ -// @flow - -import { withEditPage, type EditPageConfig } from '@performant-software/shared-components'; -import React, { useCallback, type AbstractComponent } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router'; -import _ from 'underscore'; - -const ERROR_USER_DEFINED = 'user_defined'; - -const withReactRouterEditPage = (WrappedComponent: AbstractComponent, config: EditPageConfig): any => ( - (props: any) => { - const location = useLocation(); - const navigate = useNavigate(); - const params = useParams(); - - const { pathname } = location; - const url = pathname.substring(0, pathname.lastIndexOf('/')); - - const id = params[config.id]; - - const { state } = location; - const { saved, tab: defaultTab } = state || {}; - - /** - * After save, navigate to the newly created record. We'll also add the "saved" attribute to indicate a message - * should be displayed to the user. - * - * If an "afterSave" attribute is passed, determine where to navigate based on the attribute. - * - * @type {function(*, string): *} - */ - const afterSave = useCallback((item: any) => { - if (config.afterSave && _.isFunction(config.afterSave)) { - return config.afterSave(navigate, item); - } - - if (config.afterSave && _.isString(config.afterSave)) { - return navigate(config.afterSave, { state: { saved: true } }); - } - - return navigate(`${url}/${item.id}`, { state: { saved: true } }) - }, [navigate, url, config.afterSave]); - - /** - * Navigates to the previous route. - * - * @type {function(): *} - */ - const onCancel = useCallback(() => navigate(-1), [navigate]); - - /** - * Resolves the passed validation error. - * - * @type {function({key: *, error: *}): {}} - */ - const resolveValidationError = useCallback(({ key, error }) => { - const errors = {}; - - if (key === ERROR_USER_DEFINED) { - const [uuid, message] = error; - _.extend(errors, { [uuid]: message }); - } else { - _.extend(errors, { [key]: error }); - } - - return errors; - }, []); - - const EditPage = withEditPage(WrappedComponent, { - ...config, - afterSave, - onCancel, - id, - defaultTab, - resolveValidationError, - saved - }); - - return ( - - ); - } -); - -export default withReactRouterEditPage; diff --git a/client/src/hooks/useEditContainer.js b/client/src/hooks/useEditContainer.js new file mode 100644 index 00000000..2c745ab7 --- /dev/null +++ b/client/src/hooks/useEditContainer.js @@ -0,0 +1,310 @@ +// @flow + +import { useState, useEffect, useCallback, useRef } from 'react'; +import _ from 'underscore'; +import i18n from '../i18n/i18n'; +import { ObjectJs as ObjectUtils } from '@performant-software/shared-components'; + +type Options = { + defaults?: any, + item?: any, + onClose: () => void, + onInitialize?: (id: number) => Promise, + onSave: (item: any) => Promise, + required?: Array, + resolveValidationError?: ({ error: string, item: any, status: number, key: string }) => Array, + validate?: (item: any) => Array +}; + +const ERROR_EMPTY = 'can\'t be blank'; +const ERROR_UNIQUE = 'has already been taken'; + +const useEditContainer = (options: Options) => { + const { + defaults, + item: itemProp, + onClose, + onInitialize, + onSave: onSaveProp, + required, + resolveValidationError, + validate + } = options; + + const initialItem = _.defaults(itemProp || {}, defaults || {}); + + const [item, setItem] = useState(initialItem); + const [originalItem, setOriginalItem] = useState(initialItem); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [validationErrors, setValidationErrors] = useState({}); + + const mountedRef = useRef(true); + + useEffect(() => { + if (onInitialize && itemProp && itemProp.id) { + setLoading(true); + onInitialize(itemProp.id).then((loaded) => { + if (mountedRef.current) { + setItem(loaded); + setOriginalItem(loaded); + setLoading(false); + } + }); + } + }, []); + + useEffect(() => { + if (itemProp) { + setItem(itemProp); + setOriginalItem(itemProp); + } + }, [itemProp]); + + useEffect(() => { + return () => { mountedRef.current = false; }; + }, []); + + const isChild = useCallback((a, b) => ( + (a.uid && b.uid && a.uid === b.uid) || (a.id && b.id && a.id === b.id) + ), []); + + const isRequired = useCallback((prop: string) => ( + required && _.contains(required, prop) + ), [required]); + + const isError = useCallback((prop: string) => ( + _.has(validationErrors, prop) + ), [validationErrors]); + + const onClearValidationError = useCallback((...keys: Array) => { + setValidationErrors((prev) => _.omit(prev, keys)); + }, []); + + const onMarkChildAssociationForDelete = useCallback((association: string, child: any) => { + setItem((prev) => ({ + ...prev, + [association]: _.map(prev[association] || [], + (c) => (c.id === child.id ? { ...c, _destroy: true } : c)) + })); + }, []); + + const onRemoveChildAssociation = useCallback((association: string, child: any) => { + setItem((prev) => ({ + ...prev, + [association]: _.filter(prev[association] || [], + (c) => !(isChild(c, child) || ObjectUtils.isEqual(c, child))) + })); + }, [isChild]); + + const onDeleteChildAssociation = useCallback((association: string, child: any) => ( + child.id + ? onMarkChildAssociationForDelete(association, child) + : onRemoveChildAssociation(association, child) + ), [onMarkChildAssociationForDelete, onRemoveChildAssociation]); + + const onSaveChildAssociation = useCallback((association: string, child: any) => { + setItem((prev) => { + const children = prev[association] || []; + const exists = _.find(children, (c) => isChild(child, c)); + + if (exists) { + return { + ...prev, + [association]: _.map(children, (c) => (isChild(child, c) ? child : c)) + }; + } + + return { + ...prev, + [association]: [...children, child] + }; + }); + }, [isChild]); + + const onMultiAddChildAssociations = useCallback((association: string, children: any) => { + setItem((prev) => { + let updated = { ...prev }; + const items = updated[association] || []; + + // Add new or update existing + _.each(children, (child) => { + const existing = _.find(updated[association] || [], (c) => isChild(child, c)); + if (existing) { + updated = { + ...updated, + [association]: _.map(updated[association] || [], (c) => (isChild(child, c) ? child : c)) + }; + } else { + updated = { + ...updated, + [association]: [...(updated[association] || []), child] + }; + } + }); + + // Remove children that no longer exist + const toRemove = _.filter(items, (i) => !_.find(children, (c) => isChild(i, c))); + _.each(toRemove, (child) => { + if (child.id) { + updated = { + ...updated, + [association]: _.map(updated[association] || [], + (c) => (c.id === child.id ? { ...c, _destroy: true } : c)) + }; + } else { + updated = { + ...updated, + [association]: _.filter(updated[association] || [], + (c) => !(isChild(c, child) || ObjectUtils.isEqual(c, child))) + }; + } + }); + + return updated; + }); + }, [isChild]); + + const onError = useCallback(({ response: { data: { errors = {} }, status } }: any) => { + const newErrors = {}; + + _.each(Object.keys(errors), (key) => { + const fieldErrors = errors[key]; + + _.each(fieldErrors, (error) => { + if (error === ERROR_UNIQUE) { + _.extend(newErrors, { [key]: i18n.t('EditContainer.errors.unique', { key, value: item[key] }) }); + } else if (error === ERROR_EMPTY) { + _.extend(newErrors, { [key]: i18n.t('EditContainer.errors.required', { key }) }); + } else if (resolveValidationError) { + _.extend(newErrors, resolveValidationError({ key, error, status, item })); + } + }); + }); + + if (status === 400 && _.isEmpty(newErrors)) { + _.extend(newErrors, { error: i18n.t('EditContainer.errors.general') }); + } else if (status === 500 && _.isEmpty(newErrors)) { + _.extend(newErrors, { error: i18n.t('EditContainer.errors.system') }); + } + + setSaving(false); + setValidationErrors(newErrors); + }, [item, resolveValidationError]); + + const validateForm = useCallback(() => { + const errors = {}; + + if (validate) { + _.extend(errors, validate(item)); + } + + _.each(required || [], (key) => { + const value = item[key]; + const invalid = _.isNumber(value) + ? _.isEmpty(value.toString()) + : _.isEmpty(value); + + if (invalid) { + _.extend(errors, { [key]: i18n.t('EditContainer.errors.required', { key }) }); + } + }); + + setValidationErrors(errors); + return _.keys(errors).length === 0; + }, [item, required, validate]); + + const onSave = useCallback(() => { + if (!validateForm()) return; + + setSaving(true); + onSaveProp(item) + .catch(onError) + .finally(() => { + if (mountedRef.current) setSaving(false); + }); + }, [item, onSaveProp, onError, validateForm]); + + const onJsonInputChange = useCallback((key: string, jsonKey: string, e: Event, { value }: any) => { + setItem((prev) => ({ + ...prev, + [key]: { ...prev[key], [jsonKey]: value } + })); + setValidationErrors((prev) => _.omit(prev, key)); + }, []); + + const onAssociationInputChange = useCallback((idKey: string, valueKey: string, record: any = {}) => { + setItem((prev) => ({ + ...prev, + [idKey]: record.id || '', + [valueKey]: record || {} + })); + setValidationErrors((prev) => _.omit(prev, idKey)); + }, []); + + const onCheckboxInputChange = useCallback((key: string) => { + setItem((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + const onSetState = useCallback((props: any) => { + setItem((prev) => ({ ...prev, ...props })); + setValidationErrors((prev) => _.omit(prev, _.keys(props))); + }, []); + + const onTextInputChange = useCallback((key: string, e: Event, { value }: any) => { + setItem((prev) => ({ ...prev, [key]: value })); + setValidationErrors((prev) => _.omit(prev, key)); + }, []); + + const onReset = useCallback(() => { + const resetItem = defaults || {}; + setItem(resetItem); + setOriginalItem(resetItem); + }, [defaults]); + + return { + dirty: !!(item.id && !ObjectUtils.isEqual(item, originalItem)), + errors: _.values(validationErrors), + isError, + isRequired, + item, + loading, + onAssociationInputChange, + onCheckboxInputChange, + onClearValidationError, + onClose, + onDeleteChildAssociation, + onJsonInputChange, + onMultiAddChildAssociations, + onReset, + onSave, + onSaveChildAssociation, + onSetState, + onTextInputChange, + saving + }; +}; + +export default useEditContainer; + +export type EditContainerProps = { + dirty: boolean, + errors: Array, + isError: (property: string) => boolean, + isRequired: (property: string) => boolean, + item: any, + loading: boolean, + onAssociationInputChange: (idKey: string, valueKey: string, item: any) => void, + onCheckboxInputChange: (key: string) => void, + onClearValidationError: (...keys: Array) => void, + onClose: () => void, + onJsonInputChange: (key: string, jsonKey: string, e: ?Event, value: any) => void, + onDeleteChildAssociation: (association: string, child: any) => void, + onMultiAddChildAssociations: (association: string, Array) => void, + onReset: () => void, + onSave: () => void, + onSaveChildAssociation: (association: string, child: any) => void, + onSetState: (any) => void, + onTextInputChange: (key: string, e: ?Event, value: any) => void, + saving: boolean +}; diff --git a/client/src/hooks/useEditPage.js b/client/src/hooks/useEditPage.js new file mode 100644 index 00000000..2b6cd38d --- /dev/null +++ b/client/src/hooks/useEditPage.js @@ -0,0 +1,75 @@ +// @flow + +import { useCallback, useMemo, useRef } from 'react'; +import _ from 'underscore'; +import i18n from '../i18n/i18n'; +import useEditContainer from './useEditContainer'; + +type Config = { + afterSave?: (item: any, tab: string) => Promise, + id: string, + onCancel: () => void, + onInitialize: (item: any) => Promise, + onSave: (item: any) => Promise, + required?: Array, + resolveValidationError?: (params: any) => any, + validate?: (item: any) => any +}; + +const useEditPage = (config: Config) => { + const { id } = config; + const tabRef = useRef(); + + /** + * Adds the authorization error. + */ + const resolveValidationError = useCallback((errorProps) => { + const errors = {}; + + if (config.resolveValidationError) { + _.extend(errors, config.resolveValidationError(errorProps)); + } + + if (errorProps.status === 403) { + _.extend(errors, { base: i18n.t('Common.errors.unauthorized') }); + } + + return errors; + }, [config.resolveValidationError]); + + /** + * Saves the passed item then calls the "afterSave" prop. + */ + const onSave = useCallback((item) => ( + config + .onSave(item) + .then((record) => config.afterSave && config.afterSave(record, tabRef.current)) + ), [config.afterSave, config.onSave]); + + const item = useMemo(() => ({ id }), [id]); + + const editProps = useEditContainer({ + item, + onClose: config.onCancel, + onInitialize: config.onInitialize, + onSave, + required: config.required, + resolveValidationError, + validate: config.validate + }); + + const onTabClick = useCallback((newTab) => { + tabRef.current = newTab; + }, []); + + return { + ...editProps, + onTabClick + }; +}; + +export default useEditPage; + +export type { + Config +}; \ No newline at end of file diff --git a/client/src/hooks/useReactRouterEditPage.js b/client/src/hooks/useReactRouterEditPage.js new file mode 100644 index 00000000..5e17e2a9 --- /dev/null +++ b/client/src/hooks/useReactRouterEditPage.js @@ -0,0 +1,97 @@ +// @flow + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router'; +import _ from 'underscore'; +import useEditPage from './useEditPage'; + +type Config = { + afterSave?: ((navigate: Function, item: any) => any) | string, + id: string, + onInitialize: (item: any) => Promise, + onSave: (item: any) => Promise, + required?: Array, + validate?: (item: any) => any +}; + +const ERROR_USER_DEFINED = 'user_defined'; + +const useReactRouterEditPage = (config: Config) => { + const location = useLocation(); + const navigate = useNavigate(); + const params = useParams(); + + const { state } = location; + const { saved: initialSavedState, tab: defaultTab } = state || {}; + + const [saved, setSaved] = useState(initialSavedState); + + const timeoutRef = useRef(); + + useEffect(() => { + timeoutRef.current = setTimeout(() => setSaved(false), 1000); + return () => clearTimeout(timeoutRef.current); + }, [saved]); + + const { pathname } = location; + const url = pathname.substring(0, pathname.lastIndexOf('/')); + + const id = params[config.id]; + + /** + * After save, navigate to the newly created record. We'll also add the "saved" attribute to indicate a message + * should be displayed to the user. + * + * If an "afterSave" attribute is passed, determine where to navigate based on the attribute. + */ + const afterSave = useCallback((item: any) => { + if (config.afterSave && _.isFunction(config.afterSave)) { + return config.afterSave(navigate, item); + } + + setSaved(true); + + if (config.afterSave && _.isString(config.afterSave)) { + return navigate(config.afterSave, { state: { saved: true } }); + } + + return navigate(`${url}/${item.id}`, { state: { saved: true } }); + }, [navigate, url, config.afterSave]); + + /** + * Navigates to the previous route. + */ + const onCancel = useCallback(() => navigate(-1), [navigate]); + + /** + * Resolves the passed validation error. + */ + const resolveValidationError = useCallback(({ key, error }) => { + const errors = {}; + + if (key === ERROR_USER_DEFINED) { + const [uuid, message] = error; + _.extend(errors, { [uuid]: message }); + } else { + _.extend(errors, { [key]: error }); + } + + return errors; + }, []); + + const editPageProps = useEditPage({ + ...config, + afterSave, + onCancel, + id, + defaultTab, + resolveValidationError + }); + + return { + ...editPageProps, + saved + }; +}; + +export default useReactRouterEditPage; diff --git a/client/src/pages/PasswordReset.js b/client/src/pages/PasswordReset.js index 42dd2f7a..deae8c9e 100644 --- a/client/src/pages/PasswordReset.js +++ b/client/src/pages/PasswordReset.js @@ -9,16 +9,34 @@ import SessionService from '../services/Session'; import UserPassword from '../components/UserPassword'; import UsersService from '../services/Users'; import UserUtils from '../utils/User'; -import withReactRouterEditPage from '../hooks/ReactRouterEditPage'; +import useReactRouterEditPage from '../hooks/useReactRouterEditPage'; import { AuthenticationContext } from '../context/Authentication'; -const PasswordResetForm = (props) => { +const PasswordReset = (props) => { const [toaster, setToaster] = useState(SessionService.isPasswordChangeRequired()); const { t } = useTranslation(); const { user } = useContext(AuthenticationContext); const { canResetPassword } = usePermissions(); + const editPageProps = useReactRouterEditPage({ + afterSave: (navigate) => ( + SessionService + .reset() + .then(() => navigate('/projects', { state: { saved: true } })) + ), + onSave: (user) => { + const { user: currentUser } = SessionService.getSession(); + const { id } = currentUser; + + return UsersService + .save({ ...user, id }) + .then(({ data }) => data.user); + }, + required: ['password', 'password_confirmation'], + validate: UserUtils.validatePassword + }); + /** * Navigate to the projects list if the current user does not have permissions to reset passwords. */ @@ -42,12 +60,14 @@ const PasswordResetForm = (props) => { /> @@ -68,22 +88,4 @@ const PasswordResetForm = (props) => { ); }; -const PasswordReset = withReactRouterEditPage(PasswordResetForm, { - afterSave: (navigate) => ( - SessionService - .reset() - .then(() => navigate('/projects', { state: { saved: true } })) - ), - onSave: (user) => { - const { user: currentUser } = SessionService.getSession(); - const { id } = currentUser; - - return UsersService - .save({ ...user, id }) - .then(({ data }) => data.user); - }, - required: ['password', 'password_confirmation'], - validate: UserUtils.validatePassword.bind(this) -}); - export default PasswordReset; diff --git a/client/src/pages/Project.js b/client/src/pages/Project.js index e06e4d00..a530f28d 100644 --- a/client/src/pages/Project.js +++ b/client/src/pages/Project.js @@ -28,13 +28,13 @@ import { SlLock } from 'react-icons/sl'; import styles from './Project.module.css'; import UnauthorizedRedirect from '../components/UnauthorizedRedirect'; import useParams from '../hooks/ParsedParams'; -import withReactRouterEditPage from '../hooks/ReactRouterEditPage'; +import useReactRouterEditPage from '../hooks/useReactRouterEditPage'; type Props = EditContainerProps & { item: ProjectType }; -const ProjectForm = (props: Props) => { +const Project = (props: Props) => { const [clearModal, setClearModal] = useState(false); const [cleared, setCleared] = useState(false); const [clearing, setClearing] = useState(false); @@ -50,6 +50,23 @@ const ProjectForm = (props: Props) => { canEditProjectSettings } = usePermissions(); + const editPageProps = useReactRouterEditPage({ + id: 'projectId', + onInitialize: (id) => ( + ProjectsService + .fetchOne(id) + .then(({ data }) => data.project) + ), + onSave: (project) => ( + ProjectsService + .save(project) + .then(({ data }) => data.project) + ), + required: ['name'] + }); + + const { item } = editPageProps; + /** * Clears all of the data from the current project. * @@ -59,11 +76,11 @@ const ProjectForm = (props: Props) => { setClearing(true); return ProjectsService - .clear(props.item) + .clear(item) .then(() => setCleared(true)) .then(() => setClearModal(false)) .finally(() => setClearing(false)); - }, [props.item]); + }, [item]); /** * Deletes the current project and navigates back to the list. @@ -74,11 +91,11 @@ const ProjectForm = (props: Props) => { setDeleting(true); return ProjectsService - .delete(props.item) + .delete(item) .then(() => setDeleteModal(false)) .then(() => navigate('/projects')) .finally(() => setDeleting(false)); - }, [navigate, props.item]); + }, [navigate, item]); /** * Calls the /core_data/project_models API endpoint. @@ -88,10 +105,10 @@ const ProjectForm = (props: Props) => { const onSearch = useCallback((search) => ( ProjectModelsService.fetchAll({ search, - project_id: props.item.id, + project_id: item.id, model_class: 'CoreDataConnector::Item' }) - ), [props.item.id]); + ), [item.id]); /** * Return to the projects list if the user does not have permissions to edit this project. @@ -106,95 +123,96 @@ const ProjectForm = (props: Props) => {
{ content={t('Project.messages.share.header')} /> @@ -244,17 +262,17 @@ const ProjectForm = (props: Props) => { content={t('Project.messages.archive.header')} />
)} - { canDeleteProject(props.item.id) && ( + { canDeleteProject(item.id) && (
@@ -351,19 +369,4 @@ const ProjectForm = (props: Props) => { ); }; -const Project: any = withReactRouterEditPage(ProjectForm, { - id: 'projectId', - onInitialize: (id) => ( - ProjectsService - .fetchOne(id) - .then(({ data }) => data.project) - ), - onSave: (project) => ( - ProjectsService - .save(project) - .then(({ data }) => data.project) - ), - required: ['name'] -}); - export default Project; diff --git a/client/src/pages/ProjectModel.js b/client/src/pages/ProjectModel.js index 87999c66..b9db85ec 100644 --- a/client/src/pages/ProjectModel.js +++ b/client/src/pages/ProjectModel.js @@ -39,7 +39,7 @@ import ProjectModelsUtils from '../utils/ProjectModels'; import ProjectsService from '../services/Projects'; import styles from './ProjectModel.module.css'; import useParams from '../hooks/ParsedParams'; -import withReactRouterEditPage from '../hooks/ReactRouterEditPage'; +import useReactRouterEditPage from '../hooks/useReactRouterEditPage'; const INVERSE_RELATIONSHIP_KEY = 'inverse_project_model_relationships'; const RELATIONSHIP_KEY = 'project_model_relationships'; @@ -48,7 +48,7 @@ type Props = EditContainerProps & { item: ProjectModelType }; -const ProjectModelForm = (props: Props) => { +const ProjectModel = (props: Props) => { const [accessModal, setAccessModal] = useState(false); const [shareModal, setShareModal] = useState(false); @@ -58,6 +58,24 @@ const ProjectModelForm = (props: Props) => { const { setReloadProjectModels } = useContext(ProjectContext); + const editPageProps = useReactRouterEditPage({ + id: 'projectModelId', + onInitialize: (id) => ( + ProjectModelsService + .fetchOne(id) + .then(({ data }) => data.project_model) + ), + onSave: (projectModel) => ( + ProjectModelsService + .save(projectModel) + .then(({ data }) => data.project_model) + ), + required: ['model_class', 'name'], + resolveValidationError: ProjectModelsUtils.resolveValidationError + }); + + const { item, onSetState } = editPageProps; + /** * Returns the model name for the passed relationship based on context. * @@ -92,9 +110,9 @@ const ProjectModelForm = (props: Props) => { */ const onSaveRelationship = useCallback((relationship) => { const association = relationship.inverse ? INVERSE_RELATIONSHIP_KEY : RELATIONSHIP_KEY; - props.onSaveChildAssociation(association, relationship); - props.onSaveChildAssociation('all_project_model_relationships', relationship); - }, []); + editPageProps.onSaveChildAssociation(association, relationship); + editPageProps.onSaveChildAssociation('all_project_model_relationships', relationship); + }, [editPageProps.onSaveChildAssociation]); /** * Deletes the passed relationship from the appropriate collection. @@ -103,9 +121,9 @@ const ProjectModelForm = (props: Props) => { */ const onDeleteRelationship = useCallback((relationship) => { const association = relationship.inverse ? INVERSE_RELATIONSHIP_KEY : RELATIONSHIP_KEY; - props.onDeleteChildAssociation(association, relationship); - props.onDeleteChildAssociation('all_project_model_relationships', relationship); - }, []); + editPageProps.onDeleteChildAssociation(association, relationship); + editPageProps.onDeleteChildAssociation('all_project_model_relationships', relationship); + }, [editPageProps.onDeleteChildAssociation]); /** * If we've saved the record, reload project models. @@ -120,8 +138,8 @@ const ProjectModelForm = (props: Props) => { * For a new record, set the foreign key ID based on the route parameters. */ useEffect(() => { - if (!props.item.id && projectId) { - props.onSetState({ project_id: projectId }); + if (!item.id && projectId) { + onSetState({ project_id: projectId }); } }, []); @@ -133,12 +151,13 @@ const ProjectModelForm = (props: Props) => { label: t('ProjectModel.labels.all'), url: `/projects/${projectId}/project_models` }} - name={props.item.name} + name={item.name} /> { name={t('ProjectModel.tabs.details')} > { location: 'top' }} defaults={{ - table_name: props.item.model_class + table_name: item.model_class }} excludeColumns={['table_name', 'uuid']} - items={props.item.user_defined_fields} - onDelete={props.onDeleteChildAssociation.bind(this, 'user_defined_fields')} - onSave={props.onSaveChildAssociation.bind(this, 'user_defined_fields')} + items={item.user_defined_fields} + onDelete={editPageProps.onDeleteChildAssociation.bind(this, 'user_defined_fields')} + onSave={editPageProps.onSaveChildAssociation.bind(this, 'user_defined_fields')} /> { label: t('Common.columns.uuid'), hidden: true }]} - items={props.item.all_project_model_relationships} + items={item.all_project_model_relationships} modal={{ component: ProjectModelRelationshipModal }} @@ -266,7 +285,7 @@ const ProjectModelForm = (props: Props) => { size='1rem' /> - { t('ProjectModel.accesses.message', { name: props.item.name }) } + { t('ProjectModel.accesses.message', { name: item.name }) } { label: t('ProjectModel.accesses.columns.name'), resolve: (projectModelAccess) => projectModelAccess.project?.name }]} - items={props.item.project_model_accesses} - onDelete={props.onDeleteChildAssociation.bind(this, 'project_model_accesses')} - onSave={props.onSaveChildAssociation.bind(this, 'project_model_accesses')} + items={item.project_model_accesses} + onDelete={editPageProps.onDeleteChildAssociation.bind(this, 'project_model_accesses')} + onSave={editPageProps.onSaveChildAssociation.bind(this, 'project_model_accesses')} /> { accessModal && ( { onLoad={(params) => ProjectsService.fetchAll({ ...params, discoverable: true, - project_id: props.item.project_id, + project_id: item.project_id, sort_by: 'name' })} onSave={(projects) => { - const find = (project) => _.findWhere(props.item.project_model_accesses, { project_id: project.id }); + const find = (project) => _.findWhere(item.project_model_accesses, { project_id: project.id }); const create = (project) => ({ uid: uuid(), project_id: project.id, project }); - props.onMultiAddChildAssociations( + editPageProps.onMultiAddChildAssociations( 'project_model_accesses', _.map(projects, (project) => find(project) || create(project)) ); @@ -310,7 +329,7 @@ const ProjectModelForm = (props: Props) => { setAccessModal(false); }} renderItem={(project) => project.name} - selectedItems={_.pluck(props.item.project_model_accesses, 'project')} + selectedItems={_.pluck(item.project_model_accesses, 'project')} title={t('ProjectModel.accesses.title')} width='60%' /> @@ -332,7 +351,7 @@ const ProjectModelForm = (props: Props) => { size='1rem' /> - { t('ProjectModel.shares.message', { name: props.item.name }) } + { t('ProjectModel.shares.message', { name: item.name }) } { label: t('ProjectModel.shares.columns.modelName'), resolve: (projectModelShare) => projectModelShare.project_model_access?.project_model?.name }]} - items={props.item.project_model_shares} - onDelete={props.onDeleteChildAssociation.bind(this, 'project_model_shares')} - onSave={props.onSaveChildAssociation.bind(this, 'project_model_shares')} + items={item.project_model_shares} + onDelete={editPageProps.onDeleteChildAssociation.bind(this, 'project_model_shares')} + onSave={editPageProps.onSaveChildAssociation.bind(this, 'project_model_shares')} /> { shareModal && ( { onClose={() => setShareModal(false)} onLoad={(params) => ProjectModelAccessesService.fetchAll({ ...params, - project_id: props.item.project_id, - model_class: props.item.model_class, + project_id: item.project_id, + model_class: item.model_class, sort_by: [ 'core_data_connector_projects.name', 'core_data_connector_project_models.name' ] })} onSave={(projectModelAccesses) => { - const find = (projectModelAccess) => _.findWhere(props.item.project_model_shares, { + const find = (projectModelAccess) => _.findWhere(item.project_model_shares, { project_model_access_id: projectModelAccess.id }); @@ -382,7 +401,7 @@ const ProjectModelForm = (props: Props) => { project_model_access: projectModelAccess }); - props.onMultiAddChildAssociations( + editPageProps.onMultiAddChildAssociations( 'project_model_shares', _.map( projectModelAccesses, @@ -398,7 +417,7 @@ const ProjectModelForm = (props: Props) => { subheader={projectModelAccess.project_model.name} /> )} - selectedItems={_.pluck(props.item.project_model_shares, 'project_model_access')} + selectedItems={_.pluck(item.project_model_shares, 'project_model_access')} title={t('ProjectModel.shares.title')} width='60%' /> @@ -410,20 +429,4 @@ const ProjectModelForm = (props: Props) => { ); }; -const ProjectModel = withReactRouterEditPage(ProjectModelForm, { - id: 'projectModelId', - onInitialize: (id) => ( - ProjectModelsService - .fetchOne(id) - .then(({ data }) => data.project_model) - ), - onSave: (projectModel) => ( - ProjectModelsService - .save(projectModel) - .then(({ data }) => data.project_model) - ), - required: ['model_class', 'name'], - resolveValidationError: ProjectModelsUtils.resolveValidationError.bind(this) -}); - export default ProjectModel; diff --git a/client/src/pages/User.js b/client/src/pages/User.js index 22d6370e..c87563f0 100644 --- a/client/src/pages/User.js +++ b/client/src/pages/User.js @@ -13,17 +13,40 @@ import UserPassword from '../components/UserPassword'; import UserUtils from '../utils/User'; import UsersService from '../services/Users'; import { useTranslation } from 'react-i18next'; -import withReactRouterEditPage from '../hooks/ReactRouterEditPage'; +import useReactRouterEditPage from '../hooks/useReactRouterEditPage'; type Props = EditContainerProps & { item: UserType, isNew?: boolean }; -const UserFormComponent = (props: Props) => { +const User = (props: Props) => { const { t } = useTranslation(); const { canEditUsers, isSSO } = usePermissions(); - const isNew = props.isNew || !props.item.id; + + const editPageProps = useReactRouterEditPage({ + id: 'userId', + onInitialize: (id) => ( + UsersService + .fetchOne(id) + .then(({ data }) => data.user) + ), + onSave: (user) => ( + UsersService + .save(user) + .then(({ data }) => data.user) + ), + required: ['name', 'email', 'role'], + validate: (user) => { + if (user.id && (user.password || user.password_confirmation)) { + return UserUtils.validatePassword(user); + } + return null; + } + }); + + const { item } = editPageProps; + const isNew = props.isNew || !item.id; if (!canEditUsers()) { return ; @@ -36,22 +59,25 @@ const UserFormComponent = (props: Props) => { label: t('User.labels.allUsers'), url: '/users' }} - name={isNew ? t('User.labels.inviteUser') : props.item.name} + name={isNew ? t('User.labels.inviteUser') : item.name} /> { !isNew && !isSSO() && ( )} @@ -60,25 +86,4 @@ const UserFormComponent = (props: Props) => { ); }; -const User: AbstractComponent = withReactRouterEditPage(UserFormComponent, { - id: 'userId', - onInitialize: (id) => ( - UsersService - .fetchOne(id) - .then(({ data }) => data.user) - ), - onSave: (user) => ( - UsersService - .save(user) - .then(({ data }) => data.user) - ), - required: ['name', 'email', 'role'], - validate: (user) => { - if (user.id && (user.password || user.password_confirmation)) { - return UserUtils.validatePassword(user); - } - return null; - } -}); - export default User; diff --git a/client/src/pages/UserProject.js b/client/src/pages/UserProject.js index 916ae76f..c9cc0680 100644 --- a/client/src/pages/UserProject.js +++ b/client/src/pages/UserProject.js @@ -25,14 +25,14 @@ import UserProjectsService from '../services/UserProjects'; import UsersService from '../services/Users'; import useParams from '../hooks/ParsedParams'; import Validation from '../utils/Validation'; -import withReactRouterEditPage from '../hooks/ReactRouterEditPage'; +import useReactRouterEditPage from '../hooks/useReactRouterEditPage'; import { AuthenticationContext } from '../context/Authentication'; type Props = EditContainerProps & { item: UserProjectType }; -const UserProjectForm = (props: Props) => { +const UserProject = (props: Props) => { const params = useParams(); const { t } = useTranslation(); const { @@ -42,19 +42,37 @@ const UserProjectForm = (props: Props) => { } = usePermissions(); const { provider } = useContext(AuthenticationContext); + const editPageProps = useReactRouterEditPage({ + id: 'userProjectId', + onInitialize: (id) => ( + UserProjectsService + .fetchOne(id) + .then(({ data }) => data.user_project) + ), + onSave: (userProject) => ( + UserProjectsService + .save(userProject) + .then(({ data }) => data.user_project) + ), + required: ['project_id', 'role'], + resolveValidationError: Validation.resolveUpdateError + }); + + const { item, onSetState } = editPageProps; + /** * Memo-izes whether we're on a new record. * * @type {boolean} */ - const isNew = useMemo(() => !props.item.id, [props.item.id]); + const isNew = useMemo(() => !item.id, [item.id]); /** * Memo-izes if the current user is an owner of the current project. * * @type {boolean} */ - const isOwner = useMemo(() => isOwnerPermission(props.item.project_id), [isOwnerPermission, props.item.project_id]); + const isOwner = useMemo(() => isOwnerPermission(item.project_id), [isOwnerPermission, item.project_id]); /** * Callback fired when the project search is executed. @@ -74,11 +92,11 @@ const UserProjectForm = (props: Props) => { * For a new record, set the foreign key ID based on the route parameters. */ useEffect(() => { - if (!props.item.id) { + if (!item.id) { if (params.projectId) { - props.onSetState({ project_id: params.projectId }); + onSetState({ project_id: params.projectId }); } else if (params.userId) { - props.onSetState({ user_id: params.userId }); + onSetState({ user_id: params.userId }); } } }, []); @@ -123,7 +141,7 @@ const UserProjectForm = (props: Props) => { label: t('UserProject.labels.allUsers'), url: `/projects/${params.projectId}/user_projects` }} - name={props.item.user?.name} + name={item.user?.name} /> )} { params.userId && ( @@ -132,36 +150,37 @@ const UserProjectForm = (props: Props) => { label: t('UserProject.labels.allProjects'), url: `/users/${params.userId}/user_projects` }} - name={props.item.project?.name} + name={item.project?.name} /> )} { canEditUsers() && params.userId && ( Project.toDropdown(project)} - searchQuery={props.item.project?.name} - value={props.item.project_id} + searchQuery={item.project?.name} + value={item.project_id} /> )} { canEditUsers() && params.projectId && ( @@ -169,28 +188,29 @@ const UserProjectForm = (props: Props) => { collectionName='users' modal={modal} onSearch={onUserSearch} - onSelection={props.onAssociationInputChange.bind(this, 'user_id', 'user')} + onSelection={editPageProps.onAssociationInputChange.bind(this, 'user_id', 'user')} renderOption={(user) => User.toDropdown(user)} - searchQuery={props.item.user?.name} - value={props.item.user_id} + searchQuery={item.user?.name} + value={item.user_id} /> )} { !canEditUsers() && isOwner && isNew && ( )} @@ -199,20 +219,4 @@ const UserProjectForm = (props: Props) => { ); }; -const UserProject: AbstractComponent = withReactRouterEditPage(UserProjectForm, { - id: 'userProjectId', - onInitialize: (id) => ( - UserProjectsService - .fetchOne(id) - .then(({ data }) => data.user_project) - ), - onSave: (userProject) => ( - UserProjectsService - .save(userProject) - .then(({ data }) => data.user_project) - ), - required: ['project_id', 'role'], - resolveValidationError: Validation.resolveUpdateError.bind(this) -}); - export default UserProject; diff --git a/client/src/pages/WebAuthority.js b/client/src/pages/WebAuthority.js index 3f5a4f26..ecd32955 100644 --- a/client/src/pages/WebAuthority.js +++ b/client/src/pages/WebAuthority.js @@ -18,44 +18,63 @@ import Validation from '../utils/Validation'; import type { WebAuthority as WebAuthorityType } from '../types/WebAuthority'; import WebAuthoritiesService from '../services/WebAuthorities'; import WebAuthorityUtils from '../utils/WebAuthorities'; -import withReactRouterEditPage from '../hooks/ReactRouterEditPage'; +import useReactRouterEditPage from '../hooks/useReactRouterEditPage'; type Props = EditContainerProps & { item: WebAuthorityType }; -const WebAuthorityPage = (props: Props) => { +const WebAuthority = (props: Props) => { const { projectId } = useParams(); const { t } = useTranslation(); const { canEditProjectSettings } = usePermissions(); + const editPageProps = useReactRouterEditPage({ + id: 'webAuthorityId', + onInitialize: (id) => ( + WebAuthoritiesService + .fetchOne(id) + .then(({ data }) => data.web_authority) + ), + onSave: (authority) => ( + WebAuthoritiesService + .save(authority) + .then(({ data }) => data.web_authority) + ), + required: ['source_type'], + resolveValidationError: Validation.resolveUpdateError, + validate: WebAuthorityUtils.validate + }); + + const { item, onSetState } = editPageProps; + /** * Sets the passed value/key in the access JSON. * * @type {(function(*, *): void)|*} */ const onChange = useCallback((key, value) => { - props.onSetState({ + onSetState({ access: { - ...props.item.access || {}, + ...item.access || {}, [key]: value } }); - }, [props.onSetState, props.item.access]); + }, [onSetState, item.access]); /** * Clear "access" on source_type change */ useEffect(() => { - props.onSetState({ access: {} }); - }, [props.item.source_type]); + onSetState({ access: {} }); + }, [item.source_type]); /** * Set the project_id on the state for new records. */ useEffect(() => { - if (!props.item.id && projectId) { - props.onSetState({ project_id: projectId }); + if (!item.id && projectId) { + onSetState({ project_id: projectId }); } }, []); @@ -71,46 +90,47 @@ const WebAuthorityPage = (props: Props) => { label: t('WebAuthority.labels.all'), url: `/projects/${projectId}/web_authorities` }} - name={WebAuthorityUtils.getSourceView(props.item)} + name={WebAuthorityUtils.getSourceView(item)} /> - { props.item.source_type === WebAuthorityUtils.SourceTypes.atom && ( + { item.source_type === WebAuthorityUtils.SourceTypes.atom && ( )} - { props.item.source_type === WebAuthorityUtils.SourceTypes.geonames && ( + { item.source_type === WebAuthorityUtils.SourceTypes.geonames && ( )} - { props.item.source_type === WebAuthorityUtils.SourceTypes.dpla && ( + { item.source_type === WebAuthorityUtils.SourceTypes.dpla && ( )} @@ -120,21 +140,4 @@ const WebAuthorityPage = (props: Props) => { ); }; -const WebAuthority = withReactRouterEditPage(WebAuthorityPage, { - id: 'webAuthorityId', - onInitialize: (id) => ( - WebAuthoritiesService - .fetchOne(id) - .then(({ data }) => data.web_authority) - ), - onSave: (authority) => ( - WebAuthoritiesService - .save(authority) - .then(({ data }) => data.web_authority) - ), - required: ['source_type'], - resolveValidationError: Validation.resolveUpdateError.bind(this), - validate: WebAuthorityUtils.validate.bind(this) -}); - export default WebAuthority; diff --git a/client/yarn.lock b/client/yarn.lock index e574ea0e..529be76b 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1099,18 +1099,18 @@ flow-remove-types "^2.158.0" rollup-pluginutils "^2.8.2" -"@clerk/react@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/@clerk/react/-/react-6.0.1.tgz#75cc83f6c58f201d4492bdbaa24e22f9e1a3190e" - integrity sha512-zq6vfH7Yiul4PPxUmyl/iW6CZbIzxfHvhXLxZK9zbqHQEy9xDlIQirhWIiHucBqMWTJruudY5KCV5WBe2xmfww== +"@clerk/react@^6.3.0": + version "6.3.0" + resolved "https://registry.yarnpkg.com/@clerk/react/-/react-6.3.0.tgz#af146c18fe777e904dc84dc99ee555ccb7a13ccb" + integrity sha512-etqEqdP5WlVn1Bb1NF2Drgvn3UzBSXmrkKtluObTQeqOoCsTO0uFv40oDi7QBs9WQWY1tvavI5aIHzY+uHKcdw== dependencies: - "@clerk/shared" "^4.0.0" + "@clerk/shared" "^4.7.0" tslib "2.8.1" -"@clerk/shared@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@clerk/shared/-/shared-4.0.0.tgz#077f3e7bd17b290fddb5e7c3710f3a7227f13291" - integrity sha512-Z3QhVud7FM9SBgSGxyUdC+nDg6vro+5zJ5gDO1To3FDzRLWKW4xIGd5y8UBqWZMMMHWaSDiZvYlUynb+gs8PnQ== +"@clerk/shared@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@clerk/shared/-/shared-4.7.0.tgz#b442a734d937fcc6d89441e0df4e741a103ac4bd" + integrity sha512-pm2dpxHS2teY87jmpatprG2uBAuuXuHHWvuezL3a5pRoUiIWXgWlLvwRZRgKXwDeIkIT9UCAIQBkcjueSEzqHA== dependencies: "@tanstack/query-core" "5.90.16" dequal "2.0.3"