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';