diff --git a/src/common/api/narrativeService.ts b/src/common/api/narrativeService.ts new file mode 100644 index 00000000..7eef32df --- /dev/null +++ b/src/common/api/narrativeService.ts @@ -0,0 +1,70 @@ +/* narrativeService */ +import { dynamicService } from './serviceWizardApi'; +import { baseApi } from './index'; + +const narrativeService = dynamicService({ + name: 'NarrativeService', + release: 'release', +}); + +export interface NarrativeServiceParams { + copyNarrative: { nameNew: string; workspaceRef: string; workspaceId: number }; + getStatus: void; + renameNarrative: { nameNew: string; narrativeRef: string }; + restoreNarrative: { objId: number; version: number; wsId: number }; +} + +interface NarrativeServiceResults { + copyNarrative: unknown; + getStatus: { state: string }[]; + renameNarrative: unknown; + restoreNarrative: unknown; +} + +export const narrativeServiceApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + copyNarrative: builder.mutation< + NarrativeServiceResults['copyNarrative'], + NarrativeServiceParams['copyNarrative'] + >({ + query: ({ nameNew, workspaceRef, workspaceId }) => + narrativeService({ + method: 'NarrativeService.copy_narrative', + params: [{ newName: nameNew, workspaceRef, workspaceId }], + }), + }), + getStatus: builder.query< + NarrativeServiceResults['getStatus'], + NarrativeServiceParams['getStatus'] + >({ + query: () => + narrativeService({ + method: 'NarrativeService.status', + params: [], + }), + }), + renameNarrative: builder.mutation< + NarrativeServiceResults['renameNarrative'], + NarrativeServiceParams['renameNarrative'] + >({ + query: ({ narrativeRef, nameNew }) => + narrativeService({ + method: 'NarrativeService.rename_narrative', + params: [{ narrative_ref: narrativeRef, new_name: nameNew }], + }), + }), + restoreNarrative: builder.mutation< + NarrativeServiceResults['restoreNarrative'], + NarrativeServiceParams['restoreNarrative'] + >({ + query: ({ objId, version, wsId }) => + narrativeService({ + method: 'NarrativeService.revert_narrative_object', + params: [{ ver: version, objid: objId, wsid: wsId }], + }), + }), + }), +}); + +export const { copyNarrative, getStatus, renameNarrative, restoreNarrative } = + narrativeServiceApi.endpoints; diff --git a/src/common/api/orgsApi.ts b/src/common/api/orgsApi.ts index 46f3006c..9282e07e 100644 --- a/src/common/api/orgsApi.ts +++ b/src/common/api/orgsApi.ts @@ -23,11 +23,13 @@ export interface OrgMemberInfo { export interface OrgsParams { getNarrativeOrgs: number; getUserOrgs: void; + linkNarrative: { orgId: string; wsId: number }; } export interface OrgsResults { getNarrativeOrgs: OrgInfo[]; getUserOrgs: OrgMemberInfo[]; + linkNarrative: unknown; } export const orgsApi = baseApi @@ -56,8 +58,19 @@ export const orgsApi = baseApi }), providesTags: ['Orgs'], }), + linkNarrative: builder.mutation< + OrgsResults['linkNarrative'], + OrgsParams['linkNarrative'] + >({ + query: ({ orgId, wsId }) => + orgsService({ + method: 'POST', + url: `group/${orgId}/resource/workspace/${wsId}`, + }), + }), }), }); -export const { getNarrativeOrgs, getUserOrgs } = orgsApi.endpoints; +export const { getNarrativeOrgs, getUserOrgs, linkNarrative } = + orgsApi.endpoints; export const clearCacheAction = orgsApi.util.invalidateTags(['Orgs']); diff --git a/src/common/api/serviceWizardApi.ts b/src/common/api/serviceWizardApi.ts index 5110059a..35c276ed 100644 --- a/src/common/api/serviceWizardApi.ts +++ b/src/common/api/serviceWizardApi.ts @@ -1,34 +1,40 @@ +/* serviceWizardApi */ import { baseApi } from './index'; import { setConsumedService } from './utils/kbaseBaseQuery'; import { jsonRpcService } from './utils/serviceHelpers'; const serviceWizard = jsonRpcService({ url: 'services/service_wizard' }); +const dynamicService = jsonRpcService; + +/* Use this for dynamic services to ensure serviceWizardApi is set. */ +export { dynamicService }; + +interface ServiceStatus { + git_commit_hash: string; + status: string; + version: string; + hash: string; + release_tags: string[]; + url: string; + module_name: string; + health: string; + up: number; +} + interface ServiceWizardParams { - serviceStatus: { module_name: string; version: string }; + getServiceStatus: { module_name: string; version: string }; } interface ServiceWizardResults { - serviceStatus: [ - { - git_commit_hash: string; - status: string; - version: string; - hash: string; - release_tags: string[]; - url: string; - module_name: string; - health: string; - up: number; - } - ]; + getServiceStatus: ServiceStatus[]; } export const serviceWizardApi = baseApi.injectEndpoints({ endpoints: (builder) => ({ - serviceStatus: builder.query< - ServiceWizardResults['serviceStatus'], - ServiceWizardParams['serviceStatus'] + getServiceStatus: builder.query< + ServiceWizardResults['getServiceStatus'], + ServiceWizardParams['getServiceStatus'] >({ query: ({ module_name, version }) => serviceWizard({ @@ -42,4 +48,4 @@ export const serviceWizardApi = baseApi.injectEndpoints({ setConsumedService('serviceWizardApi', serviceWizardApi); -export const { serviceStatus } = serviceWizardApi.endpoints; +export const { getServiceStatus } = serviceWizardApi.endpoints; diff --git a/src/common/api/utils/common.ts b/src/common/api/utils/common.ts new file mode 100644 index 00000000..860baef2 --- /dev/null +++ b/src/common/api/utils/common.ts @@ -0,0 +1,67 @@ +/* api/utils/common */ +import { FetchBaseQueryError } from '@reduxjs/toolkit/query/react'; +/* +JSONRPC Specification details +JSON-RPC 1.0 - https://www.jsonrpc.org/specification_v1 +JSON-RPC 1.1 wd - https://jsonrpc.org/historical/json-rpc-1-1-wd.html +JSON-RPC 2.0 - https://www.jsonrpc.org/specification +- id + - 2.0 allows id to be string, number (with no fractional part) or null + - 1.1 allows id to be "any JSON type" +- version + - a string in both JSONRPC 1.1 and 2.0. +*/ +// KBase mostly uses strings, or string serializable values, so we can too. +type JsonRpcError = { + version: '1.1'; + id: string; + error: { + name: string; + code: number; + message: string; + }; +}; + +export type KBaseBaseQueryError = + | FetchBaseQueryError + | { + status: 'JSONRPC_ERROR'; + data: JsonRpcError; + }; + +export const isJsonRpcError = (obj: unknown): obj is JsonRpcError => { + if ( + typeof obj === 'object' && + obj !== null && + ['version', 'error', 'id'].every((k) => k in obj) + ) { + const { version, error } = obj as { version: string; error: unknown }; + const versionsSupported = new Set(['1.1', '2.0']); + if (!versionsSupported.has(version)) return false; + if ( + typeof error === 'object' && + error !== null && + ['name', 'code', 'message'].every((k) => k in error) + ) { + return true; + } + } + return false; +}; + +/** + * Type predicate to narrow an unknown error to `FetchBaseQueryError` + */ +export function isFetchBaseQueryError( + error: unknown +): error is FetchBaseQueryError { + return typeof error === 'object' && error !== null && 'status' in error; +} + +export const isKBaseBaseQueryError = ( + error: unknown +): error is KBaseBaseQueryError => { + const fbq = isFetchBaseQueryError(error); + const condition = fbq && isJsonRpcError(error.data); + return condition; +}; diff --git a/src/common/api/utils/kbaseBaseQuery.ts b/src/common/api/utils/kbaseBaseQuery.ts index c1f8b30d..206ca8d8 100644 --- a/src/common/api/utils/kbaseBaseQuery.ts +++ b/src/common/api/utils/kbaseBaseQuery.ts @@ -4,10 +4,10 @@ import { BaseQueryFn, FetchArgs, fetchBaseQuery, - FetchBaseQueryError, } from '@reduxjs/toolkit/query/react'; import { RootState } from '../../../app/store'; import { serviceWizardApi } from '../serviceWizardApi'; +import { KBaseBaseQueryError, isJsonRpcError } from './common'; export interface DynamicService { name: string; @@ -39,72 +39,6 @@ export const isDynamic = ( return (service as StaticService).url === undefined; }; -/* -JSONRPC Specification details -JSON-RPC 1.0 - https://www.jsonrpc.org/specification_v1 -JSON-RPC 1.1 wd - https://jsonrpc.org/historical/json-rpc-1-1-wd.html -JSON-RPC 2.0 - https://www.jsonrpc.org/specification -- id - - 2.0 allows id to be string, number (with no fractional part) or null - - 1.1 allows id to be "any JSON type" -- version - - a string in both JSONRPC 1.1 and 2.0. -*/ -// KBase mostly uses strings, or string serializable values, so we can too. -type JsonRpcError = { - version: '1.1'; - id: string; - error: { - name: string; - code: number; - message: string; - }; -}; - -export const isJsonRpcError = (obj: unknown): obj is JsonRpcError => { - if ( - typeof obj === 'object' && - obj !== null && - ['version', 'error', 'id'].every((k) => k in obj) - ) { - const { version, error } = obj as { version: string; error: unknown }; - const versionsSupported = new Set(['1.1', '2.0']); - if (!versionsSupported.has(version)) return false; - if ( - typeof error === 'object' && - error !== null && - ['name', 'code', 'message'].every((k) => k in error) - ) { - return true; - } - } - return false; -}; - -export type KBaseBaseQueryError = - | FetchBaseQueryError - | { - status: 'JSONRPC_ERROR'; - data: JsonRpcError; - }; - -/** - * Type predicate to narrow an unknown error to `FetchBaseQueryError` - */ -export function isFetchBaseQueryError( - error: unknown -): error is FetchBaseQueryError { - return typeof error === 'object' && error !== null && 'status' in error; -} - -export const isKBaseBaseQueryError = ( - error: unknown -): error is KBaseBaseQueryError => { - const fbq = isFetchBaseQueryError(error); - const condition = fbq && isJsonRpcError(error.data); - return condition; -}; - // These helpers let us avoid circular dependencies when using an API endpoint within kbaseBaseQuery const consumedServices: { serviceWizardApi?: typeof serviceWizardApi } = {}; export const setConsumedService = ( @@ -132,7 +66,7 @@ const getServiceUrl = async ( // get serviceWizardApi while avoiding circular imports // (as serviceWizardApi imports this file) const serviceStatusQuery = - getConsumedService('serviceWizardApi').endpoints.serviceStatus; + getConsumedService('serviceWizardApi').endpoints.getServiceStatus; const wizardQueryArgs = { module_name: name, diff --git a/src/common/api/utils/parseError.test.ts b/src/common/api/utils/parseError.test.ts index fe2cf83c..cb7a7687 100644 --- a/src/common/api/utils/parseError.test.ts +++ b/src/common/api/utils/parseError.test.ts @@ -1,6 +1,6 @@ import { SerializedError } from '@reduxjs/toolkit'; import { FetchBaseQueryError } from '@reduxjs/toolkit/dist/query'; -import { KBaseBaseQueryError } from './kbaseBaseQuery'; +import { KBaseBaseQueryError } from './common'; import { parseError } from './parseError'; describe('parseError', () => { diff --git a/src/common/api/utils/parseError.ts b/src/common/api/utils/parseError.ts index f88e9e18..ad7a994b 100644 --- a/src/common/api/utils/parseError.ts +++ b/src/common/api/utils/parseError.ts @@ -1,5 +1,5 @@ import { SerializedError } from '@reduxjs/toolkit'; -import { KBaseBaseQueryError } from './kbaseBaseQuery'; +import { KBaseBaseQueryError } from './common'; export function parseError(error: KBaseBaseQueryError | SerializedError): { error: KBaseBaseQueryError | SerializedError; diff --git a/src/common/api/workspaceApi.ts b/src/common/api/workspaceApi.ts index 63c809cb..7e9790d3 100644 --- a/src/common/api/workspaceApi.ts +++ b/src/common/api/workspaceApi.ts @@ -27,7 +27,7 @@ type TimeParams = ( ); interface wsParams { - deleteWorkspace: { reqId?: number | string; wsId: number }; + deleteWorkspace: { wsId: number }; getwsNarrative: { upa: string }; getwsObjectByName: { upa: string }; getwsPermissions: { wsId: number }; @@ -103,7 +103,7 @@ const wsApi = baseApi.injectEndpoints({ wsResults['deleteWorkspace'], wsParams['deleteWorkspace'] >({ - query: ({ wsId, reqId }) => + query: ({ wsId }) => ws({ method: 'Workspace.delete_workspace', params: [{ id: wsId }], diff --git a/src/common/components/Input.tsx b/src/common/components/Input.tsx index a8609b29..8fd50a8e 100644 --- a/src/common/components/Input.tsx +++ b/src/common/components/Input.tsx @@ -10,14 +10,15 @@ import { v4 as uuidv4 } from 'uuid'; import classes from './Input.module.scss'; interface InputInterface extends ComponentProps<'input'> { - label?: ReactElement; errors?: boolean; + label?: ReactElement; + maxLength?: number; validated?: boolean; } export const Input = forwardRef( (props, ref) => { - const { className, errors, validated, label, ...rest } = props; + const { className, errors, label, maxLength, validated, ...rest } = props; const { name } = rest; // react-hook-form internals const idForLabel = useMemo(() => `input-${uuidv4()}`, []); const statusClass = errors ? classes.error : classes.success; @@ -60,9 +61,10 @@ export const Input = forwardRef( {...rest} className={classes.input} id={idForLabel} - type={'text'} - onFocus={handleFocus} + maxLength={maxLength} onBlur={handleBlur} + onFocus={handleFocus} + type={'text'} /> ); diff --git a/src/features/navigator/NarrativeControl/Copy.tsx b/src/features/navigator/NarrativeControl/Copy.tsx index 22bb4456..6ef8426e 100644 --- a/src/features/navigator/NarrativeControl/Copy.tsx +++ b/src/features/navigator/NarrativeControl/Copy.tsx @@ -1,6 +1,10 @@ /* NarrativeControl/Copy */ import { FC } from 'react'; import { useForm } from 'react-hook-form'; +import toast from 'react-hot-toast'; +import { isKBaseBaseQueryError } from '../../../common/api/utils/common'; +import { parseError } from '../../../common/api/utils/parseError'; +import { copyNarrative } from '../../../common/api/narrativeService'; import { Button } from '../../../common/components'; import { inputRegisterFactory, @@ -8,9 +12,8 @@ import { } from '../../../common/components/Input.common'; import { Input } from '../../../common/components/Input'; import { useAppDispatch } from '../../../common/hooks'; -import { TODOAddLoadingState } from '../common'; -import { copyNarrative } from '../navigatorSlice'; -import { ControlProps } from './common'; +import { copyNarrative as copyAction, setLoading } from '../navigatorSlice'; +import { ControlProps, ErrorMessage } from './common'; export interface CopyValues { narrativeCopyName: string; @@ -21,6 +24,7 @@ export interface CopyProps extends ControlProps { } export const Copy: FC = ({ narrativeDoc, modalClose, version }) => { + /* hooks */ const dispatch = useAppDispatch(); const { formState, getValues, register } = useForm({ defaultValues: { @@ -28,16 +32,42 @@ export const Copy: FC = ({ narrativeDoc, modalClose, version }) => { }, mode: 'all', }); + const [copyTrigger] = copyNarrative.useMutation(); + /* derived values */ const inputRegister = inputRegisterFactory({ formState, register, }); + const { access_group: wsId, obj_id: objId } = narrativeDoc; + const errors = formState.errors; + const errorEntries = Object.entries(errors); + const formInvalid = errorEntries.length > 0; + /* copy narrative callback */ const copyNarrativeHandler = async () => { const { narrativeCopyName: name } = getValues(); - await TODOAddLoadingState(); - dispatch(copyNarrative({ wsId: narrativeDoc.access_group, name, version })); + const message = `Copy ${wsId}/${objId}/${version} as ${name}.`; modalClose(); + dispatch(copyAction({ wsId: narrativeDoc.access_group, name, version })); + try { + await copyTrigger({ + nameNew: name, + workspaceRef: `${wsId}/${objId}/${version}`, + workspaceId: wsId, + }).unwrap(); + dispatch(setLoading(false)); + } catch (err) { + if (!isKBaseBaseQueryError(err)) { + console.error({ err }); // eslint-disable-line no-console + toast(ErrorMessage({ err })); + return; + } + toast(ErrorMessage({ err: parseError(err) })); + dispatch(setLoading(false)); + return; + } + toast(message); }; + /* Copy component */ return ( <>

@@ -47,16 +77,31 @@ export const Copy: FC = ({ narrativeDoc, modalClose, version }) => {

Enter a name for the new Narrative.

+ {formInvalid ? ( + <> + Errors: +
    + {Object.entries(errors).map(([name, err]) => ( +
  • {err.message}
  • + ))} +
+ + ) : ( + <> + )} New Narrative Title} + maxLength={MAX_WS_METADATA_VALUE_SIZE} {...inputRegister('narrativeCopyName', { maxLength: { value: MAX_WS_METADATA_VALUE_SIZE, - message: 'too long', + message: 'The selected name is too long.', }, })} /> - +
diff --git a/src/features/navigator/NarrativeControl/Delete.tsx b/src/features/navigator/NarrativeControl/Delete.tsx index e7330958..76f5cf39 100644 --- a/src/features/navigator/NarrativeControl/Delete.tsx +++ b/src/features/navigator/NarrativeControl/Delete.tsx @@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom'; import toast from 'react-hot-toast'; import { Button } from '../../../common/components'; import { useAppDispatch, useAppSelector } from '../../../common/hooks'; -import { isKBaseBaseQueryError } from '../../../common/api/utils/kbaseBaseQuery'; +import { isKBaseBaseQueryError } from '../../../common/api/utils/common'; import { parseError } from '../../../common/api/utils/parseError'; import { deleteWorkspace } from '../../../common/api/workspaceApi'; import { @@ -12,30 +12,26 @@ import { getParams, } from '../../../features/params/paramsSlice'; import { deleteNarrative, loading, setLoading } from '../navigatorSlice'; -import { ControlProps } from './common'; - -const ErrorMessage: FC<{ err: unknown }> = ({ err }) => ( - <> - There was an error! Guru meditation: - {JSON.stringify(err)} - -); +import { ControlProps, ErrorMessage } from './common'; export const Delete: FC = ({ narrativeDoc, modalClose }) => { + /* hooks */ const dispatch = useAppDispatch(); const loadState = useAppSelector(loading); const params = useAppSelector(getParams); const navigate = useNavigate(); const [userConfirmation, setUserConfirmation] = useState(false); const [deleteTrigger] = deleteWorkspace.useMutation(); - - const wsId = narrativeDoc.access_group; useEffect(() => { if (loadState) return; if (!userConfirmation) return; }); + /* derived values */ + const wsId = narrativeDoc.access_group; const message = `Deleted narrative ${wsId}.`; + + /* delete narrative callback */ const deleteNarrativeHandler = async () => { setUserConfirmation(true); modalClose(); @@ -56,6 +52,7 @@ export const Delete: FC = ({ narrativeDoc, modalClose }) => { toast(message); navigate(generatePathWithSearchParams('/narratives', params)); }; + /* Delete component */ return ( <>

Delete Narrative?

diff --git a/src/features/navigator/NarrativeControl/LinkOrg.tsx b/src/features/navigator/NarrativeControl/LinkOrg.tsx index 1cfbaf01..befd191a 100644 --- a/src/features/navigator/NarrativeControl/LinkOrg.tsx +++ b/src/features/navigator/NarrativeControl/LinkOrg.tsx @@ -2,20 +2,25 @@ import { FontAwesomeIcon as FAIcon } from '@fortawesome/react-fontawesome'; import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'; import { FC, useEffect, useId, useState } from 'react'; +import toast from 'react-hot-toast'; +import { isKBaseBaseQueryError } from '../../../common/api/utils/common'; +import { parseError } from '../../../common/api/utils/parseError'; import { getNarrativeOrgs, getUserOrgs, + linkNarrative, OrgInfo, } from '../../../common/api/orgsApi'; import { Button, Select } from '../../../common/components'; import { useAppDispatch, useAppSelector } from '../../../common/hooks'; import { NarrativeDoc } from '../../../common/types/NarrativeDoc'; -import { TODOAddLoadingState } from '../common'; import { - linkNarrative, + linkNarrative as linkAction, narrativeLinkedOrgs, setLinkedOrgs, + setLoading, } from '../navigatorSlice'; +import { ErrorMessage } from './common'; export interface OrgsValues { narrativeOrgs: string[]; @@ -25,6 +30,7 @@ export const LinkOrg: FC<{ narrativeDoc: NarrativeDoc; modalClose: () => void; }> = ({ narrativeDoc, modalClose }) => { + /* hooks */ const dispatch = useAppDispatch(); const [orgSelected, setOrgSelected] = useState(''); const narrativeOrgs = useAppSelector(narrativeLinkedOrgs); @@ -32,6 +38,7 @@ export const LinkOrg: FC<{ narrativeDoc.access_group ); const userOrgsQuery = getUserOrgs.useQuery(); + const [linkTrigger] = linkNarrative.useMutation(); useEffect(() => { if (narrativeOrgsQuery.isSuccess) { const narrativeOrgs = narrativeOrgsQuery.currentData; @@ -41,18 +48,37 @@ export const LinkOrg: FC<{ } }); const orgSelectId = useId(); + /* early exits */ if (!narrativeOrgsQuery.currentData) { return <>; } + /* derived values */ const narrativeOrgsIds = narrativeOrgs.map(({ id }) => id); + const { access_group: wsId } = narrativeDoc; + const message = `Link ${wsId} to ${orgSelected}.`; + /* link narrative callback factory */ const linkOrg = ({ orgSelected }: { orgSelected: string }) => async () => { - await TODOAddLoadingState(); - dispatch( - linkNarrative({ org: orgSelected, wsId: narrativeDoc.access_group }) - ); modalClose(); + dispatch(linkAction({ org: orgSelected, wsId })); + try { + await linkTrigger({ + orgId: orgSelected, + wsId, + }).unwrap(); + dispatch(setLoading(false)); + } catch (err) { + if (!isKBaseBaseQueryError(err)) { + console.error({ err }); // eslint-disable-line no-console + toast(ErrorMessage({ err })); + return; + } + toast(ErrorMessage({ err: parseError(err) })); + dispatch(setLoading(false)); + return; + } + toast(message); }; const orgsResults = userOrgsQuery.currentData ? userOrgsQuery.currentData @@ -60,6 +86,7 @@ export const LinkOrg: FC<{ const availableOrgs = orgsResults.filter( ({ id }) => narrativeOrgsIds.indexOf(id) === -1 ); + /* LinkOrg component */ return ( <>

Organizations

@@ -72,7 +99,6 @@ export const LinkOrg: FC<{ label: name, }))} onChange={(opts) => { - console.log({ value: opts[0].value }); // eslint-disable-line no-console const orgSelectedByUser = opts[0].value.toString(); setOrgSelected(orgSelectedByUser); }} diff --git a/src/features/navigator/NarrativeControl/NarrativeControl.test.tsx b/src/features/navigator/NarrativeControl/NarrativeControl.test.tsx index 68520f5e..2af67052 100644 --- a/src/features/navigator/NarrativeControl/NarrativeControl.test.tsx +++ b/src/features/navigator/NarrativeControl/NarrativeControl.test.tsx @@ -1,6 +1,7 @@ // Tests for import { render, screen } from '@testing-library/react'; +import { MemoryRouter as Router } from 'react-router-dom'; import { noOp } from '../../common'; import { testNarrativeDoc } from '../fixtures'; import { @@ -62,11 +63,13 @@ describe('The component...', () => { describe('The component...', () => { test('renders.', () => { const { container } = render( - + + + ); expect(container).toBeTruthy(); expect(screen.getByText('Reverting', { exact: false })).toBeInTheDocument(); diff --git a/src/features/navigator/NarrativeControl/Rename.tsx b/src/features/navigator/NarrativeControl/Rename.tsx index 9fc97a62..ad871261 100644 --- a/src/features/navigator/NarrativeControl/Rename.tsx +++ b/src/features/navigator/NarrativeControl/Rename.tsx @@ -1,6 +1,10 @@ /* NarrativeControl/Rename */ import { FC } from 'react'; import { useForm } from 'react-hook-form'; +import toast from 'react-hot-toast'; +import { isKBaseBaseQueryError } from '../../../common/api/utils/common'; +import { parseError } from '../../../common/api/utils/parseError'; +import { renameNarrative } from '../../../common/api/narrativeService'; import { Button } from '../../../common/components'; import { inputRegisterFactory, @@ -9,8 +13,8 @@ import { import { Input } from '../../../common/components/Input'; import { useAppDispatch } from '../../../common/hooks'; import { NarrativeDoc } from '../../../common/types/NarrativeDoc'; -import { TODOAddLoadingState } from '../common'; -import { renameNarrative } from '../navigatorSlice'; +import { renameNarrative as renameAction, setLoading } from '../navigatorSlice'; +import { ErrorMessage } from './common'; interface RenameValues { narrativeRenameName: string; @@ -20,6 +24,7 @@ export const Rename: FC<{ narrativeDoc: NarrativeDoc; modalClose: () => void; }> = ({ narrativeDoc, modalClose }) => { + /* hooks */ const dispatch = useAppDispatch(); const { formState, getValues, register } = useForm({ defaultValues: { @@ -27,32 +32,72 @@ export const Rename: FC<{ }, mode: 'all', }); + const [renameTrigger] = renameNarrative.useMutation(); + /* derived values */ + const { access_group: wsId, obj_id: objId } = narrativeDoc; const inputRegister = inputRegisterFactory({ formState, register, }); + const errors = formState.errors; + const errorEntries = Object.entries(errors); + const formInvalid = errorEntries.length > 0; + /* rename narrative callback */ const renameNarrativeHandler = async () => { const { narrativeRenameName: name } = getValues(); - await TODOAddLoadingState(); - dispatch(renameNarrative({ wsId: narrativeDoc.access_group, name })); + const message = `Rename ${wsId} to ${name}.`; modalClose(); + dispatch(renameAction({ wsId: narrativeDoc.access_group, name })); + try { + await renameTrigger({ + narrativeRef: `${wsId}/${objId}`, + nameNew: name, + }).unwrap(); + dispatch(setLoading(false)); + } catch (err) { + if (!isKBaseBaseQueryError(err)) { + console.error({ err }); // eslint-disable-line no-console + toast(ErrorMessage({ err })); + return; + } + toast(ErrorMessage({ err: parseError(err) })); + dispatch(setLoading(false)); + return; + } + toast(message); }; + /* Rename component */ return ( <>

Rename Narrative

Enter a new name for the Narrative:

+ {formInvalid ? ( + <> + Errors: +
    + {Object.entries(errors).map(([name, err]) => ( +
  • {err.message}
  • + ))} +
+ + ) : ( + <> + )} New Narrative Title} + maxLength={MAX_WS_METADATA_VALUE_SIZE} {...inputRegister('narrativeRenameName', { maxLength: { value: MAX_WS_METADATA_VALUE_SIZE, - message: 'too long', + message: 'The selected name is too long.', }, })} /> - +
diff --git a/src/features/navigator/NarrativeControl/Restore.tsx b/src/features/navigator/NarrativeControl/Restore.tsx index cdce8b37..055494c3 100644 --- a/src/features/navigator/NarrativeControl/Restore.tsx +++ b/src/features/navigator/NarrativeControl/Restore.tsx @@ -1,29 +1,71 @@ /* NarrativeControl/Restore */ import { FC } from 'react'; +import toast from 'react-hot-toast'; +import { useNavigate } from 'react-router-dom'; +import { restoreNarrative } from '../../../common/api/narrativeService'; +import { isKBaseBaseQueryError } from '../../../common/api/utils/common'; +import { parseError } from '../../../common/api/utils/parseError'; import { Button } from '../../../common/components'; -import { useAppDispatch } from '../../../common/hooks'; +import { useAppDispatch, useAppSelector } from '../../../common/hooks'; import { NarrativeDoc } from '../../../common/types/NarrativeDoc'; -import { TODOAddLoadingState } from '../common'; -import { restoreNarrative } from '../navigatorSlice'; +import { getParams } from '../../../features/params/paramsSlice'; +import { generateNavigatorPath } from '../common'; +import { categorySelected } from '../navigatorSlice'; +import { ErrorMessage } from './common'; +import { + restoreNarrative as restoreAction, + setLoading, +} from '../navigatorSlice'; export const Restore: FC<{ modalClose: () => void; narrativeDoc: NarrativeDoc; version: number; }> = ({ modalClose, narrativeDoc, version }) => { + /* hooks */ const dispatch = useAppDispatch(); - const wsId = narrativeDoc.access_group; + const navigate = useNavigate(); + const categorySet = useAppSelector(categorySelected); + const params = useAppSelector(getParams); + const [restoreTrigger] = restoreNarrative.useMutation(); + /* derived values */ + const categoryPath = categorySet !== 'own' ? categorySet : ''; + const { access_group: wsId, obj_id: objId } = narrativeDoc; + const message = `Restored version ${version} of object ${objId} in ${wsId}.`; + /* restore narrative callback */ const restoreHandler = async () => { - await TODOAddLoadingState(); - dispatch(restoreNarrative({ version, wsId })); modalClose(); + dispatch(restoreAction({ objId, version, wsId })); + try { + await restoreTrigger({ objId, version, wsId }).unwrap(); + dispatch(setLoading(false)); + } catch (err) { + if (!isKBaseBaseQueryError(err)) { + console.error({ err }); // eslint-disable-line no-console + toast(ErrorMessage({ err })); + return; + } + toast(ErrorMessage({ err: parseError(err) })); + dispatch(setLoading(false)); + return; + } + toast(message); + navigate( + generateNavigatorPath({ + id: wsId.toString(), + obj: objId.toString(), + ver: version.toString(), + categoryPath, + ...params, + }) + ); }; - + /* Restore component */ return ( <>

Reverting a narrative will create a new version identical to - {`v${narrativeDoc.version}`}. + {` v${version}`}.

diff --git a/src/features/navigator/NarrativeControl/common.tsx b/src/features/navigator/NarrativeControl/common.tsx index 87af533e..8dc1a1ae 100644 --- a/src/features/navigator/NarrativeControl/common.tsx +++ b/src/features/navigator/NarrativeControl/common.tsx @@ -1,6 +1,14 @@ +import { FC } from 'react'; import { NarrativeDoc } from '../../../common/types/NarrativeDoc'; export interface ControlProps { narrativeDoc: NarrativeDoc; modalClose: () => void; } + +export const ErrorMessage: FC<{ err: unknown }> = ({ err }) => ( + <> + There was an error! Guru meditation: + {JSON.stringify(err)} + +); diff --git a/src/features/navigator/Navigator.test.tsx b/src/features/navigator/Navigator.test.tsx index ee1c416c..252d9338 100644 --- a/src/features/navigator/Navigator.test.tsx +++ b/src/features/navigator/Navigator.test.tsx @@ -89,13 +89,17 @@ describe('The component...', () => { testStore.dispatch(baseApi.util.resetApiState()); }); - test('renders.', () => { - const { container } = render( - - - - - + test('renders.', async () => { + const { container } = await waitFor(() => + render( + + + + + + + + ) ); expect(container).toBeTruthy(); expect(container.querySelector('section.navigator')).toBeInTheDocument(); diff --git a/src/features/navigator/Navigator.tsx b/src/features/navigator/Navigator.tsx index 6ce6a1ac..6f604142 100644 --- a/src/features/navigator/Navigator.tsx +++ b/src/features/navigator/Navigator.tsx @@ -25,7 +25,10 @@ import { normalizeVersion, searchParams, } from './common'; -import { useNarratives } from './hooks'; +import { + useNarratives, + //useNarrativeServiceStatus // See below +} from './hooks'; import { loading, navigatorSelected, @@ -210,6 +213,10 @@ const Navigator: FC = () => { term: search, username, }); + /* This causes tests to hang which means there is probably a bug in the way + dynamic services are handled. + // useNarrativeServiceStatus(); + */ const items = useAppSelector(narrativeDocs); const narrativeSelected = getNarrativeSelected({ id, obj, verRaw, items }); // hooks that update state diff --git a/src/features/navigator/RefreshButton.tsx b/src/features/navigator/RefreshButton.tsx index 1a997afc..32ed7b82 100644 --- a/src/features/navigator/RefreshButton.tsx +++ b/src/features/navigator/RefreshButton.tsx @@ -11,36 +11,36 @@ import { setSynchronized, } from './navigatorSlice'; +const delaySeconds = AUTOMATIC_REFRESH_DELAY / 1000; const RefreshButton: FC = () => { - const [count, setCount] = useState(5); + const [count, setCount] = useState(delaySeconds); const dispatch = useAppDispatch(); const syncd = useAppSelector(synchronized); const syncdLast = useAppSelector(synchronizedLast); const refreshHandler = useCallback(() => { - dispatch(clearCacheAction); dispatch(setSynchronized(true)); - setCount(5); + dispatch(clearCacheAction); + setCount(delaySeconds); }, [dispatch]); useEffect(() => { const now = Date.now(); const age = now - syncdLast; - if (!syncd && age > AUTOMATIC_REFRESH_DELAY) { - if (count > 0) { - setCount(count - 1); - return; + if (!syncd) { + if (age < AUTOMATIC_REFRESH_DELAY) { + if (count > 0) { + setTimeout(() => { + if (syncd) return; + setCount(Math.max(0, count - 1)); + }, 1000); + return; + } } refreshHandler(); } }, [count, refreshHandler, syncd, syncdLast]); - if (!syncd) { - setTimeout(() => { - setCount(Math.max(0, count - 1)); - }, 1000); - } - return (