Skip to content

Commit

Permalink
feat(form-app): support view of user forms
Browse files Browse the repository at this point in the history
  • Loading branch information
tzuge authored Jan 31, 2025
1 parent 9497d88 commit 4cd0ae7
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 55 deletions.
4 changes: 3 additions & 1 deletion apps/form-app/src/app/containers/FormTenant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { FeedbackNotification } from './FeedbackNotification';
import { FormDefinition } from './FormDefinition';
import { useFeedbackLinkHandler } from '../util/feedbackUtils';
import { Forms } from './Forms';

const AccountActionsSpan = styled.span`
.username {
Expand Down Expand Up @@ -90,7 +91,8 @@ export const FormTenant = () => {
{userInitialized && (
<section>
<Routes>
<Route path={`/:definitionId/*`} element={<FormDefinition />} />
<Route path="/forms" element={<Forms />} />
<Route path="/:definitionId/*" element={<FormDefinition />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</section>
Expand Down
74 changes: 49 additions & 25 deletions apps/form-app/src/app/containers/Forms.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,60 @@
import { GoAButton, GoAButtonGroup, GoASkeleton, GoATable } from '@abgov/react-components-new';
import { GoAButton, GoAButtonGroup, 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 { FunctionComponent, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import {
AppDispatch,
busySelector,
canCreateDraftSelector,
createForm,
defaultUserFormSelector,
definitionFormsSelector,
findUserForms,
FormDefinition,
formsSelector,
FormStatus,
tenantSelector,
} from '../state';

const FormsLayout = styled.div`
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: auto;
`;

interface FormsProps {
definition: FormDefinition;
definition?: FormDefinition;
}

export const Forms: FunctionComponent<FormsProps> = ({ definition }) => {
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();

const tenant = useSelector(tenantSelector);
const busy = useSelector(busySelector);
const { forms, next } = useSelector(formsSelector);
const { forms, next } = useSelector((state) => definitionFormsSelector(state, definition?.id));
const { form: defaultForm } = useSelector(defaultUserFormSelector);
const canCreateDraft = useSelector(canCreateDraftSelector);

useEffect(() => {
dispatch(findUserForms({ definitionId: definition?.id }));
}, [dispatch, definition]);

return (
<div>
<Band title={`Your ${definition.name} forms`}>
<FormsLayout>
<Band title={`Your ${definition?.name ? `${definition?.name} ` : ' '}forms`}>
Continue working on existing draft forms, or view forms you submitted in the past.
</Band>
<Container vs={3} hs={1}>
<GoAButtonGroup alignment="end">
{canCreateDraft && (
{definition && canCreateDraft && (
<GoAButton
mr="m"
disabled={busy.creating}
type={defaultForm?.status === FormStatus.draft ? 'secondary' : 'primary'}
onClick={async () => {
const form = await dispatch(createForm(definition.id)).unwrap();
Expand All @@ -52,46 +70,52 @@ export const Forms: FunctionComponent<FormsProps> = ({ definition }) => {
<GoATable width="100%">
<thead>
<tr>
{!definition && <th>Form</th>}
<th>Created on</th>
<th>Submitted on</th>
<th>Status</th>
<th>Actions</th>
<th></th>
</tr>
</thead>
<tbody>
{forms.map((form) => (
<tr key={form.id}>
{!definition && <td>{form.definition?.name}</td>}
<td>{form.created.toFormat('LLLL dd, yyyy')}</td>
<td>{form.submitted?.toFormat('LLLL dd, yyyy')}</td>
<td>{form.status}</td>
<td>
<GoAButtonGroup alignment="end">
{form.status === FormStatus.draft ? (
<GoAButton
type={form?.id === defaultForm?.id ? 'primary' : 'secondary'}
onClick={() => navigate(`../${form.id}`)}
>
Continue draft
</GoAButton>
) : (
<GoAButton type="secondary" onClick={() => navigate(`../${form.id}`)}>
View submitted
</GoAButton>
)}
{form.definition &&
(form.status === FormStatus.draft ? (
<GoAButton
type={form?.id === defaultForm?.id ? 'primary' : 'secondary'}
onClick={() => navigate(`/${tenant.name}/${form.definition.id}/${form.id}`)}
>
Continue draft
</GoAButton>
) : (
<GoAButton
type="secondary"
onClick={() => navigate(`/${tenant.name}/${form.definition.id}/${form.id}`)}
>
View submitted
</GoAButton>
))}
</GoAButtonGroup>
</td>
</tr>
))}
<RowSkeleton columns={4} show={busy.loading} />
<RowSkeleton columns={4 + (!definition ? 1 : 0)} show={busy.loading} />
<RowLoadMore
columns={4}
columns={4 + (!definition ? 1 : 0)}
next={next}
loading={busy.loading}
onLoadMore={(after) => dispatch(findUserForms({ definitionId: definition.id, after }))}
onLoadMore={(after) => dispatch(findUserForms({ definitionId: definition?.id, after }))}
/>
</tbody>
</GoATable>
</Container>
</div>
</FormsLayout>
);
};
8 changes: 8 additions & 0 deletions apps/form-app/src/app/state/form.slice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const initialState: FormState = {
saving: false,
submitting: false,
},
initialized: {
forms: false,
},
};

const definitionsToTest: FormState = {
Expand All @@ -55,6 +58,7 @@ const definitionsToTest: FormState = {
form: {
definition: {
id: 'TEST',
name: 'test',
},
created: null,
submitted: null,
Expand All @@ -73,6 +77,9 @@ const definitionsToTest: FormState = {
saving: false,
submitting: false,
},
initialized: {
forms: false,
},
};

const loadedFormDefinition: FormDefinition = {
Expand All @@ -91,6 +98,7 @@ const payload = {
form: {
definition: {
id: 'TEST',
name: 'test',
},
created: null,
submitted: null,
Expand Down
67 changes: 38 additions & 29 deletions apps/form-app/src/app/state/form.slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ interface SerializableFormDefinition {
}

interface SerializableForm {
definition: { id: string };
definition: { id: string; name: string };
id: string;
urn: string;
status: 'draft' | 'locked' | 'submitted' | 'archived';
Expand Down Expand Up @@ -81,6 +81,9 @@ export interface FormState {
saving: boolean;
submitting: boolean;
};
initialized: {
forms: boolean;
};
}

const FORM_SERVICE_ID = 'urn:ads:platform:form-service';
Expand Down Expand Up @@ -213,7 +216,7 @@ export const loadDefinition = createAsyncThunk(

export const findUserForms = createAsyncThunk(
'form/find-user-forms',
async ({ definitionId, after }: { definitionId: string; after?: string }, { getState, rejectWithValue }) => {
async ({ definitionId, after }: { definitionId?: string; after?: string }, { getState, rejectWithValue }) => {
try {
const { config, user } = getState() as AppState;
const formServiceUrl = config.directory[FORM_SERVICE_ID];
Expand Down Expand Up @@ -498,6 +501,9 @@ const initialFormState: FormState = {
saving: false,
submitting: false,
},
initialized: {
forms: false,
},
};

export const formSlice = createSlice({
Expand All @@ -517,8 +523,6 @@ 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.forms = {};
state.results = [];
state.next = null;
state.form = null;
state.data = {};
Expand All @@ -532,13 +536,6 @@ export const formSlice = createSlice({
.addCase(loadDefinition.fulfilled, (state, { payload, meta }) => {
state.busy.loading = false;
state.definitions[meta.arg] = payload;

//Check form definition id case sensitivity, and use the definition id in the payload object,
//instead of using the value in querystring because if the case is not the same
//grabbing the object using the form definition id in the querystring as the key wont work.
if (payload && payload.id?.toLowerCase() === state.selected?.toLowerCase()) {
state.selected = payload.id;
}
})
.addCase(loadDefinition.rejected, (state) => {
state.busy.loading = false;
Expand Down Expand Up @@ -580,6 +577,7 @@ export const formSlice = createSlice({
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;
state.initialized.forms = true;
})
.addCase(findUserForms.rejected, (state) => {
state.busy.loading = false;
Expand Down Expand Up @@ -643,18 +641,28 @@ export const formsSelector = createSelector(
(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
)
.map((result) => {
const form = forms[result];
const status = FormStatus[form.status];
return {
...forms[result],
status,
created: form.created && DateTime.fromISO(form.created),
// Submitted can be set for draft forms if it was returned to draft.
submitted: status !== FormStatus.draft ? form.submitted && DateTime.fromISO(form.submitted) : undefined,
};
})
.filter((result) => !!result)
.sort((a, b) => a.created.diff(b.created).as('seconds')),
.sort((a, b) => b.created.diff(a.created).as('seconds')),
next,
})
);

export const definitionFormsSelector = createSelector(
formsSelector,
(_, definitionId: string) => definitionId,
({ forms, next }, definitionId) => ({
forms: forms.filter((form) => !definitionId || form.definition?.id === definitionId),
next,
})
);
Expand All @@ -668,15 +676,18 @@ export const formSelector = createSelector(
...form,
status: FormStatus[form.status],
created: form.created && DateTime.fromISO(form.created),
submitted: form.submitted && DateTime.fromISO(form.submitted),
submitted:
FormStatus[form.status] !== FormStatus.draft
? form.submitted && DateTime.fromISO(form.submitted)
: undefined,
}
: null
);

export const defaultUserFormSelector = createSelector(
formsSelector,
(state: AppState) => state.form.busy.loading,
({ forms }, loading) => {
(state: AppState) => state.form.initialized.forms,
({ forms }, initialized) => {
let form: ReturnType<typeof formsSelector>['forms'][0];
if (forms.length === 1) {
// If user only has one form, then that's the default form.
Expand All @@ -690,7 +701,7 @@ export const defaultUserFormSelector = createSelector(
}
return {
form,
initialized: !loading,
initialized,
empty: forms.length < 1,
};
}
Expand All @@ -715,12 +726,10 @@ 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)
(isApplicant, { definition }, { form }) => isApplicant && (definition?.oneFormPerApplicant === false || !form)
);

export const showSubmitSelector = createSelector(definitionSelector, ({ definition }) => {
Expand Down

0 comments on commit 4cd0ae7

Please sign in to comment.