diff --git a/apps/form-admin-app/src/app/containers/FormDefinitionOverview.tsx b/apps/form-admin-app/src/app/containers/FormDefinitionOverview.tsx index 996001936..2d6a78ae5 100644 --- a/apps/form-admin-app/src/app/containers/FormDefinitionOverview.tsx +++ b/apps/form-admin-app/src/app/containers/FormDefinitionOverview.tsx @@ -10,6 +10,7 @@ import { GoASpacer, GoATable, } from '@abgov/react-components-new'; +import { RowSkeleton } from '@core-services/app-common'; import { FunctionComponent, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; @@ -29,7 +30,6 @@ import { import { ContentContainer } from '../components/ContentContainer'; import { PropertiesContainer } from '../components/PropertiesContainer'; import { ScheduleIntakeModal } from '../components/ScheduleIntakeModal'; -import { RowSkeleton } from '../components/RowSkeleton'; const OverviewLayout = styled.div` position: absolute; diff --git a/apps/form-admin-app/src/app/containers/FormDefinitions.tsx b/apps/form-admin-app/src/app/containers/FormDefinitions.tsx index 8488c57be..05c66a8d1 100644 --- a/apps/form-admin-app/src/app/containers/FormDefinitions.tsx +++ b/apps/form-admin-app/src/app/containers/FormDefinitions.tsx @@ -1,4 +1,5 @@ import { GoABadge, GoAButton, GoAButtonGroup, GoACallout, GoATable } from '@abgov/react-components-new'; +import { RowLoadMore, RowSkeleton } from '@core-services/app-common'; import { FunctionComponent, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { @@ -17,7 +18,6 @@ import { NavigateFunction, useNavigate } from 'react-router-dom'; import { AddTagModal } from '../components/AddTagModal'; import { SearchLayout } from '../components/SearchLayout'; import { ContentContainer } from '../components/ContentContainer'; -import { RowSkeleton } from '../components/RowSkeleton'; import { Tags } from './Tags'; import { TagSearchFilter } from './TagSearchFilter'; @@ -126,21 +126,12 @@ export const FormsDefinitions = () => { /> ))} - {next && ( - - - - dispatch(loadDefinitions({ after: next }))} - > - Load more - - - - - )} + dispatch(loadDefinitions({ after }))} + /> diff --git a/apps/form-admin-app/src/app/containers/FormSubmissions.tsx b/apps/form-admin-app/src/app/containers/FormSubmissions.tsx index 03647d183..147be7816 100644 --- a/apps/form-admin-app/src/app/containers/FormSubmissions.tsx +++ b/apps/form-admin-app/src/app/containers/FormSubmissions.tsx @@ -6,6 +6,7 @@ import { GoAFormItem, GoATable, } from '@abgov/react-components-new'; +import { RowLoadMore, RowSkeleton } from '@core-services/app-common'; import { useDispatch, useSelector } from 'react-redux'; import { FunctionComponent, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -32,7 +33,6 @@ import { DataValueCell } from '../components/DataValueCell'; import { ExportModal } from '../components/ExportModal'; import { SearchFormItemsContainer } from '../components/SearchFormItemsContainer'; import { DataValueCriteriaItem } from '../components/DataValueCriteriaItem'; -import { RowSkeleton } from '../components/RowSkeleton'; import { AddTagModal } from '../components/AddTagModal'; import { Tags } from './Tags'; import { TagSearchFilter } from './TagSearchFilter'; @@ -186,21 +186,12 @@ export const FormSubmissions: FunctionComponent = ({ defin ))} - {next && ( - - - - dispatch(findSubmissions({ definitionId, criteria, after: next }))} - > - Load more - - - - - )} + dispatch(findSubmissions({ definitionId, criteria, after }))} + /> diff --git a/apps/form-admin-app/src/app/containers/Forms.tsx b/apps/form-admin-app/src/app/containers/Forms.tsx index 87a4ed08f..acadea9c5 100644 --- a/apps/form-admin-app/src/app/containers/Forms.tsx +++ b/apps/form-admin-app/src/app/containers/Forms.tsx @@ -7,6 +7,7 @@ import { GoAIcon, GoATable, } from '@abgov/react-components-new'; +import { RowLoadMore, RowSkeleton } from '@core-services/app-common'; import { FunctionComponent, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { NavigateFunction, useNavigate } from 'react-router-dom'; @@ -37,7 +38,6 @@ import { DataValueCell } from '../components/DataValueCell'; import { ExportModal } from '../components/ExportModal'; import { SearchFormItemsContainer } from '../components/SearchFormItemsContainer'; import { DataValueCriteriaItem } from '../components/DataValueCriteriaItem'; -import { RowSkeleton } from '../components/RowSkeleton'; import { AddTagModal } from '../components/AddTagModal'; import { Tags } from './Tags'; import { TagSearchFilter } from './TagSearchFilter'; @@ -226,21 +226,12 @@ export const Forms: FunctionComponent = ({ definitionId }) => { /> ))} - {next && ( - - - - dispatch(findForms({ definitionId, criteria, after: next }))} - > - Load more - - - - - )} + dispatch(findForms({ definitionId, criteria, after }))} + /> diff --git a/apps/form-app/src/app/containers/Form.tsx b/apps/form-app/src/app/containers/Form.tsx index 23bfeb6aa..d154b8fea 100644 --- a/apps/form-app/src/app/containers/Form.tsx +++ b/apps/form-app/src/app/containers/Form.tsx @@ -62,8 +62,8 @@ const FormComponent: FunctionComponent = ({ className }) => { {form && !fileBusy.loading && ( <> - {form.status === 'submitted' && } - {form.status === 'draft' && ( + {form.status === 'Submitted' && } + {form.status === 'Draft' && ( = ({ de const urlParams = new URLSearchParams(location.search); const AUTO_CREATE_PARAM = 'autoCreate'; - const { initialized, form } = useSelector(userFormSelector); + const { initialized, form, empty } = useSelector(defaultUserFormSelector); const busy = useSelector(busySelector); - useEffect(() => { - dispatch(findUserForm(definition.id)); - }, [dispatch, definition]); - return definition.anonymousApply ? ( ) : ( initialized && (form?.id ? ( navigate(`${form.id}`)} /> - ) : ( + ) : empty ? ( = ({ de } }} /> + ) : ( + )) ); }; @@ -81,6 +80,12 @@ export const FormDefinition: FunctionComponent = () => { } }, [dispatch, definitionId, tenant]); + useEffect(() => { + if (definition && user) { + dispatch(findUserForms({ definitionId: definition.id })); + } + }, [dispatch, definition, user]); + // Definition can be available even if there is no signed in user. // If definition is not available, then show the sign-in option as user might have access if they sign in. return ( @@ -91,6 +96,7 @@ export const FormDefinition: FunctionComponent = () => { } /> + } />{' '} } /> } /> } /> diff --git a/apps/form-app/src/app/containers/Forms.tsx b/apps/form-app/src/app/containers/Forms.tsx new file mode 100644 index 000000000..559ea325b --- /dev/null +++ b/apps/form-app/src/app/containers/Forms.tsx @@ -0,0 +1,97 @@ +import { GoAButton, GoAButtonGroup, GoASkeleton, GoATable } from '@abgov/react-components-new'; +import { Band, Container, RowLoadMore, RowSkeleton } from '@core-services/app-common'; +import { useDispatch, useSelector } from 'react-redux'; +import { FunctionComponent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + AppDispatch, + busySelector, + canCreateDraftSelector, + createForm, + defaultUserFormSelector, + findUserForms, + FormDefinition, + formsSelector, + FormStatus, +} from '../state'; + +interface FormsProps { + definition: FormDefinition; +} + +export const Forms: FunctionComponent = ({ definition }) => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const busy = useSelector(busySelector); + const { forms, next } = useSelector(formsSelector); + const { form: defaultForm } = useSelector(defaultUserFormSelector); + const canCreateDraft = useSelector(canCreateDraftSelector); + + return ( +
+ + Continue working on existing draft forms, or view forms you submitted in the past. + + + + {canCreateDraft && ( + { + const form = await dispatch(createForm(definition.id)).unwrap(); + if (form?.id) { + navigate(`../${form.id}`); + } + }} + > + Start new draft + + )} + + + + + Created on + Submitted on + Status + Actions + + + + {forms.map((form) => ( + + {form.created.toFormat('LLLL dd, yyyy')} + {form.submitted?.toFormat('LLLL dd, yyyy')} + {form.status} + + + {form.status === FormStatus.draft ? ( + navigate(`../${form.id}`)} + > + Continue draft + + ) : ( + navigate(`../${form.id}`)}> + View submitted + + )} + + + + ))} + + dispatch(findUserForms({ definitionId: definition.id, after }))} + /> + + + +
+ ); +}; diff --git a/apps/form-app/src/app/state/form.slice.spec.ts b/apps/form-app/src/app/state/form.slice.spec.ts index 0a8a4edf5..074244429 100644 --- a/apps/form-app/src/app/state/form.slice.spec.ts +++ b/apps/form-app/src/app/state/form.slice.spec.ts @@ -4,7 +4,7 @@ import { FormDefinition, FormState, createForm, - findUserForm, + findUserForms, formActions, formReducer, loadDefinition, @@ -17,7 +17,9 @@ import { const initialState: FormState = { definitions: {}, selected: null, - userForm: null, + forms: {}, + results: [], + next: null, form: null, data: {}, files: {}, @@ -42,11 +44,14 @@ const definitionsToTest: FormState = { applicantRoles: ['Admin'], clerkRoles: [], anonymousApply: false, - scheduledIntakes: false + scheduledIntakes: false, + oneFormPerApplicant: true, }, }, selected: null, - userForm: 'TEST', + forms: {}, + results: [], + next: null, form: { definition: { id: 'TEST', @@ -79,7 +84,8 @@ const loadedFormDefinition: FormDefinition = { clerkRoles: [], anonymousApply: false, scheduledIntakes: false, - intake: undefined + intake: undefined, + oneFormPerApplicant: true, }; const payload = { form: { @@ -158,7 +164,6 @@ describe('form slice unit tests', () => { id: 'TEST2', }, }, - userForm: null, data: null, files: null, saved: null, @@ -216,7 +221,6 @@ describe('form slice unit tests', () => { const action = { type: createForm.fulfilled, payload: 'TEST' }; const form = formReducer(clonedDefinitionToTest, action); expect(form).not.toBeNull(); - expect(form.userForm).toBe('TEST'); }); it('can return pending for create form', () => { @@ -330,7 +334,7 @@ describe('form slice unit tests', () => { expect(form.files).not.toBeNull(); }); - it('can return fulfilled for find user form', () => { + it('can return fulfilled for find user forms', () => { const clonedDefinitionToTest: FormState = { ...definitionsToTest, form: { @@ -346,17 +350,18 @@ describe('form slice unit tests', () => { }, }; - const action = { type: findUserForm.fulfilled, payload }; + const result = { results: [{ id: 'abc-123' }], page: { next: 'test' } }; + const action = { type: findUserForms.fulfilled, payload: result }; const form = formReducer(clonedDefinitionToTest, action); - expect(form).not.toBeNull(); - expect(form.data).not.toBeNull(); - expect(form.files).not.toBeNull(); + expect(form.busy.loading).toBe(false); + expect(form.results).toEqual(expect.arrayContaining(['abc-123'])); + expect(form.forms).toEqual(expect.objectContaining({ 'abc-123': result.results[0] })); + expect(form.next).toBe('test'); }); - it('can return pending for find user form', () => { + it('can return pending for find user forms', () => { const clonedDefinitionToTest: FormState = { ...definitionsToTest, - userForm: null, form: { ...definitionsToTest.form, definition: { @@ -370,17 +375,14 @@ describe('form slice unit tests', () => { }, }; - const action = { type: findUserForm.pending, payload }; + const action = { type: findUserForms.pending, payload }; const form = formReducer(clonedDefinitionToTest, action); - expect(form).not.toBeNull(); - expect(form.data).not.toBeNull(); - expect(form.files).not.toBeNull(); + expect(form.busy.loading).toBe(true); }); - it('can return rejected for find user form', () => { + it('can return rejected for find user forms', () => { const clonedDefinitionToTest: FormState = { ...definitionsToTest, - userForm: null, form: { ...definitionsToTest.form, definition: { @@ -394,9 +396,8 @@ describe('form slice unit tests', () => { }, }; - const action = { type: findUserForm.rejected, payload }; + const action = { type: findUserForms.rejected, payload }; const form = formReducer(clonedDefinitionToTest, action); - expect(form).not.toBeNull(); expect(form.busy.loading).toBe(false); }); diff --git a/apps/form-app/src/app/state/form.slice.ts b/apps/form-app/src/app/state/form.slice.ts index 33f548d8b..effe68ca4 100644 --- a/apps/form-app/src/app/state/form.slice.ts +++ b/apps/form-app/src/app/state/form.slice.ts @@ -5,12 +5,13 @@ import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit' import axios from 'axios'; import * as _ from 'lodash'; import { debounce } from 'lodash'; +import { DateTime } from 'luxon'; import { AppState } from './store'; import { hashData } from './util'; import { getAccessToken } from './user.slice'; import { connectStream, loadTopic, selectTopic } from './comment.slice'; import { loadFileMetadata } from './file.slice'; -import { DateTime } from 'luxon'; +import { PagedResults } from './types'; export const FORM_FEATURE_KEY = 'form'; @@ -21,6 +22,7 @@ interface SerializableFormDefinition { uiSchema: UISchemaElement; applicantRoles: string[]; clerkRoles: string[]; + oneFormPerApplicant: boolean; registerData?: RegisterData; anonymousApply: boolean; generatesPdf?: boolean; @@ -53,12 +55,20 @@ interface FormDataResponse { files: Record; } +export enum FormStatus { + draft = 'Draft', + submitted = 'Submitted', + archived = 'Archived', +} + export type ValidationError = JsonFormsCore['errors'][number]; export interface FormState { definitions: Record; selected: string; - userForm: string; + forms: Record; + results: string[]; + next: string; form: SerializableForm; data: Record; files: Record; @@ -146,6 +156,10 @@ export const loadDefinition = createAsyncThunk( } } + if (typeof data.oneFormPerApplicant !== 'boolean') { + data.oneFormPerApplicant = true; + } + const registerUrns = extraRegisterUrns(data?.uiSchema); const registerData = []; if (registerUrns) { @@ -197,24 +211,24 @@ export const loadDefinition = createAsyncThunk( } ); -export const findUserForm = createAsyncThunk( - 'form/find-user-form', - async (definitionId: string, { getState, rejectWithValue }) => { +export const findUserForms = createAsyncThunk( + 'form/find-user-forms', + async ({ definitionId, after }: { definitionId: string; after?: string }, { getState, rejectWithValue }) => { try { const { config, user } = getState() as AppState; const formServiceUrl = config.directory[FORM_SERVICE_ID]; // If there is no user context, then there is no existing form to find. if (!user.user) { - return { form: null, data: null, files: null, digest: null }; + return { results: [], page: {} } as PagedResults; } - let token = await getAccessToken(); - const { - data: { results }, - } = await axios.get<{ results: SerializableForm[] }>(new URL(`/form/v1/forms`, formServiceUrl).href, { + const token = await getAccessToken(); + const { data } = await axios.get>(new URL(`/form/v1/forms`, formServiceUrl).href, { headers: { Authorization: `Bearer ${token}` }, params: { + top: 10, + after, criteria: JSON.stringify({ createdByIdEquals: user.user.id, definitionIdEquals: definitionId, @@ -222,24 +236,7 @@ export const findUserForm = createAsyncThunk( }, }); - const [form] = results; - let data = null, - files = null, - digest = null; - if (form) { - token = await getAccessToken(); - const { data: formData } = await axios.get( - new URL(`/form/v1/forms/${form.id}/data`, formServiceUrl).href, - { - headers: { Authorization: `Bearer ${token}` }, - } - ); - data = formData.data; - files = formData.files; - digest = await hashData({ data, files }); - } - - return { form, data, files, digest }; + return data; } catch (err) { if (axios.isAxiosError(err)) { return rejectWithValue({ @@ -264,9 +261,12 @@ export const loadForm = createAsyncThunk( const formServiceUrl = config.directory[FORM_SERVICE_ID]; let token = await getAccessToken(); - const { data: form } = await axios.get(new URL(`/form/v1/forms/${formId}`, formServiceUrl).href, { - headers: { Authorization: `Bearer ${token}` }, - }); + const { data: form } = await axios.get( + new URL(`/form/v1/forms/${formId}`, formServiceUrl).href, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); token = await getAccessToken(); const { data } = await axios.get( @@ -483,7 +483,9 @@ export const submitAnonymousForm = createAsyncThunk( const initialFormState: FormState = { definitions: {}, selected: null, - userForm: null, + forms: {}, + results: [], + next: null, form: null, data: {}, files: {}, @@ -515,7 +517,9 @@ export const formSlice = createSlice({ state.selected = meta.arg; // Clear the form if the form definition is changing. if (state.form && state.form.definition.id !== meta.arg) { - state.userForm = null; + state.forms = {}; + state.results = []; + state.next = null; state.form = null; state.data = {}; state.files = {}; @@ -544,6 +548,8 @@ export const formSlice = createSlice({ }) .addCase(createForm.fulfilled, (state, { payload }) => { state.busy.creating = false; + state.forms[payload.id] = payload; + state.results.push(payload.id); state.form = payload; state.data = {}; state.files = {}; @@ -557,6 +563,7 @@ export const formSlice = createSlice({ }) .addCase(loadForm.fulfilled, (state, { payload }) => { state.busy.loading = false; + state.forms[payload.form.id] = payload.form; state.form = payload.form; state.data = payload.data || {}; state.files = payload.files || {}; @@ -565,20 +572,16 @@ export const formSlice = createSlice({ .addCase(loadForm.rejected, (state) => { state.busy.loading = false; }) - .addCase(findUserForm.pending, (state) => { + .addCase(findUserForms.pending, (state) => { state.busy.loading = true; - state.userForm = null; }) - .addCase(findUserForm.fulfilled, (state, { payload }) => { + .addCase(findUserForms.fulfilled, (state, { payload }) => { state.busy.loading = false; - // This isn't very clear, but empty string is indicating no result found. - state.userForm = payload.form?.id || ''; - state.form = payload.form; - state.data = payload.data || {}; - state.files = payload.files || {}; - state.saved = payload.digest; + state.forms = payload.results.reduce((forms, result) => ({ ...forms, [result.id]: result }), state.forms); + state.results = [...(payload.page.after ? state.results : []), ...payload.results.map((result) => result.id)]; + state.next = payload.page.next; }) - .addCase(findUserForm.rejected, (state) => { + .addCase(findUserForms.rejected, (state) => { state.busy.loading = false; }) .addCase(updateForm.pending, (state, { meta }) => { @@ -634,6 +637,28 @@ export const definitionSelector = createSelector( } ); +export const formsSelector = createSelector( + (state: AppState) => state.form.forms, + (state: AppState) => state.form.results, + (state: AppState) => state.form.next, + (forms, results, next) => ({ + forms: results + .map((result) => + forms[result] + ? { + ...forms[result], + status: FormStatus[forms[result].status], + created: forms[result].created && DateTime.fromISO(forms[result].created), + submitted: forms[result].submitted && DateTime.fromISO(forms[result].submitted), + } + : null + ) + .filter((result) => !!result) + .sort((a, b) => a.created.diff(b.created).as('seconds')), + next, + }) +); + export const formSelector = createSelector( definitionSelector, (state: AppState) => state.form.form, @@ -641,16 +666,34 @@ export const formSelector = createSelector( definition && form && definition?.id === form?.definition.id ? { ...form, + status: FormStatus[form.status], created: form.created && DateTime.fromISO(form.created), submitted: form.submitted && DateTime.fromISO(form.submitted), } : null ); -export const userFormSelector = createSelector( - formSelector, - (state: AppState) => state.form.userForm, - (form, formId) => ({ form: formId && formId === form?.id ? form : null, initialized: formId !== null }) +export const defaultUserFormSelector = createSelector( + formsSelector, + (state: AppState) => state.form.busy.loading, + ({ forms }, loading) => { + let form: ReturnType['forms'][0]; + if (forms.length === 1) { + // If user only has one form, then that's the default form. + form = forms[0]; + } else if (forms.length > 1) { + // If user only has one draft form, then that's the default form. + const draftForms = forms.filter(({ status }) => status === 'draft'); + if (draftForms.length === 1) { + form = draftForms[0]; + } + } + return { + form, + initialized: !loading, + empty: forms.length < 1, + }; + } ); export const dataSelector = (state: AppState) => state.form.data; @@ -671,6 +714,15 @@ export const isClerkSelector = createSelector( export const busySelector = (state: AppState) => state.form.busy; +export const canCreateDraftSelector = createSelector( + (state: AppState) => state.form.busy.creating, + isApplicantSelector, + definitionSelector, + defaultUserFormSelector, + (creating, isApplicant, { definition }, { form }) => + !creating && isApplicant && (definition?.oneFormPerApplicant === false || !form) +); + export const showSubmitSelector = createSelector(definitionSelector, ({ definition }) => { // Stepper variant of the categorization includes a Submit button on the review step, so don't show submit outside form. return definition?.uiSchema?.type !== 'Categorization' || definition?.uiSchema?.options?.variant !== 'stepper'; diff --git a/apps/form-app/src/app/state/types.ts b/apps/form-app/src/app/state/types.ts index 481a5d25c..9f5c28511 100644 --- a/apps/form-app/src/app/state/types.ts +++ b/apps/form-app/src/app/state/types.ts @@ -2,3 +2,11 @@ export interface SerializedAxiosError { status: number; message: string; } + +export interface PagedResults { + results: T[]; + page: { + after: string; + next: string; + }; +} diff --git a/apps/form-service/src/form/mapper.ts b/apps/form-service/src/form/mapper.ts index 0ff0dae39..173eaa7f5 100644 --- a/apps/form-service/src/form/mapper.ts +++ b/apps/form-service/src/form/mapper.ts @@ -9,6 +9,7 @@ export function mapFormDefinition(entity: FormDefinition, intake?: Intake) { name: entity.name, description: entity.description, anonymousApply: entity.anonymousApply, + oneFormPerApplicant: entity.oneFormPerApplicant, applicantRoles: entity.applicantRoles, assessorRoles: entity.assessorRoles, clerkRoles: entity.clerkRoles, diff --git a/libs/app-common/src/components/RowLoadMore.tsx b/libs/app-common/src/components/RowLoadMore.tsx new file mode 100644 index 000000000..b58485c60 --- /dev/null +++ b/libs/app-common/src/components/RowLoadMore.tsx @@ -0,0 +1,25 @@ +import { GoAButton, GoAButtonGroup } from '@abgov/react-components-new'; +import { FunctionComponent } from 'react'; + +interface RowLoadMoreProps { + next?: string; + columns: number; + loading: boolean; + onLoadMore: (after: string) => void; +} + +export const RowLoadMore: FunctionComponent = ({ next, columns, loading, onLoadMore }) => { + return ( + next && ( + + + + onLoadMore(next)}> + Load more + + + + + ) + ); +}; diff --git a/apps/form-admin-app/src/app/components/RowSkeleton.tsx b/libs/app-common/src/components/RowSkeleton.tsx similarity index 100% rename from apps/form-admin-app/src/app/components/RowSkeleton.tsx rename to libs/app-common/src/components/RowSkeleton.tsx diff --git a/libs/app-common/src/index.ts b/libs/app-common/src/index.ts index a6fcf07de..59ad74821 100644 --- a/libs/app-common/src/index.ts +++ b/libs/app-common/src/index.ts @@ -7,6 +7,8 @@ export * from './components/Grid'; export * from './components/PageProgress'; export * from './components/TextSkeleton'; export * from './components/Recaptcha'; +export * from './components/RowSkeleton'; +export * from './components/RowLoadMore'; export * from './utils'; export * from './useScripts';