diff --git a/schemas/json/layout/layout.schema.v1.json b/schemas/json/layout/layout.schema.v1.json index dc0a4c548f..448a628664 100644 --- a/schemas/json/layout/layout.schema.v1.json +++ b/schemas/json/layout/layout.schema.v1.json @@ -42,7 +42,7 @@ "type": "string", "title": "Type", "description": "The component type.", - "enum": ["AddressComponent", "AttachmentList", "Button", "Checkboxes", "Custom", "Datepicker", "Dropdown", "FileUpload", "FileUploadWithTag", "Group", "Header", "Image", "Input", "InstantiationButton", "Likert", "MultipleSelect", "NavigationButtons", "NavigationBar", "Panel", "Paragraph", "PrintButton", "RadioButtons", "Summary", "TextArea"] + "enum": ["AddressComponent", "AttachmentList", "Button", "Checkboxes", "Custom", "Datepicker", "Dropdown", "FileUpload", "FileUploadWithTag", "Group", "Header", "Image", "Input", "InstantiationButton", "Likert","List", "MultipleSelect", "NavigationButtons", "NavigationBar", "Panel", "Paragraph", "PrintButton", "RadioButtons", "Summary", "TextArea"] }, "required": { "title": "Required", @@ -138,7 +138,8 @@ { "if": {"properties": {"type": { "const": "RadioButtons"}}}, "then": { "$ref": "#/definitions/radioAndCheckboxComponents"}}, { "if": {"properties": {"type": { "const": "Summary"}}}, "then": {"$ref": "#/definitions/summaryComponent"}}, { "if": {"properties": {"type": { "const": "Header"}}}, "then": {"$ref": "#/definitions/headerComponent"}}, - { "if": {"properties": {"type": { "const": "Panel"}}}, "then": {"$ref": "#/definitions/panelComponent"}} + { "if": {"properties": {"type": { "const": "Panel"}}}, "then": {"$ref": "#/definitions/panelComponent"}}, + { "if": {"properties": {"type": { "const": "List"}}}, "then": {"$ref": "#/definitions/listComponent"}} ] }, "headerComponent": { @@ -758,6 +759,71 @@ "additionalProperties": { "type": "string" } + }, + "listComponent": { + "type": "object", + "properties": { + "tableHeaders": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Table Headers", + "description": "An array of strings that is going to be headers of the table. Can be added to the resource files to change between languages" + }, + "sortableColumns": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Sortable Columns", + "description": "An array of the columns that is going to be sortable. The column has to be represented by the the headername that is written in tableHeaders" + }, + "pagination": { + "title": "Pagination", + "$ref": "#/definitions/paginationProperties" + }, + "dataListId": { + "type": "string", + "title": "List ID", + "description": "The Id of the list. This id is used to retrive the datalist from the backend" + }, + "secure": { + "type": "boolean", + "title": "Secure ListItems", + "description": "Boolean value indicating if the options should be instance aware. Defaults to false." + }, + "bindingToShowInSummary": { + "type": "string", + "title": "Binding to show in summary", + "description": "The value of this binding will be shown in the summary component for the list. This binding must be one of the specified bindings under dataModelBindings." + } + }, + "required": [ + "dataListId" + ] + }, + "paginationProperties": { + "type": "object", + "properties": { + "alternatives": { + "type": "array", + "items": { + "type": "number" + }, + "title": "Alternatives", + "description": "List of page sizes the user can choose from. Make sure to test the performance of the largest number of items per page you are allowing." + }, + "default": { + "type": "number", + "title": "Default", + "description": "The pagination size that is set to default" + } + }, + "required": [ + "alternatives", + "default" + ] } } } diff --git a/src/altinn-app-frontend/__mocks__/initialStateMock.ts b/src/altinn-app-frontend/__mocks__/initialStateMock.ts index 651edbbc5b..e3124aaa13 100644 --- a/src/altinn-app-frontend/__mocks__/initialStateMock.ts +++ b/src/altinn-app-frontend/__mocks__/initialStateMock.ts @@ -132,6 +132,10 @@ export function getInitialStateMock(customStates?: Partial): IRun options: {}, error: null, }, + dataListState: { + dataLists: {}, + error: null, + }, applicationSettings: { applicationSettings: applicationSettingsMock, error: null, diff --git a/src/altinn-app-frontend/src/components/base/ListComponent.test.tsx b/src/altinn-app-frontend/src/components/base/ListComponent.test.tsx new file mode 100644 index 0000000000..afacc1a174 --- /dev/null +++ b/src/altinn-app-frontend/src/components/base/ListComponent.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import { getInitialStateMock } from '__mocks__/initialStateMock'; +import { SortDirection } from '@altinn/altinn-design-system'; +import { screen } from '@testing-library/react'; +import { mockComponentProps, renderWithProviders } from 'testUtils'; +import type { PreloadedState } from 'redux'; + +import { ListComponent } from 'src/components/base/ListComponent'; +import type { IListProps } from 'src/components/base/ListComponent'; +import type { IDataListsState } from 'src/shared/resources/dataLists'; +import type { RootState } from 'src/store'; + +const countries = [ + { Name: 'Norway', Population: 5, HighestMountain: 2469 }, + { Name: 'Sweden', Population: 10, HighestMountain: 1738 }, + { Name: 'Denmark', Population: 6, HighestMountain: 170 }, + { Name: 'Germany', Population: 83, HighestMountain: 2962 }, + { Name: 'Spain', Population: 47, HighestMountain: 3718 }, + { Name: 'France', Population: 67, HighestMountain: 4807 }, +]; + +export const testState: IDataListsState = { + dataLists: { + ['countries']: { + listItems: countries, + dataListId: 'countries', + loading: true, + sortColumn: 'HighestMountain', + sortDirection: SortDirection.Ascending, + }, + }, + dataListsWithIndexIndicator: [], + error: null, +}; + +const render = (props: Partial = {}, customState: PreloadedState = {}) => { + const allProps: IListProps = { + ...mockComponentProps, + dataListId: 'countries', + tableHeaders: ['Name', 'Population', 'HighestMountain'], + sortableColumns: ['Population', 'HighestMountain'], + pagination: { alternatives: [2, 5], default: 2 }, + getTextResourceAsString: (value) => value, + ...props, + }; + + renderWithProviders(, { + preloadedState: { + ...getInitialStateMock(), + dataListState: { + dataLists: { + [allProps.id]: { listItems: countries, id: 'countries' }, + }, + error: { + name: '', + message: '', + }, + ...customState, + }, + }, + }); +}; + +describe('ListComponent', () => { + jest.useFakeTimers(); + + it('should render rows that is sent in but not rows that is not sent in', async () => { + render({}); + expect(screen.getByText('Norway')).toBeInTheDocument(); + expect(screen.getByText('Sweden')).toBeInTheDocument(); + expect(screen.queryByText('Italy')).not.toBeInTheDocument(); + }); +}); diff --git a/src/altinn-app-frontend/src/components/base/ListComponent.tsx b/src/altinn-app-frontend/src/components/base/ListComponent.tsx new file mode 100644 index 0000000000..c460f7e68b --- /dev/null +++ b/src/altinn-app-frontend/src/components/base/ListComponent.tsx @@ -0,0 +1,195 @@ +import React from 'react'; + +import { + Pagination, + RadioButton, + SortDirection, + Table, + TableBody, + TableCell, + TableFooter, + TableHeader, + TableRow, +} from '@altinn/altinn-design-system'; +import type { ChangeProps, RowData, SortProps } from '@altinn/altinn-design-system'; + +import type { PropsFromGenericComponent } from '..'; + +import { useAppDispatch, useAppSelector } from 'src/common/hooks'; +import { useGetDataList } from 'src/components/hooks'; +import { DataListsActions } from 'src/shared/resources/dataLists/dataListsSlice'; + +import { getLanguageFromKey } from 'altinn-shared/utils'; + +export type IListProps = PropsFromGenericComponent<'List'>; + +const defaultDataList: any[] = []; +export interface rowValue { + [key: string]: string; +} + +export const ListComponent = ({ + tableHeaders, + id, + pagination, + formData, + handleDataChange, + getTextResourceAsString, + sortableColumns, + dataModelBindings, + language, +}: IListProps) => { + const dynamicDataList = useGetDataList({ id }); + const calculatedDataList = dynamicDataList || defaultDataList; + const defaultPagination = pagination ? pagination.default : 0; + const rowsPerPage = useAppSelector((state) => state.dataListState.dataLists[id]?.size || defaultPagination); + const currentPage = useAppSelector((state) => state.dataListState.dataLists[id]?.pageNumber || 0); + + const sortColumn = useAppSelector((state) => state.dataListState.dataLists[id]?.sortColumn || null); + const sortDirection = useAppSelector( + (state) => state.dataListState.dataLists[id]?.sortDirection || SortDirection.NotActive, + ); + const totalItemsCount = useAppSelector( + (state) => state.dataListState.dataLists[id]?.paginationData?.totaltItemsCount || 0, + ); + + const handleChange = ({ selectedValue }: ChangeProps) => { + for (const key in formData) { + handleDataChange(selectedValue[key], { key: key }); + } + }; + + const renderRow = (datalist) => { + const cells: JSX.Element[] = []; + for (const key of Object.keys(datalist)) { + cells.push({datalist[key]}); + } + return cells; + }; + + const renderHeaders = (headers) => { + const cell: JSX.Element[] = []; + for (const header of headers) { + if ((sortableColumns || []).includes(header)) { + cell.push( + + {getTextResourceAsString(header)} + , + ); + } else { + cell.push({getTextResourceAsString(header)}); + } + } + return cell; + }; + + const dispatch = useAppDispatch(); + + const handleSortChange = ({ sortedColumn, previousSortDirection }: SortProps) => { + dispatch( + DataListsActions.setSort({ + key: id || '', + sortColumn: sortedColumn, + sortDirection: + previousSortDirection === SortDirection.Descending ? SortDirection.Ascending : SortDirection.Descending, + }), + ); + }; + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + dispatch( + DataListsActions.setPageSize({ + key: id || '', + size: parseInt(event.target.value, 10), + }), + ); + }; + + const handleChangeCurrentPage = (newPage: number) => { + dispatch( + DataListsActions.setPageNumber({ + key: id || '', + pageNumber: newPage, + }), + ); + }; + const rowAsValue = (datalist) => { + const chosenRowData: rowValue = {}; + for (const key in dataModelBindings) { + chosenRowData[key] = datalist[key]; + } + return chosenRowData; + }; + const rowAsValueString = (datalist) => { + return JSON.stringify(rowAsValue(datalist)); + }; + + const createLabelRadioButton = (datalist) => { + let label = ''; + for (const key in formData) { + label += `${key} ${datalist[key]} `; + } + return label; + }; + + return ( + + + + + {renderHeaders(tableHeaders)} + + + + {calculatedDataList.map((datalist) => { + return ( + + + { + // Intentionally empty to prevent double-selection + }} + value={rowAsValueString(datalist)} + checked={rowAsValueString(datalist) === JSON.stringify(formData) ? true : false} + label={createLabelRadioButton(datalist)} + hideLabel={true} + > + + {renderRow(datalist)} + + ); + })} + + {pagination && ( + + + + + + + + )} +
+ ); +}; diff --git a/src/altinn-app-frontend/src/components/hooks/index.ts b/src/altinn-app-frontend/src/components/hooks/index.ts index 4d412cb945..26f771baa8 100644 --- a/src/altinn-app-frontend/src/components/hooks/index.ts +++ b/src/altinn-app-frontend/src/components/hooks/index.ts @@ -57,11 +57,11 @@ export const useGetOptions = ({ optionsId, mapping, source }: IUseGetOptionsPara ); }, [ applicationSettings, + optionsId, relevantFormData, instance, mapping, optionState, - optionsId, repeatingGroups, source, relevantTextResource, @@ -70,4 +70,21 @@ export const useGetOptions = ({ optionsId, mapping, source }: IUseGetOptionsPara return options; }; +interface IUseGetDataListParams { + id?: string; + mapping?: IMapping; + source?: IOptionSource; +} + +export const useGetDataList = ({ id }: IUseGetDataListParams) => { + const dataListState = useAppSelector((state) => state.dataListState.dataLists); + const [dataList, setDataList] = useState(undefined); + useEffect(() => { + if (id) { + setDataList(dataListState[getOptionLookupKey({ id: id })]?.listItems); + } + }, [id, dataListState]); + return dataList; +}; + export { useDisplayData } from './useDisplayData'; diff --git a/src/altinn-app-frontend/src/components/index.ts b/src/altinn-app-frontend/src/components/index.ts index 360a31337d..322503fca5 100644 --- a/src/altinn-app-frontend/src/components/index.ts +++ b/src/altinn-app-frontend/src/components/index.ts @@ -13,6 +13,7 @@ import { HeaderComponent } from 'src/components/base/HeaderComponent'; import { ImageComponent } from 'src/components/base/ImageComponent'; import { InputComponent } from 'src/components/base/InputComponent'; import { LikertComponent } from 'src/components/base/LikertComponent'; +import { ListComponent } from 'src/components/base/ListComponent'; import { MapComponent } from 'src/components/base/MapComponent'; import { MultipleSelect } from 'src/components/base/MultipleSelect'; import { NavigationBar as NavigationBarComponent } from 'src/components/base/NavigationBar'; @@ -61,6 +62,7 @@ const components: { PrintButton: PrintButtonComponent, RadioButtons: RadioButtonContainerComponent, TextArea: TextAreaComponent, + List: ListComponent, }; export interface IComponentProps extends IGenericComponentProps { diff --git a/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.ts b/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.ts index 89acac5b5d..838e814a42 100644 --- a/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.ts +++ b/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.ts @@ -17,6 +17,7 @@ import { watchMapFileUploaderWithTagSaga, watchUpdateCurrentViewSaga, } from 'src/features/form/layout/update/updateFormLayoutSagas'; +import { DataListsActions } from 'src/shared/resources/dataLists/dataListsSlice'; import { OptionsActions } from 'src/shared/resources/options/optionsSlice'; import { replaceTextResourcesSaga } from 'src/shared/resources/textResources/replace/replaceTextResourcesSagas'; import { createSagaSlice } from 'src/shared/resources/utils/sagaSlice'; @@ -53,7 +54,6 @@ export const initialState: ILayoutState = { }, layoutsets: null, }; - const formLayoutSlice = createSagaSlice((mkAction: MkActionType) => ({ name: 'formLayout', initialState, @@ -74,6 +74,7 @@ const formLayoutSlice = createSagaSlice((mkAction: MkActionType) = }, takeLatest: function* () { yield put(OptionsActions.fetch()); + yield put(DataListsActions.fetch()); }, }), fetchRejected: mkAction({ diff --git a/src/altinn-app-frontend/src/features/form/layout/formLayoutTypes.ts b/src/altinn-app-frontend/src/features/form/layout/formLayoutTypes.ts index 255b485774..2af111aa19 100644 --- a/src/altinn-app-frontend/src/features/form/layout/formLayoutTypes.ts +++ b/src/altinn-app-frontend/src/features/form/layout/formLayoutTypes.ts @@ -116,7 +116,6 @@ export interface IUpdateFileUploaderWithTagChosenOptions { id: string; option: IOption; } - export interface IUpdateFileUploaderWithTagChosenOptionsFulfilled { componentId: string; baseComponentId: string; diff --git a/src/altinn-app-frontend/src/features/form/layout/index.ts b/src/altinn-app-frontend/src/features/form/layout/index.ts index 0a87c39cb1..9bbf147ff0 100644 --- a/src/altinn-app-frontend/src/features/form/layout/index.ts +++ b/src/altinn-app-frontend/src/features/form/layout/index.ts @@ -99,6 +99,14 @@ export interface ILayoutCompDatePicker extends ILayoutCompBase<'DatePicker'> { } export type ILayoutCompDropdown = ILayoutCompBase<'Dropdown'> & ISelectionComponent; +export interface ILayoutCompList extends ILayoutCompBase<'List'> { + tableHeaders?: string[]; + sortableColumns?: string[]; + pagination?: IPagination; + dataListId: string; + secure?: boolean; + bindingToShowInSummary?: string; +} export type ILayoutCompMultipleSelect = ILayoutCompBase<'MultipleSelect'> & ISelectionComponent; @@ -207,6 +215,7 @@ interface Map { Input: ILayoutCompInput; InstantiationButton: ILayoutCompInstantiationButton; Likert: ILayoutCompLikert; + List: ILayoutCompList; Map: ILayoutCompMap; MultipleSelect: ILayoutCompMultipleSelect; NavigationBar: ILayoutCompNavBar; @@ -265,11 +274,16 @@ export interface IDataModelBindingsForAddress { careOf?: string; houseNumber?: string; } +export interface IDataModelBindingsForList { + [columnKey: string]: string; +} -export type IDataModelBindings = Partial & - Partial & - Partial & - Partial; +export type IDataModelBindings = + | (Partial & + Partial & + Partial & + Partial) + | IDataModelBindingsForList; export interface ITextResourceBindings { [id: string]: string; @@ -318,3 +332,8 @@ export interface SummaryDisplayProperties { useComponentGrid?: boolean; hideBottomBorder?: boolean; } + +export interface IPagination { + alternatives: number[]; + default: number; +} diff --git a/src/altinn-app-frontend/src/reducers/index.ts b/src/altinn-app-frontend/src/reducers/index.ts index df957b6994..d4eda76377 100644 --- a/src/altinn-app-frontend/src/reducers/index.ts +++ b/src/altinn-app-frontend/src/reducers/index.ts @@ -11,6 +11,7 @@ import { appApi } from 'src/services/AppApi'; import applicationMetadataSlice from 'src/shared/resources/applicationMetadata/applicationMetadataSlice'; import applicationSettingsSlice from 'src/shared/resources/applicationSettings/applicationSettingsSlice'; import attachmentSlice from 'src/shared/resources/attachments/attachmentSlice'; +import dataListsSlice from 'src/shared/resources/dataLists/dataListsSlice'; import instanceDataSlice from 'src/shared/resources/instanceData/instanceDataSlice'; import isLoadingSlice from 'src/shared/resources/isLoading/isLoadingSlice'; import languageSlice from 'src/shared/resources/language/languageSlice'; @@ -42,6 +43,7 @@ const reducers = { [queueSlice.name]: queueSlice.reducer, [textResourcesSlice.name]: textResourcesSlice.reducer, [optionsSlice.name]: optionsSlice.reducer, + [dataListsSlice.name]: dataListsSlice.reducer, [applicationSettingsSlice.name]: applicationSettingsSlice.reducer, [appApi.reducerPath]: appApi.reducer, }; diff --git a/src/altinn-app-frontend/src/selectors/dataListStateSelector.ts b/src/altinn-app-frontend/src/selectors/dataListStateSelector.ts new file mode 100644 index 0000000000..9fbb1f4e8d --- /dev/null +++ b/src/altinn-app-frontend/src/selectors/dataListStateSelector.ts @@ -0,0 +1,9 @@ +import type { IRuntimeState } from 'src/types'; + +const dataListStateSelector = (state: IRuntimeState) => state.dataListState; + +export const listStateSelector = (state: IRuntimeState) => { + const dataListState = dataListStateSelector(state); + + return dataListState || {}; +}; diff --git a/src/altinn-app-frontend/src/selectors/getErrors.ts b/src/altinn-app-frontend/src/selectors/getErrors.ts index c4e69b981f..011305b769 100644 --- a/src/altinn-app-frontend/src/selectors/getErrors.ts +++ b/src/altinn-app-frontend/src/selectors/getErrors.ts @@ -29,6 +29,7 @@ const getHasErrorsSelector = (state: IRuntimeState) => { state.formDataModel.error || state.optionState.error || state.attachments.error || + state.dataListState.error || // we have a few special cases where we allow 404 status codes but not other errors exceptIfIncludes(state.applicationSettings.error, '404') || exceptIfIncludes(state.textResources.error, '404') || diff --git a/src/altinn-app-frontend/src/shared/resources/dataLists/dataListSlice.test.tsx b/src/altinn-app-frontend/src/shared/resources/dataLists/dataListSlice.test.tsx new file mode 100644 index 0000000000..90c059511f --- /dev/null +++ b/src/altinn-app-frontend/src/shared/resources/dataLists/dataListSlice.test.tsx @@ -0,0 +1,83 @@ +import { SortDirection } from '@altinn/altinn-design-system'; + +import type { IDataListsState } from '.'; + +import slice, { DataListsActions } from 'src/shared/resources/dataLists/dataListsSlice'; +const countries = [ + { Name: 'Norway', Population: 5, HighestMountain: 2469 }, + { Name: 'Sweden', Population: 10, HighestMountain: 1738 }, + { Name: 'Denmark', Population: 6, HighestMountain: 170 }, + { Name: 'Germany', Population: 83, HighestMountain: 2962 }, + { Name: 'Spain', Population: 47, HighestMountain: 3718 }, + { Name: 'France', Population: 67, HighestMountain: 4807 }, +]; +export const testState: IDataListsState = { + dataLists: { + ['countries']: { + listItems: countries, + id: 'countries', + loading: true, + sortColumn: 'HighestMountain', + sortDirection: SortDirection.Ascending, + size: 10, + pageNumber: 0, + }, + }, + dataListsWithIndexIndicator: [], + error: null, +}; + +describe('languageSlice', () => { + let state: IDataListsState; + beforeEach(() => { + state = testState; + }); + + it('handles fetchLanguageFulfilled action', () => { + const nextState = slice.reducer( + state, + DataListsActions.fetchFulfilled({ + key: 'countries', + dataLists: countries, + metadata: null, + }), + ); + expect(nextState.dataLists['countries'].loading).toBe(false); + expect(nextState.dataLists['countries'].listItems[0].Name).toBe('Norway'); + expect(nextState.dataLists['countries'].listItems[0].Name).not.toBe('Italy'); + expect(nextState.error).toBeNull(); + }); + + it('handles fetchLanguageRejected action', () => { + const errorMessage = 'This is an error'; + const nextState = slice.reducer( + state, + DataListsActions.fetchRejected({ + key: 'countries', + error: new Error(errorMessage), + }), + ); + expect(nextState.dataLists['countries'].loading).toBe(false); + expect(nextState.error?.message).toEqual(errorMessage); + }); + + it('Check if the sort values is changed to the right values in the dataListState when setSort is called', () => { + const nextState = slice.reducer( + state, + DataListsActions.setSort({ key: 'countries', sortColumn: 'Population', sortDirection: SortDirection.Descending }), + ); + expect(nextState.dataLists['countries'].sortColumn).toBe('Population'); + expect(nextState.dataLists['countries'].sortDirection).toBe(SortDirection.Descending); + }); + + it('Check if the size and pageNumber is changed to the right values in the dataListState when setPageSize is called', () => { + const nextState = slice.reducer(state, DataListsActions.setPageSize({ key: 'countries', size: 5 })); + expect(nextState.dataLists['countries'].size).toBe(5); + expect(nextState.dataLists['countries'].pageNumber).toBe(0); + }); + + it('Check if pageNumber is changed to the right value in the dataListState when setPageNumber is called', () => { + const nextState = slice.reducer(state, DataListsActions.setPageNumber({ key: 'countries', pageNumber: 2 })); + expect(nextState.dataLists['countries'].pageNumber).toBe(2); + }); +}); diff --git a/src/altinn-app-frontend/src/shared/resources/dataLists/dataListsSlice.ts b/src/altinn-app-frontend/src/shared/resources/dataLists/dataListsSlice.ts new file mode 100644 index 0000000000..3d6aa51a83 --- /dev/null +++ b/src/altinn-app-frontend/src/shared/resources/dataLists/dataListsSlice.ts @@ -0,0 +1,93 @@ +import { fetchDataListsSaga } from 'src/shared/resources/dataLists/fetchDataListsSaga'; +import { createSagaSlice } from 'src/shared/resources/utils/sagaSlice'; +import type { + IDataListsState, + IFetchDataListsFulfilledAction, + IFetchDataListsRejectedAction, + IFetchingDataListsAction, + ISetDataLists, + ISetDataListsPageNumber, + ISetDataListsPageSize, + ISetDataListsWithIndexIndicators, + ISetSort, +} from 'src/shared/resources/dataLists'; +import type { MkActionType } from 'src/shared/resources/utils/sagaSlice'; + +const initialState: IDataListsState = { + dataLists: {}, + dataListsWithIndexIndicator: [], + error: null, +}; + +const dataListsSlice = createSagaSlice((mkAction: MkActionType) => ({ + name: 'dataListState', + initialState, + actions: { + fetch: mkAction({ + takeEvery: fetchDataListsSaga, + }), + fetchFulfilled: mkAction({ + reducer: (state, action) => { + const { key, dataLists, metadata } = action.payload; + state.dataLists[key].loading = false; + state.dataLists[key].listItems = dataLists; + state.dataLists[key].paginationData = metadata; + }, + }), + fetchRejected: mkAction({ + reducer: (state, action) => { + const { key, error } = action.payload; + state.dataLists[key].loading = false; + state.error = error; + }, + }), + fetching: mkAction({ + reducer: (state, action) => { + const { key, metaData } = action.payload; + state.dataLists[key] = { + ...(state.dataLists[key] || {}), + ...metaData, + loading: true, + }; + }, + }), + setDataListsWithIndexIndicators: mkAction({ + reducer: (state, action) => { + const { dataListsWithIndexIndicators } = action.payload; + state.dataListsWithIndexIndicator = dataListsWithIndexIndicators; + }, + }), + setDataList: mkAction({ + reducer: (state, action) => { + const { dataLists } = action.payload; + state.dataLists = dataLists; + }, + }), + setPageSize: mkAction({ + takeLatest: fetchDataListsSaga, + reducer: (state, action) => { + const { key, size } = action.payload; + state.dataLists[key].size = size; + state.dataLists[key].pageNumber = 0; + }, + }), + setPageNumber: mkAction({ + takeLatest: fetchDataListsSaga, + reducer: (state, action) => { + const { key, pageNumber } = action.payload; + state.dataLists[key].pageNumber = pageNumber; + }, + }), + setSort: mkAction({ + takeLatest: fetchDataListsSaga, + reducer: (state, action) => { + const { key, sortColumn, sortDirection } = action.payload; + state.dataLists[key].sortColumn = sortColumn; + state.dataLists[key].sortDirection = sortDirection; + }, + }), + }, +})); + +export const DataListsActions = dataListsSlice.actions; +export default dataListsSlice; diff --git a/src/altinn-app-frontend/src/shared/resources/dataLists/fetchDataListsSaga.ts b/src/altinn-app-frontend/src/shared/resources/dataLists/fetchDataListsSaga.ts new file mode 100644 index 0000000000..11ebb03631 --- /dev/null +++ b/src/altinn-app-frontend/src/shared/resources/dataLists/fetchDataListsSaga.ts @@ -0,0 +1,134 @@ +import { SortDirection } from '@altinn/altinn-design-system'; +import { call, fork, put, select } from 'redux-saga/effects'; +import type { SagaIterator } from 'redux-saga'; + +import { appLanguageStateSelector } from 'src/selectors/appLanguageStateSelector'; +import { listStateSelector } from 'src/selectors/dataListStateSelector'; +import { DataListsActions } from 'src/shared/resources/dataLists/dataListsSlice'; +import { getDataListsUrl } from 'src/utils/appUrlHelper'; +import { getDataListLookupKey, getDataListLookupKeys } from 'src/utils/dataList'; +import { selectNotNull } from 'src/utils/sagas'; +import type { IFormData } from 'src/features/form/data'; +import type { ILayouts } from 'src/features/form/layout'; +import type { + IDataList, + IDataLists, + IDataListsMetaData, + IFetchSpecificDataListSaga, +} from 'src/shared/resources/dataLists/index'; +import type { IRepeatingGroups, IRuntimeState } from 'src/types'; + +import { get } from 'altinn-shared/utils'; + +export const formLayoutSelector = (state: IRuntimeState): ILayouts | null => state.formLayout?.layouts; +export const formDataSelector = (state: IRuntimeState) => state.formData.formData; +export const dataListsSelector = (state: IRuntimeState): IDataLists => state.dataListState.dataLists; +export const dataListsWithIndexIndicatorsSelector = (state: IRuntimeState) => + state.dataListState.dataListsWithIndexIndicator; +export const instanceIdSelector = (state: IRuntimeState): string | undefined => state.instanceData.instance?.id; +export const repeatingGroupsSelector = (state: IRuntimeState) => state.formLayout?.uiConfig.repeatingGroups; + +export function* fetchDataListsSaga(): SagaIterator { + const layouts: ILayouts = yield selectNotNull(formLayoutSelector); + const repeatingGroups: IRepeatingGroups = yield selectNotNull(repeatingGroupsSelector); + const fetchedDataLists: string[] = []; + const dataListsWithIndexIndicators: IDataListsMetaData[] = []; + for (const layoutId of Object.keys(layouts)) { + for (const element of layouts[layoutId] || []) { + if (element.type !== 'List' || !element.id) { + continue; + } + + const { secure, id, dataListId, pagination } = element; + + const { keys, keyWithIndexIndicator } = getDataListLookupKeys({ + id: id, + secure, + repeatingGroups, + }); + if (keyWithIndexIndicator) { + dataListsWithIndexIndicators.push(keyWithIndexIndicator); + } + + if (!keys?.length) { + continue; + } + + for (const dataListsObject of keys) { + const { id, mapping, secure } = dataListsObject; + const lookupKey = getDataListLookupKey({ id, mapping }); + const paginationDefault = pagination ? pagination.default : 0; + if (id && !fetchedDataLists.includes(lookupKey) && dataListId) { + yield fork(fetchSpecificDataListSaga, { + id, + dataListId, + dataMapping: mapping, + secure, + paginationDefaultValue: paginationDefault, + }); + fetchedDataLists.push(lookupKey); + } + } + } + } + yield put( + DataListsActions.setDataListsWithIndexIndicators({ + dataListsWithIndexIndicators, + }), + ); +} + +export function* fetchSpecificDataListSaga({ + id, + dataMapping, + secure, + dataListId, + paginationDefaultValue, +}: IFetchSpecificDataListSaga): SagaIterator { + const key = getDataListLookupKey({ id: id, mapping: dataMapping }); + + const instanceId = yield select(instanceIdSelector); + try { + const metaData: IDataListsMetaData = { + id: id, + mapping: dataMapping, + secure, + dataListId, + }; + yield put(DataListsActions.fetching({ key, metaData })); + const formData: IFormData = yield select(formDataSelector); + const language = yield select(appLanguageStateSelector); + const dataList = yield select(listStateSelector); + + const pageSize = dataList.dataLists[id].size ? dataList.dataLists[id].size.toString() : paginationDefaultValue; + const pageNumber = dataList.dataLists[id].pageNumber ? dataList.dataLists[id].pageNumber.toString() : '0'; + const sortColumn = dataList.dataLists[id].sortColumn ? dataList.dataLists[id].sortColumn.toString() : null; + const sortDirection = dataList.dataLists[id].sortDirection + ? dataList.dataLists[id].sortDirection.toString() + : SortDirection.NotActive; + + const url = getDataListsUrl({ + dataListId, + formData, + language, + dataMapping, + secure, + instanceId, + pageSize, + pageNumber, + sortColumn, + sortDirection, + }); + + const dataLists: IDataList = yield call(get, url); + yield put( + DataListsActions.fetchFulfilled({ + key, + dataLists: dataLists.listItems, + metadata: dataLists._metaData, + }), + ); + } catch (error) { + yield put(DataListsActions.fetchRejected({ key: key, error })); + } +} diff --git a/src/altinn-app-frontend/src/shared/resources/dataLists/index.d.ts b/src/altinn-app-frontend/src/shared/resources/dataLists/index.d.ts new file mode 100644 index 0000000000..d850dd08c0 --- /dev/null +++ b/src/altinn-app-frontend/src/shared/resources/dataLists/index.d.ts @@ -0,0 +1,89 @@ +import type { SortDirection } from '@altinn/altinn-design-system'; + +import type { IDataLists, IDataListsMetaData } from 'src/types'; + +export interface IDataListsState { + error: Error | null; + dataLists: IDataLists; + dataListsWithIndexIndicator?: IDataListsMetaData[]; +} + +export interface IFetchDataListsFulfilledAction { + key: string; + dataLists: any; + metadata: any; +} + +export interface IFetchDataListsRejectedAction { + key: string; + error: Error; +} + +export interface IFetchingDataListsAction { + key: string; + metaData: IOptionsMetaData; +} + +export interface ISetDataListsWithIndexIndicators { + dataListsWithIndexIndicators: IDataListsMetaData[]; +} + +export interface ISetDataLists { + dataLists: IDataLists; +} + +export interface ISetDataListsPageSize { + key: string; + size: number; +} + +export interface ISetDataListsPageNumber { + key: string; + pageNumber: number; +} + +export interface ISetSort { + key: string; + sortColumn: string; + sortDirection: SortDirection; +} + +export interface IDataList { + listItems: Record[]; + _metaData: IDataListPaginationData; +} + +export interface IDataLists { + [key: string]: IDataListData; +} + +export interface IDataListActualData { + listItems: Record[]; +} + +export interface IDataListsMetaData { + id: string; + mapping?: IMapping; + loading?: boolean; + secure?: boolean; + size?: number; + pageNumber?: number; + paginationData?: IDataListPaginationData; + sortColumn?: string; + sortDirection?: SortDirection; + dataListId?: string; +} + +export interface IDataListPaginationData { + totaltItemsCount: number; +} + +export type IDataListData = IDataListActualData & IDataListsMetaData; + +export interface IFetchSpecificDataListSaga { + id: string; + dataListId: string; + dataMapping?: IMapping; + secure?: boolean; + paginationDefaultValue?: number; +} diff --git a/src/altinn-app-frontend/src/shared/resources/options/fetch/fetchOptionsSagas.ts b/src/altinn-app-frontend/src/shared/resources/options/fetch/fetchOptionsSagas.ts index b81828c86f..0c92a22811 100644 --- a/src/altinn-app-frontend/src/shared/resources/options/fetch/fetchOptionsSagas.ts +++ b/src/altinn-app-frontend/src/shared/resources/options/fetch/fetchOptionsSagas.ts @@ -38,7 +38,6 @@ export const repeatingGroupsSelector = (state: IRuntimeState) => state.formLayou export function* fetchOptionsSaga(): SagaIterator { const layouts: ILayouts = yield selectNotNull(formLayoutSelector); const repeatingGroups: IRepeatingGroups = yield selectNotNull(repeatingGroupsSelector); - const fetchedOptions: string[] = []; const optionsWithIndexIndicators: IOptionsMetaData[] = []; @@ -119,7 +118,6 @@ export function* checkIfOptionsShouldRefetchSaga({ }: PayloadAction): SagaIterator { const options: IOptions = yield select(optionsSelector); const optionsWithIndexIndicators = yield select(optionsWithIndexIndicatorsSelector); - let foundInExistingOptions = false; for (const optionsKey of Object.keys(options)) { const { mapping, id, secure } = options[optionsKey] || {}; diff --git a/src/altinn-app-frontend/src/shared/resources/options/index.d.ts b/src/altinn-app-frontend/src/shared/resources/options/index.d.ts index 2cd4fe1810..50f9bf53bb 100644 --- a/src/altinn-app-frontend/src/shared/resources/options/index.d.ts +++ b/src/altinn-app-frontend/src/shared/resources/options/index.d.ts @@ -1,4 +1,4 @@ -import type { IOption, IOptions, IOptionsMetaData } from 'src/types'; +import type { IOption, IOption, IOptions, IOptions, IOptionsMetaData, IOptionsMetaData } from 'src/types'; export interface IOptionsState { error: Error | null; diff --git a/src/altinn-app-frontend/src/utils/appUrlHelper.test.ts b/src/altinn-app-frontend/src/utils/appUrlHelper.test.ts index cca0c0b0a7..b0183f6a52 100644 --- a/src/altinn-app-frontend/src/utils/appUrlHelper.test.ts +++ b/src/altinn-app-frontend/src/utils/appUrlHelper.test.ts @@ -1,9 +1,12 @@ +import { SortDirection } from '@altinn/altinn-design-system'; + import { dataElementUrl, fileTagUrl, fileUploadUrl, getCalculatePageOrderUrl, getCreateInstancesUrl, + getDataListsUrl, getDataValidationUrl, getEnvironmentLoginUrl, getFetchFormDynamicsUrl, @@ -301,6 +304,73 @@ describe('Frontend urlHelper.ts', () => { }); }); + describe('getDataListsUrl', () => { + it('should return correct url when no language, pagination or sorting parameters are provided', () => { + const result = getDataListsUrl({ dataListId: 'country' }); + expect(result).toEqual('https://local.altinn.cloud/ttd/test/api/datalists/country'); + }); + + it('should return correct url when a language parameter is provided, but no pagination or sorting parameters are provided', () => { + const result = getDataListsUrl({ dataListId: 'country', language: 'no' }); + expect(result).toEqual('https://local.altinn.cloud/ttd/test/api/datalists/country?language=no'); + }); + + it('should return correct url when only sorting paramters are provided', () => { + const result = getDataListsUrl({ + dataListId: 'country', + sortColumn: 'id', + sortDirection: SortDirection.Descending, + }); + expect(result).toEqual( + 'https://local.altinn.cloud/ttd/test/api/datalists/country?sortColumn=id&sortDirection=desc', + ); + }); + + it('should return correct url when only pagination paramters are provided', () => { + const result = getDataListsUrl({ + dataListId: 'country', + pageSize: '10', + pageNumber: '2', + }); + expect(result).toEqual('https://local.altinn.cloud/ttd/test/api/datalists/country?size=10&page=2'); + }); + + it('should return correct url when formData/dataMapping is provided', () => { + const result = getDataListsUrl({ + dataListId: 'country', + formData: { + country: 'Norway', + }, + dataMapping: { + country: 'selectedCountry', + }, + }); + + expect(result).toEqual('https://local.altinn.cloud/ttd/test/api/datalists/country?selectedCountry=Norway'); + }); + + it('should render correct url when formData/Mapping, language, pagination and sorting paramters are provided', () => { + const result = getDataListsUrl({ + dataListId: 'country', + formData: { + country: 'Norway', + }, + dataMapping: { + country: 'selectedCountry', + }, + pageSize: '10', + pageNumber: '2', + sortColumn: 'id', + sortDirection: SortDirection.Descending, + language: 'no', + }); + + expect(result).toEqual( + 'https://local.altinn.cloud/ttd/test/api/datalists/country?language=no&size=10&page=2&sortColumn=id&sortDirection=desc&selectedCountry=Norway', + ); + }); + }); + describe('getRulehandlerUrl', () => { it('should return default when no parameter is passed', () => { const result = getRulehandlerUrl(); diff --git a/src/altinn-app-frontend/src/utils/appUrlHelper.ts b/src/altinn-app-frontend/src/utils/appUrlHelper.ts index a8c76466b3..e1dd431b14 100644 --- a/src/altinn-app-frontend/src/utils/appUrlHelper.ts +++ b/src/altinn-app-frontend/src/utils/appUrlHelper.ts @@ -1,3 +1,5 @@ +import type { SortDirection } from '@altinn/altinn-design-system'; + import { mapFormData } from 'src/utils/databindings'; import type { IFormData } from 'src/features/form/data'; import type { IAltinnWindow, IMapping } from 'src/types'; @@ -186,6 +188,70 @@ export const getOptionsUrl = ({ if (language) { params.language = language; } + if (formData && dataMapping) { + const mapped = mapFormData(formData, dataMapping); + + params = { + ...params, + ...mapped, + }; + } + + url.search = new URLSearchParams(params).toString(); + return url.toString(); +}; +export interface IGetDataListsUrlParams { + dataListId: string; + dataMapping?: IMapping; + formData?: IFormData; + language?: string; + secure?: boolean; + instanceId?: string; + pageSize?: string; + pageNumber?: string; + sortDirection?: SortDirection; + sortColumn?: string; +} + +export const getDataListsUrl = ({ + dataListId, + dataMapping, + formData, + language, + pageSize, + pageNumber, + sortDirection, + sortColumn, + secure, + instanceId, +}: IGetDataListsUrlParams) => { + let url: URL; + if (secure) { + url = new URL(`${appPath}/instances/${instanceId}/datalists/${dataListId}`); + } else { + url = new URL(`${appPath}/api/datalists/${dataListId}`); + } + let params: Record = {}; + + if (language) { + params.language = language; + } + + if (pageSize) { + params.size = pageSize; + } + + if (pageNumber !== undefined) { + params.page = pageNumber; + } + + if (sortColumn) { + params.sortColumn = sortColumn; + } + + if (sortDirection) { + params.sortDirection = sortDirection; + } if (formData && dataMapping) { const mapped = mapFormData(formData, dataMapping); diff --git a/src/altinn-app-frontend/src/utils/dataList.ts b/src/altinn-app-frontend/src/utils/dataList.ts new file mode 100644 index 0000000000..85d3f0628a --- /dev/null +++ b/src/altinn-app-frontend/src/utils/dataList.ts @@ -0,0 +1,63 @@ +import { + getBaseGroupDataModelBindingFromKeyWithIndexIndicators, + getIndexCombinations, + keyHasIndexIndicators, + replaceIndexIndicatorsWithIndexes, +} from 'src/utils/databindings'; +import type { IDataListsMetaData } from 'src/shared/resources/dataLists/index'; +import type { IMapping, IRepeatingGroups } from 'src/types'; + +interface IGetDataListLookupKeysParam extends IDataListsMetaData { + repeatingGroups: IRepeatingGroups | null; +} + +interface IDataListLookupKeys { + keys: IDataListsMetaData[]; + keyWithIndexIndicator?: IDataListsMetaData; +} + +export function getDataListLookupKey({ id, mapping }: IDataListsMetaData) { + if (!mapping) { + return id; + } + + return JSON.stringify({ id, mapping }); +} + +export function getDataListLookupKeys({ + id, + mapping, + secure, + repeatingGroups, +}: IGetDataListLookupKeysParam): IDataListLookupKeys { + const lookupKeys: IDataListsMetaData[] = []; + + const _mapping = mapping || {}; + const mappingsWithIndexIndicators = Object.keys(_mapping).filter((key) => keyHasIndexIndicators(key)); + if (mappingsWithIndexIndicators.length) { + // create lookup keys for each index of the relevant repeating group + mappingsWithIndexIndicators.forEach((mappingKey) => { + const baseGroupBindings = getBaseGroupDataModelBindingFromKeyWithIndexIndicators(mappingKey); + const possibleCombinations = getIndexCombinations(baseGroupBindings, repeatingGroups); + for (const possibleCombination of possibleCombinations) { + const newMappingKey = replaceIndexIndicatorsWithIndexes(mappingKey, possibleCombination); + const newMapping: IMapping = { + ..._mapping, + }; + delete newMapping[mappingKey]; + newMapping[newMappingKey] = _mapping[mappingKey]; + lookupKeys.push({ id, mapping: newMapping, secure }); + } + }); + + return { + keys: lookupKeys, + keyWithIndexIndicator: { id, mapping, secure }, + }; + } + + lookupKeys.push({ id, mapping, secure }); + return { + keys: lookupKeys, + }; +} diff --git a/src/altinn-app-frontend/src/utils/databindings.ts b/src/altinn-app-frontend/src/utils/databindings.ts index 1b40c453ed..1a032c5a07 100644 --- a/src/altinn-app-frontend/src/utils/databindings.ts +++ b/src/altinn-app-frontend/src/utils/databindings.ts @@ -94,7 +94,10 @@ export function replaceIndexIndicatorsWithIndexes(key: string, indexes: number[] } Would produce the following output: [[0, 0], [0, 1], [0, 2], [1, 0]] */ -export function getIndexCombinations(baseGroupBindings: string[], repeatingGroups: IRepeatingGroups): number[][] { +export function getIndexCombinations( + baseGroupBindings: string[], + repeatingGroups: IRepeatingGroups | null, +): number[][] { const combinations: number[][] = []; if (!baseGroupBindings?.length || !repeatingGroups) { diff --git a/src/altinn-app-frontend/src/utils/formComponentUtils.ts b/src/altinn-app-frontend/src/utils/formComponentUtils.ts index b9e9e1641d..df348e6745 100644 --- a/src/altinn-app-frontend/src/utils/formComponentUtils.ts +++ b/src/altinn-app-frontend/src/utils/formComponentUtils.ts @@ -114,6 +114,11 @@ export const getDisplayFormDataForComponent = ( const formDataObj = {}; Object.keys(component.dataModelBindings).forEach((key: any) => { const binding = component.dataModelBindings && component.dataModelBindings[key]; + + if (component.type == 'List' && component.bindingToShowInSummary !== binding) { + return; + } + formDataObj[key] = getDisplayFormData( binding, component, diff --git a/src/shared/src/language/texts/en.ts b/src/shared/src/language/texts/en.ts index 30be7abef9..585aac1420 100644 --- a/src/shared/src/language/texts/en.ts +++ b/src/shared/src/language/texts/en.ts @@ -268,5 +268,13 @@ export function en() { no_options: 'No options available', placeholder: 'Select...', }, + list_component: { + rowsPerPage: 'Rows per page', + of: 'of', + navigateFirstPage: 'Navigate to the first page in the table', + previousPage: 'Previous page in the table', + nextPage: 'Next page in the table', + navigateLastPage: 'Navigate to the last page in the table', + }, }; } diff --git a/src/shared/src/language/texts/nb.ts b/src/shared/src/language/texts/nb.ts index 0ff9a4ae0a..edfc8cba01 100644 --- a/src/shared/src/language/texts/nb.ts +++ b/src/shared/src/language/texts/nb.ts @@ -269,5 +269,13 @@ export function nb() { no_options: 'Ingen valg tilgjengelig', placeholder: 'Velg...', }, + list_component: { + rowsPerPage: 'Rader per side', + of: 'av', + navigateFirstPage: 'Naviger til første side i tabell', + previousPage: 'Forrige side i tabell', + nextPage: 'Neste side i tabell', + navigateLastPage: 'Naviger til siste side i tabell', + }, }; } diff --git a/src/shared/src/language/texts/nn.ts b/src/shared/src/language/texts/nn.ts index 65c5d1b4fa..087dee5119 100644 --- a/src/shared/src/language/texts/nn.ts +++ b/src/shared/src/language/texts/nn.ts @@ -269,5 +269,13 @@ export function nn() { no_options: 'Ingen valg tilgjengelig', placeholder: 'Velg...', }, + list_component: { + rowsPerPage: 'Rader per side', + of: 'av', + navigateFirstPage: 'Naviger til første side i tabell', + previousPage: 'Førre side i tabell', + nextPage: 'Neste side i tabell', + navigateLastPage: 'Naviger til siste side i tabell', + }, }; } diff --git a/test/cypress/e2e/integration/app-frontend/confirmation.js b/test/cypress/e2e/integration/app-frontend/confirmation.js index 867d370ae7..fb325dccbf 100644 --- a/test/cypress/e2e/integration/app-frontend/confirmation.js +++ b/test/cypress/e2e/integration/app-frontend/confirmation.js @@ -14,7 +14,7 @@ describe('Confirm', () => { cy.get(appFrontend.confirm.body).should('contain.text', texts.confirmBody); cy.get(appFrontend.confirm.receiptPdf) .find('a') - .should('have.length', 4) + .should('have.length', 5) // This is the number of process data tasks .first() .should('contain.text', `${appFrontend.apps.frontendTest}.pdf`); diff --git a/test/cypress/e2e/integration/app-frontend/list-component.js b/test/cypress/e2e/integration/app-frontend/list-component.js new file mode 100644 index 0000000000..4a37fab7b2 --- /dev/null +++ b/test/cypress/e2e/integration/app-frontend/list-component.js @@ -0,0 +1,51 @@ +import { Datalist } from '../../pageobjects/datalist'; + +const dataListPage = new Datalist(); + +describe('List component', () => { + it('Dynamic list is loaded correctly', () => { + cy.goto('datalist'); + cy.get(dataListPage.tableBody).first().should('be.visible'); + cy.get(dataListPage.tableBody).contains('Caroline').parent('td').parent('tr').within(() => { + cy.get('td').eq(2).contains(28); + cy.get('td').eq(3).contains('Utvikler'); + }); + cy.get(dataListPage.tableBody).contains('Kåre').parent('td').parent('tr').within(() => { + cy.get('td').eq(2).contains(37); + cy.get('td').eq(3).contains('Sykepleier'); + }); + cy.get(dataListPage.tableBody).contains('Petter').parent('td').parent('tr').within(() => { + cy.get('td').eq(2).contains(19); + cy.get('td').eq(3).contains('Personlig trener'); + }); + }); + + it('It is possible to select a row', () => { + cy.goto('datalist'); + cy.get(dataListPage.tableBody).first().should('be.visible'); + cy.get(dataListPage.tableBody).contains('Caroline').parent('td').parent('tr').should('not.have.class', dataListPage.selectedRowClass); + cy.get(dataListPage.tableBody).contains('Caroline').parent('td').parent('tr').click().should('have.class', dataListPage.selectedRowClass); + cy.get(dataListPage.tableBody).contains('Kåre').parent('td').parent('tr').click().get(dataListPage.tableBody).contains('Caroline').parent('td').parent('tr').should('not.have.class', dataListPage.selectedRowClass); + }); + + it('When selecting number of rows to show this is updated correctly', () => { + cy.goto('datalist'); + cy.get(dataListPage.listComponent).get(dataListPage.selectComponent).select('10').should('have.value', 10); + cy.get(dataListPage.listComponent).get(dataListPage.selectComponent).select('10').get(dataListPage.tableBody).find('tr').its('length').should('eq', 10); + }); + + it('When navigation between pages the expected data is shown in the first table row', () => { + cy.goto('datalist'); + cy.get(dataListPage.navigateNextButton).should('not.be.disabled'); + cy.get(dataListPage.tableBody).first().first().contains('Caroline'); + cy.get(dataListPage.navigateNextButton).click().get(dataListPage.tableBody).first().first().contains('Hans'); + cy.get(dataListPage.navigatePreviousButton).click().get(dataListPage.tableBody).first().first().contains('Caroline'); + }); + + it('Sorting works as expected', () => { + cy.goto('datalist'); + cy.get(dataListPage.sortButton).click().get(dataListPage.tableBody).first().first().contains('Hans'); + cy.get(dataListPage.sortButton).click().get(dataListPage.tableBody).first().first().contains('Petter'); + }); + +}); diff --git a/test/cypress/e2e/integration/app-frontend/mobile.js b/test/cypress/e2e/integration/app-frontend/mobile.js index 15761b4ff6..a2a114c6be 100644 --- a/test/cypress/e2e/integration/app-frontend/mobile.js +++ b/test/cypress/e2e/integration/app-frontend/mobile.js @@ -5,10 +5,12 @@ import AppFrontend from '../../pageobjects/app-frontend'; import Common from '../../pageobjects/common'; import * as texts from '../../fixtures/texts.json'; import { Likert } from '../../pageobjects/likert'; +import { Datalist } from '../../pageobjects/datalist'; const appFrontend = new AppFrontend(); const mui = new Common(); const likertPage = new Likert(); +const dataListPage = new Datalist(); describe('Mobile', () => { beforeEach(() => { @@ -28,7 +30,7 @@ describe('Mobile', () => { expect(width).to.be.gt(268); expect(width).to.be.lt(289); }); - cy.get(appFrontend.sendinButton).click(); + cy.sendIn(); cy.wait('@getLayoutGroup'); cy.contains(mui.button, texts.next).click(); cy.get(appFrontend.group.showGroupToContinue).then((checkbox) => { @@ -46,10 +48,15 @@ describe('Mobile', () => { cy.get(appFrontend.navMenu).should('not.have.attr', 'hidden'); cy.get(appFrontend.navMenu).find('li > button').last().click(); cy.get(appFrontend.navMenu).should('have.attr', 'hidden'); - cy.get(appFrontend.sendinButton).click(); + cy.sendIn(); likertPage.selectRequiredRadiosInMobile(); - cy.sendIn('likert'); + cy.sendIn(); + cy.get(dataListPage.tableBody).contains('Caroline').parent('td').parent('tr').click(); + cy.contains(mui.button, texts.next).click(); + cy.sendIn(); + cy.get(appFrontend.confirm.sendIn).should('be.visible').click(); + cy.get(appFrontend.confirm.sendIn).should('not.exist'); cy.get(appFrontend.receipt.container).should('be.visible'); cy.get(appFrontend.receipt.linkToArchive).should('be.visible'); }); diff --git a/test/cypress/e2e/integration/app-frontend/receipt.js b/test/cypress/e2e/integration/app-frontend/receipt.js index 5447f9cc90..c88cdbf9d3 100644 --- a/test/cypress/e2e/integration/app-frontend/receipt.js +++ b/test/cypress/e2e/integration/app-frontend/receipt.js @@ -22,7 +22,7 @@ describe('Receipt', () => { cy.get(appFrontend.receipt.linkToArchive).should('be.visible'); cy.get(appFrontend.receipt.pdf) .find('a') - .should('have.length', 4) + .should('have.length', 5) // This is the number of process data tasks .first() .should('contain.text', `${appFrontend.apps.frontendTest}.pdf`); diff --git a/test/cypress/e2e/pageobjects/datalist.js b/test/cypress/e2e/pageobjects/datalist.js new file mode 100644 index 0000000000..871a8aeeb9 --- /dev/null +++ b/test/cypress/e2e/pageobjects/datalist.js @@ -0,0 +1,11 @@ +export class Datalist { + constructor() { + this.listComponent = '#form-content-listComponent'; + this.selectComponent ='[class^="Pagination-module_pagination-wrapper"] select'; + this.tableBody = '[class=TableBody-module_TableBody__tqUvt]'; + this.navigateNextButton = '[data-testid=pagination-next-icon]'; + this.navigatePreviousButton = '[data-testid=pagination-previous-icon]'; + this.sortButton = '[data-testid="sort-icon"]'; + this.selectedRowClass = 'TableRow-module_table-row--selected__0i2on'; + } +} diff --git a/test/cypress/e2e/support/index.d.ts b/test/cypress/e2e/support/index.d.ts index 123df6ffeb..e6b9c772e6 100644 --- a/test/cypress/e2e/support/index.d.ts +++ b/test/cypress/e2e/support/index.d.ts @@ -1,6 +1,6 @@ /// -export type FrontendTestTask = 'message' | 'changename' | 'group' | 'likert' | 'confirm'; +export type FrontendTestTask = 'message' | 'changename' | 'group' | 'likert' | 'datalist' | 'confirm'; export type GotoMode = 'fast' | 'with-data'; declare namespace Cypress { diff --git a/test/cypress/e2e/support/navigation.js b/test/cypress/e2e/support/navigation.js index c757820682..35ea5b603f 100644 --- a/test/cypress/e2e/support/navigation.js +++ b/test/cypress/e2e/support/navigation.js @@ -2,9 +2,11 @@ import AppFrontend from '../pageobjects/app-frontend'; import Common from '../pageobjects/common'; import * as texts from '../fixtures/texts.json'; import { Likert } from '../pageobjects/likert'; +import { Datalist } from '../pageobjects/datalist'; const appFrontend = new AppFrontend(); const mui = new Common(); +const dataListPage = new Datalist(); /** * This object contains a valid data model for each of the tasks that can be fast-skipped. To produce one such data @@ -85,6 +87,10 @@ const completeFormFast = { completeFormSlow.likert(); genericSendIn(); }, + datalist: () => { + cy.contains(mui.button, texts.next).click(); + genericSendIn(); + }, confirm: () => { genericSendIn(); }, @@ -178,6 +184,10 @@ const completeFormSlow = { const likertPage = new Likert(); likertPage.selectRequiredRadios(); }, + datalist: () => { + cy.get(dataListPage.tableBody).contains('Caroline').parent('td').parent('tr').click(); + cy.contains(mui.button, texts.next).click(); + }, confirm: () => {}, }; @@ -186,7 +196,11 @@ const sendInTask = { changename: genericSendIn, group: genericSendIn, likert: genericSendIn, - confirm: genericSendIn, + datalist: genericSendIn, + confirm: () => { + cy.get(appFrontend.confirm.sendIn).should('be.visible').click(); + cy.get(appFrontend.confirm.sendIn).should('not.exist'); + }, }; let currentTask = undefined;