Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(form-app): support view of user forms #4052

Merged
merged 2 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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