diff --git a/optaweb-employee-rostering-frontend/package-lock.json b/optaweb-employee-rostering-frontend/package-lock.json index 19ce50b92..45045344c 100644 --- a/optaweb-employee-rostering-frontend/package-lock.json +++ b/optaweb-employee-rostering-frontend/package-lock.json @@ -8733,6 +8733,11 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz", "integrity": "sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==" }, + "immutable": { + "version": "4.0.0-rc.12", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0-rc.12.tgz", + "integrity": "sha512-0M2XxkZLx/mi3t8NVwIm1g8nHoEmM9p9UBl/G9k4+hm0kBgOVdMV/B3CY5dQ8qG8qc80NN4gDV4HQv6FTJ5q7A==" + }, "import-cwd": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", diff --git a/optaweb-employee-rostering-frontend/package.json b/optaweb-employee-rostering-frontend/package.json index 43e30f0bb..2d74fc891 100644 --- a/optaweb-employee-rostering-frontend/package.json +++ b/optaweb-employee-rostering-frontend/package.json @@ -12,6 +12,7 @@ "i18next": "^19.1.0", "i18next-browser-languagedetector": "^4.0.1", "i18next-xhr-backend": "^3.2.2", + "immutable": "^4.0.0-rc.12", "moment": "^2.24.0", "node-sass": "^4.14.1", "react": "^16.13.1", diff --git a/optaweb-employee-rostering-frontend/src/index.tsx b/optaweb-employee-rostering-frontend/src/index.tsx index d55e72af0..8e8bb82ec 100644 --- a/optaweb-employee-rostering-frontend/src/index.tsx +++ b/optaweb-employee-rostering-frontend/src/index.tsx @@ -23,6 +23,7 @@ import { SpinnerIcon } from '@patternfly/react-icons'; import './index.css'; import { I18nextProvider } from 'react-i18next'; import { configureStore } from 'store'; +import { List } from 'immutable'; import App from './ui/App'; // import i18n (needs to be bundled) @@ -39,7 +40,7 @@ const store = configureStore({ }, { tenantData: { currentTenantId: windowTenantId, - tenantList: [], + tenantList: List(), timezoneList: [], }, }); diff --git a/optaweb-employee-rostering-frontend/src/store/admin/admin.test.ts b/optaweb-employee-rostering-frontend/src/store/admin/admin.test.ts index 06f8e02a2..1ab22cf1a 100644 --- a/optaweb-employee-rostering-frontend/src/store/admin/admin.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/admin/admin.test.ts @@ -18,6 +18,7 @@ import * as tenantOperations from 'store/tenant/operations'; import { onPost } from 'store/rest/RestTestUtils'; import { alert } from 'store/alert'; import { doNothing } from 'types'; +import { List } from 'immutable'; import { mockStore } from '../mockStore'; import { AppState } from '../types'; import * as adminOperations from './operations'; @@ -51,7 +52,7 @@ describe('Contract operations', () => { const state: Partial = { tenantData: { currentTenantId: 0, - tenantList: [], + tenantList: List(), timezoneList: ['America/Toronto'], }, }; diff --git a/optaweb-employee-rostering-frontend/src/store/alert/alert.test.ts b/optaweb-employee-rostering-frontend/src/store/alert/alert.test.ts index 265c788c0..4aa534d38 100644 --- a/optaweb-employee-rostering-frontend/src/store/alert/alert.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/alert/alert.test.ts @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { withElement, withoutElementWithId } from 'util/ImmutableCollectionOperations'; import { ServerSideExceptionInfo, BasicObject } from 'types'; +import { List } from 'immutable'; import { mockStore } from '../mockStore'; import { AppState } from '../types'; import * as actions from './actions'; @@ -23,15 +23,15 @@ import { AlertInfo, AlertComponent } from './types'; const state: Partial = { alerts: { - alertList: [{ + alertList: List([{ id: 0, createdAt: new Date(), i18nKey: 'alert1', - variant: 'info', + variant: 'info' as 'info', params: {}, components: [], componentProps: [], - }], + }]), idGeneratorIndex: 1, }, }; @@ -162,12 +162,14 @@ describe('Alert reducers', () => { it('add an alert', () => { expect( reducer(state.alerts, actions.addAlert(addedAlert)), - ).toEqual({ idGeneratorIndex: 2, alertList: withElement(storeState.alerts.alertList, { ...addedAlert, id: 1 }) }); + ).toEqual({ idGeneratorIndex: 2, alertList: storeState.alerts.alertList.push({ ...addedAlert, id: 1 }) }); }); it('remove an alert', () => { expect( reducer(state.alerts, actions.removeAlert(removedAlertId)), - ).toEqual({ ...state.alerts, alertList: withoutElementWithId(storeState.alerts.alertList, removedAlertId) }); + ).toEqual({ ...state.alerts, + alertList: storeState.alerts.alertList + .filterNot(a => a.id === removedAlertId) }); }); }); diff --git a/optaweb-employee-rostering-frontend/src/store/alert/reducers.ts b/optaweb-employee-rostering-frontend/src/store/alert/reducers.ts index ffd8f58e3..76bd301f6 100644 --- a/optaweb-employee-rostering-frontend/src/store/alert/reducers.ts +++ b/optaweb-employee-rostering-frontend/src/store/alert/reducers.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { withElement, withoutElementWithId } from 'util/ImmutableCollectionOperations'; +import { List } from 'immutable'; import { ActionType, AlertList, AlertAction } from './types'; export const initialState: AlertList = { - alertList: [], + alertList: List(), idGeneratorIndex: 0, }; @@ -27,10 +27,10 @@ const alertReducer = (state = initialState, action: AlertAction): AlertList => { case ActionType.ADD_ALERT: { const alertWithId = { ...action.alertInfo, id: state.idGeneratorIndex }; const nextIndex = state.idGeneratorIndex + 1; - return { ...state, idGeneratorIndex: nextIndex, alertList: withElement(state.alertList, alertWithId) }; + return { ...state, idGeneratorIndex: nextIndex, alertList: state.alertList.push(alertWithId) }; } case ActionType.REMOVE_ALERT: { - return { ...state, alertList: withoutElementWithId(state.alertList, action.id) }; + return { ...state, alertList: state.alertList.filterNot(alert => alert.id === action.id) }; } default: return state; diff --git a/optaweb-employee-rostering-frontend/src/store/alert/types.ts b/optaweb-employee-rostering-frontend/src/store/alert/types.ts index 3604e2aa0..d2c5ea9f2 100644 --- a/optaweb-employee-rostering-frontend/src/store/alert/types.ts +++ b/optaweb-employee-rostering-frontend/src/store/alert/types.ts @@ -16,6 +16,7 @@ import { Action } from 'redux'; import { BasicObject } from 'types'; +import { List } from 'immutable'; export enum ActionType { ADD_ALERT = 'ADD_ALERT', @@ -47,6 +48,6 @@ export interface AlertInfo { } export interface AlertList { - readonly alertList: AlertInfo[]; + readonly alertList: List; readonly idGeneratorIndex: number; } diff --git a/optaweb-employee-rostering-frontend/src/store/contract/contract.test.ts b/optaweb-employee-rostering-frontend/src/store/contract/contract.test.ts index 0f78e0593..88d71842d 100644 --- a/optaweb-employee-rostering-frontend/src/store/contract/contract.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/contract/contract.test.ts @@ -15,10 +15,7 @@ */ import { alert } from 'store/alert'; -import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, -} from 'util/ImmutableCollectionOperations'; +import { createIdMapFromList } from 'util/ImmutableCollectionOperations'; import { onGet, onPost, onDelete } from 'store/rest/RestTestUtils'; import { Contract } from 'domain/Contract'; import { mockStore } from '../mockStore'; @@ -29,8 +26,8 @@ import reducer, { contractSelectors, contractOperations } from './index'; const state: Partial = { contractList: { isLoading: false, - contractMapById: new Map([ - [0, { + contractMapById: createIdMapFromList([ + { tenantId: 0, id: 0, version: 0, @@ -39,8 +36,8 @@ const state: Partial = { maximumMinutesPerWeek: null, maximumMinutesPerMonth: null, maximumMinutesPerYear: null, - }], - [1, { + }, + { tenantId: 0, id: 1, version: 0, @@ -49,8 +46,8 @@ const state: Partial = { maximumMinutesPerWeek: 100, maximumMinutesPerMonth: null, maximumMinutesPerYear: null, - }], - [2, { + }, + { tenantId: 0, id: 2, version: 0, @@ -59,7 +56,7 @@ const state: Partial = { maximumMinutesPerWeek: null, maximumMinutesPerMonth: null, maximumMinutesPerYear: 100, - }], + }, ]), }, }; @@ -220,19 +217,19 @@ describe('Contract reducers', () => { expect( reducer(state.contractList, actions.addContract(addedContract)), ).toEqual({ ...state.contractList, - contractMapById: mapWithElement(storeState.contractList.contractMapById, addedContract) }); + contractMapById: storeState.contractList.contractMapById.set(addedContract.id as number, addedContract) }); }); it('remove contract', () => { expect( reducer(state.contractList, actions.removeContract(deletedContract)), ).toEqual({ ...state.contractList, - contractMapById: mapWithoutElement(storeState.contractList.contractMapById, deletedContract) }); + contractMapById: storeState.contractList.contractMapById.delete(deletedContract.id as number) }); }); it('update contract', () => { expect( reducer(state.contractList, actions.updateContract(updatedContract)), ).toEqual({ ...state.contractList, - contractMapById: mapWithUpdatedElement(storeState.contractList.contractMapById, updatedContract) }); + contractMapById: storeState.contractList.contractMapById.set(updatedContract.id as number, updatedContract) }); }); it('refresh contract list', () => { expect( diff --git a/optaweb-employee-rostering-frontend/src/store/contract/reducers.ts b/optaweb-employee-rostering-frontend/src/store/contract/reducers.ts index 8cbd316ea..d12a51fc2 100644 --- a/optaweb-employee-rostering-frontend/src/store/contract/reducers.ts +++ b/optaweb-employee-rostering-frontend/src/store/contract/reducers.ts @@ -14,17 +14,15 @@ * limitations under the License. */ -import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, -} from 'util/ImmutableCollectionOperations'; +import { createIdMapFromList } from 'util/ImmutableCollectionOperations'; import DomainObjectView from 'domain/DomainObjectView'; import { Contract } from 'domain/Contract'; +import { Map } from 'immutable'; import { ActionType, ContractList, ContractAction } from './types'; export const initialState: ContractList = { isLoading: true, - contractMapById: new Map>(), + contractMapById: Map>(), }; const contractReducer = (state = initialState, action: ContractAction): ContractList => { @@ -32,14 +30,12 @@ const contractReducer = (state = initialState, action: ContractAction): Contract case ActionType.SET_CONTRACT_LIST_LOADING: { return { ...state, isLoading: action.isLoading }; } - case ActionType.ADD_CONTRACT: { - return { ...state, contractMapById: mapWithElement(state.contractMapById, action.contract) }; + case ActionType.ADD_CONTRACT: + case ActionType.UPDATE_CONTRACT: { + return { ...state, contractMapById: state.contractMapById.set(action.contract.id as number, action.contract) }; } case ActionType.REMOVE_CONTRACT: { - return { ...state, contractMapById: mapWithoutElement(state.contractMapById, action.contract) }; - } - case ActionType.UPDATE_CONTRACT: { - return { ...state, contractMapById: mapWithUpdatedElement(state.contractMapById, action.contract) }; + return { ...state, contractMapById: state.contractMapById.remove(action.contract.id as number) }; } case ActionType.REFRESH_CONTRACT_LIST: { return { ...state, contractMapById: createIdMapFromList(action.contractList) }; diff --git a/optaweb-employee-rostering-frontend/src/store/contract/selectors.ts b/optaweb-employee-rostering-frontend/src/store/contract/selectors.ts index 50132bafa..6183c3efa 100644 --- a/optaweb-employee-rostering-frontend/src/store/contract/selectors.ts +++ b/optaweb-employee-rostering-frontend/src/store/contract/selectors.ts @@ -14,6 +14,8 @@ * limitations under the License. */ import { Contract } from 'domain/Contract'; +import { Map } from 'immutable'; +import DomainObjectView from 'domain/DomainObjectView'; import { AppState } from '../types'; export const getContractById = (state: AppState, id: number): Contract => { @@ -23,7 +25,7 @@ export const getContractById = (state: AppState, id: number): Contract => { return state.contractList.contractMapById.get(id) as Contract; }; -let oldContractMapById: Map | null = null; +let oldContractMapById: Map> | null = null; let contractListForOldContractMapById: Contract[] | null = null; export const getContractList = (state: AppState): Contract[] => { @@ -33,11 +35,11 @@ export const getContractList = (state: AppState): Contract[] => { if (oldContractMapById === state.contractList.contractMapById && contractListForOldContractMapById !== null) { return contractListForOldContractMapById; } - - const out: Contract[] = []; - state.contractList.contractMapById.forEach((value, key) => out.push(getContractById(state, key))); + const out = state.contractList.contractMapById.keySeq().map(id => getContractById(state, id)) + .sortBy(contract => contract.name).toList(); oldContractMapById = state.contractList.contractMapById; - contractListForOldContractMapById = out; - return out; + contractListForOldContractMapById = out.toArray(); + + return contractListForOldContractMapById; }; diff --git a/optaweb-employee-rostering-frontend/src/store/contract/types.ts b/optaweb-employee-rostering-frontend/src/store/contract/types.ts index a1b3f9c34..98c14950e 100644 --- a/optaweb-employee-rostering-frontend/src/store/contract/types.ts +++ b/optaweb-employee-rostering-frontend/src/store/contract/types.ts @@ -17,6 +17,7 @@ import { Action } from 'redux'; import { Contract } from 'domain/Contract'; import DomainObjectView from 'domain/DomainObjectView'; +import { Map } from 'immutable'; export enum ActionType { ADD_CONTRACT = 'ADD_CONTRACT', diff --git a/optaweb-employee-rostering-frontend/src/store/employee/employee.test.ts b/optaweb-employee-rostering-frontend/src/store/employee/employee.test.ts index c50729c60..c852a14d4 100644 --- a/optaweb-employee-rostering-frontend/src/store/employee/employee.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/employee/employee.test.ts @@ -15,10 +15,7 @@ */ import { alert } from 'store/alert'; -import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, -} from 'util/ImmutableCollectionOperations'; +import { createIdMapFromList, mapDomainObjectToView } from 'util/ImmutableCollectionOperations'; import { onGet, onPost, onDelete, onUploadFile } from 'store/rest/RestTestUtils'; import { Employee } from 'domain/Employee'; import * as skillActions from 'store/skill/actions'; @@ -31,8 +28,8 @@ import reducer, { employeeSelectors, employeeOperations } from './index'; const state: Partial = { employeeList: { isLoading: false, - employeeMapById: new Map([ - [1, { + employeeMapById: createIdMapFromList([ + { tenantId: 0, id: 1, version: 0, @@ -41,8 +38,8 @@ const state: Partial = { contract: 1, shortId: 'e1', color: '#FFFFFF', - }], - [2, { + }, + { tenantId: 0, id: 2, version: 0, @@ -51,13 +48,13 @@ const state: Partial = { contract: 1, shortId: 'e2', color: '#FFFFFF', - }], + }, ]), }, contractList: { isLoading: false, - contractMapById: new Map([ - [1, { + contractMapById: createIdMapFromList([ + { tenantId: 0, id: 1, version: 0, @@ -66,18 +63,18 @@ const state: Partial = { maximumMinutesPerWeek: null, maximumMinutesPerMonth: 10, maximumMinutesPerYear: null, - }], + }, ]), }, skillList: { isLoading: false, - skillMapById: new Map([ - [3, { + skillMapById: createIdMapFromList([ + { tenantId: 0, id: 3, version: 0, name: 'Skill 3', - }], + }, ]), }, }; @@ -276,19 +273,21 @@ describe('Employee reducers', () => { expect( reducer(state.employeeList, actions.addEmployee(addedEmployee)), ).toEqual({ ...state.employeeList, - employeeMapById: mapWithElement(storeState.employeeList.employeeMapById, addedEmployee) }); + employeeMapById: storeState.employeeList.employeeMapById + .set(addedEmployee.id as number, mapDomainObjectToView(addedEmployee)) }); }); it('remove employee', () => { expect( reducer(state.employeeList, actions.removeEmployee(deletedEmployee)), ).toEqual({ ...state.employeeList, - employeeMapById: mapWithoutElement(storeState.employeeList.employeeMapById, deletedEmployee) }); + employeeMapById: storeState.employeeList.employeeMapById.delete(deletedEmployee.id as number) }); }); it('update employee', () => { expect( reducer(state.employeeList, actions.updateEmployee(updatedEmployee)), ).toEqual({ ...state.employeeList, - employeeMapById: mapWithUpdatedElement(storeState.employeeList.employeeMapById, updatedEmployee) }); + employeeMapById: storeState.employeeList.employeeMapById + .set(updatedEmployee.id as number, mapDomainObjectToView(updatedEmployee)) }); }); it('refresh employee list', () => { expect( diff --git a/optaweb-employee-rostering-frontend/src/store/employee/reducers.ts b/optaweb-employee-rostering-frontend/src/store/employee/reducers.ts index da4e8376b..41641109c 100644 --- a/optaweb-employee-rostering-frontend/src/store/employee/reducers.ts +++ b/optaweb-employee-rostering-frontend/src/store/employee/reducers.ts @@ -14,17 +14,15 @@ * limitations under the License. */ -import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, -} from 'util/ImmutableCollectionOperations'; +import { createIdMapFromList, mapDomainObjectToView } from 'util/ImmutableCollectionOperations'; import DomainObjectView from 'domain/DomainObjectView'; import { Employee } from 'domain/Employee'; +import { Map } from 'immutable'; import { ActionType, EmployeeList, EmployeeAction } from './types'; export const initialState: EmployeeList = { isLoading: true, - employeeMapById: new Map>(), + employeeMapById: Map>(), }; const employeeReducer = (state = initialState, action: EmployeeAction): EmployeeList => { @@ -32,14 +30,14 @@ const employeeReducer = (state = initialState, action: EmployeeAction): Employee case ActionType.SET_EMPLOYEE_LIST_LOADING: { return { ...state, isLoading: action.isLoading }; } - case ActionType.ADD_EMPLOYEE: { - return { ...state, employeeMapById: mapWithElement(state.employeeMapById, action.employee) }; + case ActionType.ADD_EMPLOYEE: + case ActionType.UPDATE_EMPLOYEE: { + return { ...state, + employeeMapById: state.employeeMapById.set(action.employee.id as number, + mapDomainObjectToView(action.employee)) }; } case ActionType.REMOVE_EMPLOYEE: { - return { ...state, employeeMapById: mapWithoutElement(state.employeeMapById, action.employee) }; - } - case ActionType.UPDATE_EMPLOYEE: { - return { ...state, employeeMapById: mapWithUpdatedElement(state.employeeMapById, action.employee) }; + return { ...state, employeeMapById: state.employeeMapById.remove(action.employee.id as number) }; } case ActionType.REFRESH_EMPLOYEE_LIST: { return { ...state, employeeMapById: createIdMapFromList(action.employeeList) }; diff --git a/optaweb-employee-rostering-frontend/src/store/employee/selectors.ts b/optaweb-employee-rostering-frontend/src/store/employee/selectors.ts index cc4af583e..ce3c2e731 100644 --- a/optaweb-employee-rostering-frontend/src/store/employee/selectors.ts +++ b/optaweb-employee-rostering-frontend/src/store/employee/selectors.ts @@ -17,6 +17,7 @@ import { contractSelectors } from 'store/contract'; import { skillSelectors } from 'store/skill'; import { Employee } from 'domain/Employee'; import DomainObjectView from 'domain/DomainObjectView'; +import { Map } from 'immutable'; import { AppState } from '../types'; export const getEmployeeById = (state: AppState, id: number): Employee => { @@ -42,10 +43,10 @@ export const getEmployeeList = (state: AppState): Employee[] => { return employeeListForOldEmployeeMapById; } - const out: Employee[] = []; - state.employeeList.employeeMapById.forEach((value, key) => out.push(getEmployeeById(state, key))); + const out = state.employeeList.employeeMapById.keySeq().map(id => getEmployeeById(state, id)) + .sortBy(employee => employee.name).toList(); oldEmployeeMapById = state.employeeList.employeeMapById; - employeeListForOldEmployeeMapById = out; - return out; + employeeListForOldEmployeeMapById = out.toArray(); + return employeeListForOldEmployeeMapById; }; diff --git a/optaweb-employee-rostering-frontend/src/store/employee/types.ts b/optaweb-employee-rostering-frontend/src/store/employee/types.ts index 196d71fe7..195104662 100644 --- a/optaweb-employee-rostering-frontend/src/store/employee/types.ts +++ b/optaweb-employee-rostering-frontend/src/store/employee/types.ts @@ -17,6 +17,7 @@ import { Action } from 'redux'; import { Employee } from 'domain/Employee'; import DomainObjectView from 'domain/DomainObjectView'; +import { Map } from 'immutable'; export enum ActionType { ADD_EMPLOYEE = 'ADD_EMPLOYEE', diff --git a/optaweb-employee-rostering-frontend/src/store/mockStore.ts b/optaweb-employee-rostering-frontend/src/store/mockStore.ts index f843137f0..9a57e2b12 100644 --- a/optaweb-employee-rostering-frontend/src/store/mockStore.ts +++ b/optaweb-employee-rostering-frontend/src/store/mockStore.ts @@ -18,6 +18,7 @@ import * as Redux from 'react-redux'; import createMockStore, { MockStoreCreator } from 'redux-mock-store'; import thunk, { ThunkDispatch } from 'redux-thunk'; import { resetRestClientMock } from 'store/rest/RestTestUtils'; +import { Map, List } from 'immutable'; import RestServiceClient from './rest/RestServiceClient'; import { TenantAction } from './tenant/types'; import { SkillAction } from './skill/types'; @@ -38,28 +39,28 @@ export const mockStore = (state: Partial) => { const out = { store: mockStoreCreator({ tenantData: { currentTenantId: 0, - tenantList: [], + tenantList: List(), timezoneList: ['America/Toronto'], }, employeeList: { isLoading: true, - employeeMapById: new Map(), + employeeMapById: Map(), }, contractList: { isLoading: true, - contractMapById: new Map(), + contractMapById: Map(), }, spotList: { isLoading: true, - spotMapById: new Map(), + spotMapById: Map(), }, skillList: { isLoading: true, - skillMapById: new Map(), + skillMapById: Map(), }, timeBucketList: { isLoading: true, - timeBucketMapById: new Map(), + timeBucketMapById: Map(), }, rosterState: { isLoading: true, @@ -77,7 +78,7 @@ export const mockStore = (state: Partial) => { solverStatus: 'NOT_SOLVING', }, alerts: { - alertList: [], + alertList: List(), idGeneratorIndex: 0, }, isConnected: true, diff --git a/optaweb-employee-rostering-frontend/src/store/roster/roster.test.ts b/optaweb-employee-rostering-frontend/src/store/roster/roster.test.ts index 5613e730b..a85860b64 100644 --- a/optaweb-employee-rostering-frontend/src/store/roster/roster.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/roster/roster.test.ts @@ -24,11 +24,12 @@ import { Spot } from 'domain/Spot'; import { ShiftRosterView } from 'domain/ShiftRosterView'; import { AvailabilityRosterView } from 'domain/AvailabilityRosterView'; import { Employee } from 'domain/Employee'; -import DomainObjectView from 'domain/DomainObjectView'; import { RosterState } from 'domain/RosterState'; import { serializeLocalDate } from 'store/rest/DataSerialization'; import { flushPromises } from 'setupTests'; import { doNothing } from 'types'; +import { Map, List } from 'immutable'; +import { createIdMapFromList } from 'util/ImmutableCollectionOperations'; import { availabilityRosterReducer } from './reducers'; import { rosterStateReducer, shiftRosterViewReducer, rosterSelectors, rosterOperations, solverReducer } from './index'; import * as actions from './actions'; @@ -196,13 +197,13 @@ const mockAvailabilityRoster: AvailabilityRosterView = { const state: Partial = { tenantData: { currentTenantId: 0, - tenantList: [], + tenantList: List(), timezoneList: ['America/Toronto'], }, employeeList: { isLoading: false, - employeeMapById: new Map([[ - 20, { + employeeMapById: createIdMapFromList([ + { tenantId: 0, id: 20, version: 0, @@ -212,12 +213,12 @@ const state: Partial = { shortId: 'e', color: '#FFFFFF', }, - ]]), + ]), }, contractList: { isLoading: false, - contractMapById: new Map([[ - 30, { + contractMapById: createIdMapFromList([ + { tenantId: 0, id: 30, version: 0, @@ -227,19 +228,19 @@ const state: Partial = { maximumMinutesPerMonth: null, maximumMinutesPerYear: null, }, - ]]), + ]), }, spotList: { isLoading: false, - spotMapById: new Map([[ - 10, { + spotMapById: createIdMapFromList([ + { tenantId: 0, id: 10, version: 0, name: 'Spot', requiredSkillSet: [], }, - ]]), + ]), }, rosterState: { isLoading: false, @@ -255,7 +256,7 @@ const state: Partial = { }, skillList: { isLoading: false, - skillMapById: new Map(), + skillMapById: Map(), }, }; @@ -288,7 +289,7 @@ describe('Roster operations', () => { }); it('should not dispatch actions or call client if tenantId is negative', async () => { - const { store, client } = mockStore({ tenantData: { currentTenantId: -1, tenantList: [], timezoneList: [] } }); + const { store, client } = mockStore({ tenantData: { currentTenantId: -1, tenantList: List(), timezoneList: [] } }); const pagination = { pageNumber: 0, itemsPerPage: 10, @@ -695,7 +696,7 @@ describe('Roster operations', () => { const { store, client } = mockStore({ ...state, spotList: { - spotMapById: new Map>(), + spotMapById: Map(), isLoading: false, }, }); @@ -857,7 +858,7 @@ describe('Roster operations', () => { const { store, client } = mockStore({ ...state, employeeList: { - employeeMapById: new Map>(), + employeeMapById: Map(), isLoading: false, }, }); diff --git a/optaweb-employee-rostering-frontend/src/store/rotation/reducers.ts b/optaweb-employee-rostering-frontend/src/store/rotation/reducers.ts index d08a55d42..9f693b0f6 100644 --- a/optaweb-employee-rostering-frontend/src/store/rotation/reducers.ts +++ b/optaweb-employee-rostering-frontend/src/store/rotation/reducers.ts @@ -14,17 +14,15 @@ * limitations under the License. */ -import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, -} from 'util/ImmutableCollectionOperations'; +import { createIdMapFromList, mapDomainObjectToView } from 'util/ImmutableCollectionOperations'; import DomainObjectView from 'domain/DomainObjectView'; import { TimeBucket } from 'domain/TimeBucket'; +import { Map } from 'immutable'; import { ActionType, TimeBucketList, TimeBucketAction } from './types'; export const initialState: TimeBucketList = { isLoading: true, - timeBucketMapById: new Map>(), + timeBucketMapById: Map>(), }; const timeBucketReducer = (state = initialState, action: TimeBucketAction): TimeBucketList => { @@ -32,16 +30,14 @@ const timeBucketReducer = (state = initialState, action: TimeBucketAction): Time case ActionType.SET_TIME_BUCKET_LIST_LOADING: { return { ...state, isLoading: action.isLoading }; } - case ActionType.ADD_TIME_BUCKET: { - return { ...state, timeBucketMapById: mapWithElement(state.timeBucketMapById, action.timeBucket) }; - } - case ActionType.REMOVE_TIME_BUCKET: { - return { ...state, timeBucketMapById: mapWithoutElement(state.timeBucketMapById, action.timeBucket) }; - } + case ActionType.ADD_TIME_BUCKET: case ActionType.UPDATE_TIME_BUCKET: { return { ...state, - timeBucketMapById: mapWithUpdatedElement(state.timeBucketMapById, - action.timeBucket) }; + timeBucketMapById: state.timeBucketMapById.set(action.timeBucket.id as number, + mapDomainObjectToView(action.timeBucket)) }; + } + case ActionType.REMOVE_TIME_BUCKET: { + return { ...state, timeBucketMapById: state.timeBucketMapById.remove(action.timeBucket.id as number) }; } case ActionType.REFRESH_TIME_BUCKET_LIST: { return { ...state, timeBucketMapById: createIdMapFromList(action.timeBucketList) }; diff --git a/optaweb-employee-rostering-frontend/src/store/rotation/rotation.test.ts b/optaweb-employee-rostering-frontend/src/store/rotation/rotation.test.ts index 9591a6d26..a989cb412 100644 --- a/optaweb-employee-rostering-frontend/src/store/rotation/rotation.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/rotation/rotation.test.ts @@ -16,8 +16,7 @@ import { alert } from 'store/alert'; import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, + createIdMapFromList, mapDomainObjectToView, } from 'util/ImmutableCollectionOperations'; import { onGet, onPost, onDelete, onPut } from 'store/rest/RestTestUtils'; @@ -28,6 +27,7 @@ import { TimeBucketView, } from 'store/rotation/TimeBucketView'; import moment from 'moment'; +import { Map } from 'immutable'; import reducer, { timeBucketSelectors, timeBucketOperations } from './index'; import * as actions from './actions'; import { AppState } from '../types'; @@ -36,12 +36,12 @@ import { mockStore } from '../mockStore'; const state: Partial = { skillList: { isLoading: false, - skillMapById: new Map(), + skillMapById: Map(), }, employeeList: { isLoading: false, - employeeMapById: new Map([ - [3, { + employeeMapById: createIdMapFromList([ + { tenantId: 0, id: 3, version: 0, @@ -50,13 +50,13 @@ const state: Partial = { skillProficiencySet: [], shortId: 'e', color: '#FFFFFF', - }], + }, ]), }, contractList: { isLoading: false, - contractMapById: new Map([ - [10, { + contractMapById: createIdMapFromList([ + { tenantId: 0, id: 10, version: 0, @@ -65,25 +65,25 @@ const state: Partial = { maximumMinutesPerWeek: null, maximumMinutesPerMonth: null, maximumMinutesPerYear: null, - }], + }, ]), }, spotList: { isLoading: false, - spotMapById: new Map([ - [1, { + spotMapById: createIdMapFromList([ + { tenantId: 0, id: 1, version: 0, name: 'Spot', requiredSkillSet: [], - }], + }, ]), }, timeBucketList: { isLoading: false, - timeBucketMapById: new Map([ - [2, { + timeBucketMapById: createIdMapFromList([ + { tenantId: 0, id: 2, version: 0, @@ -93,8 +93,8 @@ const state: Partial = { seatList: [{ dayInRotation: 0, employee: 3 }], startTime: moment('09:00', 'HH:mm').toDate(), endTime: moment('17:00', 'HH:mm').toDate(), - }], - [4, { + }, + { tenantId: 0, id: 4, version: 0, @@ -104,7 +104,7 @@ const state: Partial = { seatList: [{ dayInRotation: 0, employee: 3 }], startTime: moment('17:00', 'HH:mm').toDate(), endTime: moment('09:00', 'HH:mm').toDate(), - }], + }, ]), }, }; @@ -330,21 +330,22 @@ describe('Rotation reducers', () => { reducer(storeState.timeBucketList, actions.addTimeBucket(mapDomainObjectToView(addedTimeBucket))), ).toEqual({ ...storeState.timeBucketList, timeBucketMapById: - mapWithElement(storeState.timeBucketList.timeBucketMapById, mapDomainObjectToView(addedTimeBucket)) }); + storeState.timeBucketList.timeBucketMapById + .set(addedTimeBucket.id as number, mapDomainObjectToView(addedTimeBucket)) }); }); it('remove shift template', () => { expect( reducer(storeState.timeBucketList, actions.removeTimeBucket(mapDomainObjectToView(deletedTimeBucket))), ).toEqual({ ...storeState.timeBucketList, timeBucketMapById: - mapWithoutElement(storeState.timeBucketList.timeBucketMapById, mapDomainObjectToView(deletedTimeBucket)) }); + storeState.timeBucketList.timeBucketMapById.delete(deletedTimeBucket.id as number) }); }); it('update shift template', () => { expect( reducer(storeState.timeBucketList, actions.updateTimeBucket(mapDomainObjectToView(updatedTimeBucket))), ).toEqual({ ...storeState.timeBucketList, timeBucketMapById: - mapWithUpdatedElement(storeState.timeBucketList.timeBucketMapById, + storeState.timeBucketList.timeBucketMapById.set(updatedTimeBucket.id as number, mapDomainObjectToView(updatedTimeBucket)) }); }); it('refresh shift template list', () => { diff --git a/optaweb-employee-rostering-frontend/src/store/rotation/types.ts b/optaweb-employee-rostering-frontend/src/store/rotation/types.ts index e34864c81..0c209aa67 100644 --- a/optaweb-employee-rostering-frontend/src/store/rotation/types.ts +++ b/optaweb-employee-rostering-frontend/src/store/rotation/types.ts @@ -17,6 +17,7 @@ import { Action } from 'redux'; import DomainObjectView from 'domain/DomainObjectView'; import { TimeBucket } from 'domain/TimeBucket'; +import { Map } from 'immutable'; export enum ActionType { ADD_TIME_BUCKET = 'ADD__TIME_BUCKET', diff --git a/optaweb-employee-rostering-frontend/src/store/shift/shift.test.ts b/optaweb-employee-rostering-frontend/src/store/shift/shift.test.ts index 25abec693..bcde24162 100644 --- a/optaweb-employee-rostering-frontend/src/store/shift/shift.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/shift/shift.test.ts @@ -21,6 +21,7 @@ import { Shift } from 'domain/Shift'; import moment from 'moment'; import { serializeLocalDateTime } from 'store/rest/DataSerialization'; import { HardMediumSoftScore } from 'domain/HardMediumSoftScore'; +import { createIdMapFromList } from 'util/ImmutableCollectionOperations'; import { shiftAdapter, KindaShiftView, kindaShiftViewAdapter } from './KindaShiftView'; import { shiftOperations } from './index'; import { AppState } from '../types'; @@ -330,19 +331,19 @@ describe('shift adapters', () => { const state: Partial = { skillList: { isLoading: false, - skillMapById: new Map([ - [1234, { + skillMapById: createIdMapFromList([ + { tenantId: 0, id: 1234, version: 0, name: 'Skill 2', - }], - [2312, { + }, + { tenantId: 0, id: 2312, version: 1, name: 'Skill 3', - }], + }, ]), }, }; diff --git a/optaweb-employee-rostering-frontend/src/store/skill/reducers.ts b/optaweb-employee-rostering-frontend/src/store/skill/reducers.ts index 862d804c1..22ae23fea 100644 --- a/optaweb-employee-rostering-frontend/src/store/skill/reducers.ts +++ b/optaweb-employee-rostering-frontend/src/store/skill/reducers.ts @@ -14,17 +14,15 @@ * limitations under the License. */ -import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, -} from 'util/ImmutableCollectionOperations'; +import { createIdMapFromList } from 'util/ImmutableCollectionOperations'; import DomainObjectView from 'domain/DomainObjectView'; import { Skill } from 'domain/Skill'; +import { Map } from 'immutable'; import { ActionType, SkillList, SkillAction } from './types'; export const initialState: SkillList = { isLoading: true, - skillMapById: new Map>(), + skillMapById: Map>(), }; const skillReducer = (state = initialState, action: SkillAction): SkillList => { @@ -32,14 +30,12 @@ const skillReducer = (state = initialState, action: SkillAction): SkillList => { case ActionType.SET_SKILL_LIST_LOADING: { return { ...state, isLoading: action.isLoading }; } - case ActionType.ADD_SKILL: { - return { ...state, skillMapById: mapWithElement(state.skillMapById, action.skill) }; + case ActionType.ADD_SKILL: + case ActionType.UPDATE_SKILL: { + return { ...state, skillMapById: state.skillMapById.set(action.skill.id as number, action.skill) }; } case ActionType.REMOVE_SKILL: { - return { ...state, skillMapById: mapWithoutElement(state.skillMapById, action.skill) }; - } - case ActionType.UPDATE_SKILL: { - return { ...state, skillMapById: mapWithUpdatedElement(state.skillMapById, action.skill) }; + return { ...state, skillMapById: state.skillMapById.remove(action.skill.id as number) }; } case ActionType.REFRESH_SKILL_LIST: { return { ...state, skillMapById: createIdMapFromList(action.skillList) }; diff --git a/optaweb-employee-rostering-frontend/src/store/skill/selectors.ts b/optaweb-employee-rostering-frontend/src/store/skill/selectors.ts index 6577d4db3..a4bdb183c 100644 --- a/optaweb-employee-rostering-frontend/src/store/skill/selectors.ts +++ b/optaweb-employee-rostering-frontend/src/store/skill/selectors.ts @@ -14,6 +14,8 @@ * limitations under the License. */ import { Skill } from 'domain/Skill'; +import { Map } from 'immutable'; +import DomainObjectView from 'domain/DomainObjectView'; import { AppState } from '../types'; export const getSkillById = (state: AppState, id: number): Skill => { @@ -24,7 +26,7 @@ export const getSkillById = (state: AppState, id: number): Skill => { }; -let oldSkillMapById: Map | null = null; +let oldSkillMapById: Map>| null = null; let skillListForOldSkillMapById: Skill[] | null = null; export const getSkillList = (state: AppState): Skill[] => { @@ -35,10 +37,10 @@ export const getSkillList = (state: AppState): Skill[] => { return skillListForOldSkillMapById; } - const out: Skill[] = []; - state.skillList.skillMapById.forEach((value, key) => out.push(getSkillById(state, key))); + const out = state.skillList.skillMapById.keySeq().map(key => getSkillById(state, key)) + .sortBy(skill => skill.name).toList(); oldSkillMapById = state.skillList.skillMapById; - skillListForOldSkillMapById = out; - return out; + skillListForOldSkillMapById = out.toArray(); + return skillListForOldSkillMapById; }; diff --git a/optaweb-employee-rostering-frontend/src/store/skill/skill.test.ts b/optaweb-employee-rostering-frontend/src/store/skill/skill.test.ts index 483b6efaf..5b9628fcc 100644 --- a/optaweb-employee-rostering-frontend/src/store/skill/skill.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/skill/skill.test.ts @@ -15,10 +15,7 @@ */ import { alert } from 'store/alert'; -import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, -} from 'util/ImmutableCollectionOperations'; +import { createIdMapFromList } from 'util/ImmutableCollectionOperations'; import { onGet, onPost, onDelete } from 'store/rest/RestTestUtils'; import { Skill } from 'domain/Skill'; import { mockStore } from '../mockStore'; @@ -29,19 +26,19 @@ import reducer, { skillSelectors, skillOperations } from './index'; const state: Partial = { skillList: { isLoading: false, - skillMapById: new Map([ - [1234, { + skillMapById: createIdMapFromList([ + { tenantId: 0, id: 1234, version: 0, name: 'Skill 2', - }], - [2312, { + }, + { tenantId: 0, id: 2312, version: 1, name: 'Skill 3', - }], + }, ]), }, }; @@ -152,14 +149,16 @@ describe('Skill reducers', () => { it('add skill', () => { expect( reducer(storeState.skillList, actions.addSkill(addedSkill)), - ).toEqual({ ...storeState.skillList, skillMapById: mapWithElement(storeState.skillList.skillMapById, addedSkill) }); + ).toEqual({ ...storeState.skillList, + skillMapById: storeState.skillList.skillMapById + .set(addedSkill.id as number, addedSkill) }); }); it('remove skill', () => { expect( reducer(storeState.skillList, actions.removeSkill(deletedSkill)), ).toEqual({ ...storeState.skillList, - skillMapById: mapWithoutElement(storeState.skillList.skillMapById, deletedSkill), + skillMapById: storeState.skillList.skillMapById.delete(deletedSkill.id as number), }); }); it('update skill', () => { @@ -167,7 +166,7 @@ describe('Skill reducers', () => { reducer(storeState.skillList, actions.updateSkill(updatedSkill)), ).toEqual({ ...storeState.skillList, - skillMapById: mapWithUpdatedElement(storeState.skillList.skillMapById, updatedSkill), + skillMapById: storeState.skillList.skillMapById.set(updatedSkill.id as number, updatedSkill), }); }); it('refresh skill list', () => { diff --git a/optaweb-employee-rostering-frontend/src/store/skill/types.ts b/optaweb-employee-rostering-frontend/src/store/skill/types.ts index 4c05d6842..f8c4922c6 100644 --- a/optaweb-employee-rostering-frontend/src/store/skill/types.ts +++ b/optaweb-employee-rostering-frontend/src/store/skill/types.ts @@ -17,6 +17,7 @@ import { Action } from 'redux'; import { Skill } from 'domain/Skill'; import DomainObjectView from 'domain/DomainObjectView'; +import { Map } from 'immutable'; export enum ActionType { ADD_SKILL = 'ADD_SKILL', diff --git a/optaweb-employee-rostering-frontend/src/store/spot/reducers.ts b/optaweb-employee-rostering-frontend/src/store/spot/reducers.ts index c48963f82..20b991518 100644 --- a/optaweb-employee-rostering-frontend/src/store/spot/reducers.ts +++ b/optaweb-employee-rostering-frontend/src/store/spot/reducers.ts @@ -14,17 +14,15 @@ * limitations under the License. */ -import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, -} from 'util/ImmutableCollectionOperations'; +import { createIdMapFromList, mapDomainObjectToView } from 'util/ImmutableCollectionOperations'; import DomainObjectView from 'domain/DomainObjectView'; import { Spot } from 'domain/Spot'; +import { Map } from 'immutable'; import { ActionType, SpotList, SpotAction } from './types'; export const initialState: SpotList = { isLoading: true, - spotMapById: new Map>(), + spotMapById: Map>(), }; const spotReducer = (state = initialState, action: SpotAction): SpotList => { @@ -32,14 +30,14 @@ const spotReducer = (state = initialState, action: SpotAction): SpotList => { case ActionType.SET_SPOT_LIST_LOADING: { return { ...state, isLoading: action.isLoading }; } - case ActionType.ADD_SPOT: { - return { ...state, spotMapById: mapWithElement(state.spotMapById, action.spot) }; + case ActionType.ADD_SPOT: + case ActionType.UPDATE_SPOT: { + return { ...state, + spotMapById: state.spotMapById.set(action.spot.id as number, + mapDomainObjectToView(action.spot)) }; } case ActionType.REMOVE_SPOT: { - return { ...state, spotMapById: mapWithoutElement(state.spotMapById, action.spot) }; - } - case ActionType.UPDATE_SPOT: { - return { ...state, spotMapById: mapWithUpdatedElement(state.spotMapById, action.spot) }; + return { ...state, spotMapById: state.spotMapById.remove(action.spot.id as number) }; } case ActionType.REFRESH_SPOT_LIST: { return { ...state, spotMapById: createIdMapFromList(action.spotList) }; diff --git a/optaweb-employee-rostering-frontend/src/store/spot/selectors.ts b/optaweb-employee-rostering-frontend/src/store/spot/selectors.ts index 1e0d73226..d43bab14c 100644 --- a/optaweb-employee-rostering-frontend/src/store/spot/selectors.ts +++ b/optaweb-employee-rostering-frontend/src/store/spot/selectors.ts @@ -16,6 +16,7 @@ import { skillSelectors } from 'store/skill'; import { Spot } from 'domain/Spot'; import DomainObjectView from 'domain/DomainObjectView'; +import { Map } from 'immutable'; import { AppState } from '../types'; export const getSpotById = (state: AppState, id: number): Spot => { @@ -40,10 +41,10 @@ export const getSpotList = (state: AppState): Spot[] => { return spotListForOldSpotMapById; } - const out: Spot[] = []; - state.spotList.spotMapById.forEach((value, key) => out.push(getSpotById(state, key))); + const out = state.spotList.spotMapById.keySeq().map(key => getSpotById(state, key)) + .sortBy(spot => spot.name).toList(); oldSpotMapById = state.spotList.spotMapById; - spotListForOldSpotMapById = out; - return out; + spotListForOldSpotMapById = out.toArray(); + return spotListForOldSpotMapById; }; diff --git a/optaweb-employee-rostering-frontend/src/store/spot/spot.test.ts b/optaweb-employee-rostering-frontend/src/store/spot/spot.test.ts index a8f5767f7..b95f5c3f7 100644 --- a/optaweb-employee-rostering-frontend/src/store/spot/spot.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/spot/spot.test.ts @@ -15,10 +15,7 @@ */ import { alert } from 'store/alert'; -import { - createIdMapFromList, mapWithElement, mapWithoutElement, - mapWithUpdatedElement, -} from 'util/ImmutableCollectionOperations'; +import { createIdMapFromList, mapDomainObjectToView } from 'util/ImmutableCollectionOperations'; import { onGet, onPost, onDelete } from 'store/rest/RestTestUtils'; import { Spot } from 'domain/Spot'; import { mockStore } from '../mockStore'; @@ -29,32 +26,32 @@ import reducer, { spotSelectors, spotOperations } from './index'; const state: Partial = { spotList: { isLoading: false, - spotMapById: new Map([ - [1234, { + spotMapById: createIdMapFromList([ + { tenantId: 0, id: 1234, version: 1, name: 'Spot 2', requiredSkillSet: [1], - }], - [2312, { + }, + { tenantId: 0, id: 2312, version: 0, name: 'Spot 3', requiredSkillSet: [], - }], + }, ]), }, skillList: { isLoading: false, - skillMapById: new Map([ - [1, { + skillMapById: createIdMapFromList([ + { tenantId: 0, id: 1, version: 0, name: 'Skill 1', - }], + }, ]), }, }; @@ -205,19 +202,19 @@ describe('Spot reducers', () => { expect( reducer(storeState.spotList, actions.addSpot(addedSpot)), ).toEqual({ ...storeState.spotList, - spotMapById: mapWithElement(storeState.spotList.spotMapById, addedSpot) }); + spotMapById: storeState.spotList.spotMapById.set(addedSpot.id as number, mapDomainObjectToView(addedSpot)) }); }); it('remove spot', () => { expect( reducer(storeState.spotList, actions.removeSpot(deletedSpot)), ).toEqual({ ...storeState.spotList, - spotMapById: mapWithoutElement(storeState.spotList.spotMapById, deletedSpot) }); + spotMapById: storeState.spotList.spotMapById.delete(deletedSpot.id as number) }); }); it('update spot', () => { expect( reducer(storeState.spotList, actions.updateSpot(updatedSpot)), ).toEqual({ ...storeState.spotList, - spotMapById: mapWithUpdatedElement(storeState.spotList.spotMapById, updatedSpot) }); + spotMapById: storeState.spotList.spotMapById.set(updatedSpot.id as number, mapDomainObjectToView(updatedSpot)) }); }); it('refresh spot list', () => { expect( diff --git a/optaweb-employee-rostering-frontend/src/store/spot/types.ts b/optaweb-employee-rostering-frontend/src/store/spot/types.ts index 77704c7bb..d1b0f8391 100644 --- a/optaweb-employee-rostering-frontend/src/store/spot/types.ts +++ b/optaweb-employee-rostering-frontend/src/store/spot/types.ts @@ -17,6 +17,7 @@ import { Action } from 'redux'; import { Spot } from 'domain/Spot'; import DomainObjectView from 'domain/DomainObjectView'; +import { Map } from 'immutable'; export enum ActionType { ADD_SPOT = 'ADD_SPOT', diff --git a/optaweb-employee-rostering-frontend/src/store/tenant/reducers.ts b/optaweb-employee-rostering-frontend/src/store/tenant/reducers.ts index ee2f5ae32..2757f6951 100644 --- a/optaweb-employee-rostering-frontend/src/store/tenant/reducers.ts +++ b/optaweb-employee-rostering-frontend/src/store/tenant/reducers.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { withElement, withoutElement } from 'util/ImmutableCollectionOperations'; +import { List } from 'immutable'; import { ActionType, TenantData, TenantAction, ConnectAction, ConnectionActionType } from './types'; const initialState: TenantData = { currentTenantId: 0, - tenantList: [], + tenantList: List(), timezoneList: [], }; @@ -31,13 +31,13 @@ const tenantReducer = (state = initialState, action: TenantAction): TenantData = return { ...state, currentTenantId: action.tenantId }; } case ActionType.REFRESH_TENANT_LIST: { - return { ...state, currentTenantId: action.tenantId, tenantList: action.tenantList }; + return { ...state, currentTenantId: action.tenantId, tenantList: List(action.tenantList) }; } case ActionType.ADD_TENANT: { - return { ...state, tenantList: withElement(state.tenantList, action.tenant) }; + return { ...state, tenantList: state.tenantList.push(action.tenant) }; } case ActionType.REMOVE_TENANT: { - return { ...state, tenantList: withoutElement(state.tenantList, action.tenant) }; + return { ...state, tenantList: state.tenantList.filterNot(tenant => tenant.id === action.tenant.id) }; } case ActionType.REFRESH_SUPPORTED_TIMEZONES: { return { ...state, timezoneList: action.timezoneList }; diff --git a/optaweb-employee-rostering-frontend/src/store/tenant/tenant.test.ts b/optaweb-employee-rostering-frontend/src/store/tenant/tenant.test.ts index 384607a20..94ccfc50c 100644 --- a/optaweb-employee-rostering-frontend/src/store/tenant/tenant.test.ts +++ b/optaweb-employee-rostering-frontend/src/store/tenant/tenant.test.ts @@ -21,10 +21,11 @@ import { Tenant } from 'domain/Tenant'; import moment from 'moment'; import { timeBucketOperations } from 'store/rotation'; import { RosterState } from 'domain/RosterState'; -import * as immutableOperations from 'util/ImmutableCollectionOperations'; import { flushPromises } from 'setupTests'; import { getRouterProps } from 'util/BookmarkableTestUtils'; -import { doNothing } from 'types'; +import { doNothing, error } from 'types'; +import { Map, List } from 'immutable'; +import { createIdMapFromList } from 'util/ImmutableCollectionOperations'; import { mockStore } from '../mockStore'; import { AppState } from '../types'; import * as actions from './actions'; @@ -43,7 +44,7 @@ import reducer, { tenantOperations } from './index'; const state: Partial = { tenantData: { currentTenantId: 0, - tenantList: [ + tenantList: List([ { id: 0, version: 0, @@ -54,7 +55,7 @@ const state: Partial = { version: 0, name: 'Tenant 1', }, - ], + ]), timezoneList: ['America/Toronto'], }, isConnected: true, @@ -250,7 +251,7 @@ describe('Tenant operations', () => { const newState: AppState = { tenantData: { currentTenantId: 0, - tenantList: [ + tenantList: List([ { id: 0, version: 0, @@ -261,13 +262,13 @@ describe('Tenant operations', () => { version: 0, name: 'Tenant 1', }, - ], + ]), timezoneList: ['America/Toronto'], }, employeeList: { isLoading: false, - employeeMapById: new Map([ - [10, { + employeeMapById: createIdMapFromList([ + { tenantId: 0, id: 10, version: 0, @@ -276,24 +277,24 @@ describe('Tenant operations', () => { skillProficiencySet: [], shortId: 'A', color: '#FFFFFF', - }], + }, ]), }, contractList: { isLoading: false, - contractMapById: new Map(), + contractMapById: Map(), }, spotList: { isLoading: false, - spotMapById: new Map(), + spotMapById: Map(), }, skillList: { isLoading: false, - skillMapById: new Map(), + skillMapById: Map(), }, timeBucketList: { isLoading: false, - timeBucketMapById: new Map(), + timeBucketMapById: Map(), }, rosterState: { isLoading: false, @@ -321,7 +322,7 @@ describe('Tenant operations', () => { solverStatus: 'NOT_SOLVING', }, alerts: { - alertList: [], + alertList: List(), idGeneratorIndex: 0, }, isConnected: true, @@ -468,7 +469,7 @@ describe('Tenant reducers', () => { it('refresh tenant list', () => { expect( reducer(storeState.tenantData, actions.refreshTenantList({ currentTenantId: 1, tenantList: newTenantList })), - ).toEqual({ ...storeState.tenantData, currentTenantId: 1, tenantList: newTenantList }); + ).toEqual({ ...storeState.tenantData, currentTenantId: 1, tenantList: List(newTenantList) }); }); it('change tenant', () => { expect( @@ -479,15 +480,14 @@ describe('Tenant reducers', () => { expect( reducer(storeState.tenantData, actions.addTenant(newTenantList[2])), ).toEqual({ ...storeState.tenantData, - tenantList: immutableOperations.withElement(storeState.tenantData.tenantList, newTenantList[2]) }); + tenantList: storeState.tenantData.tenantList.set(2, newTenantList[2]) }); }); it('removeTenant', () => { expect( - reducer(storeState.tenantData, actions.removeTenant(storeState.tenantData.tenantList[0])), + reducer(storeState.tenantData, actions.removeTenant(storeState.tenantData.tenantList.get(0) ?? error())), ).toEqual({ ...storeState.tenantData, - tenantList: immutableOperations.withoutElement(storeState.tenantData.tenantList, - storeState.tenantData.tenantList[0]), + tenantList: storeState.tenantData.tenantList.delete(0), }); }); }); diff --git a/optaweb-employee-rostering-frontend/src/store/tenant/types.ts b/optaweb-employee-rostering-frontend/src/store/tenant/types.ts index 490a2499d..744f78bf5 100644 --- a/optaweb-employee-rostering-frontend/src/store/tenant/types.ts +++ b/optaweb-employee-rostering-frontend/src/store/tenant/types.ts @@ -16,6 +16,7 @@ import { Action } from 'redux'; import { Tenant } from 'domain/Tenant'; +import { List } from 'immutable'; export enum ActionType { CHANGE_TENANT = 'CHANGE_TENANT', @@ -63,6 +64,6 @@ RemoveTenantAction | RefreshSupportedTimezoneListAction; export interface TenantData { readonly currentTenantId: number; - readonly tenantList: Tenant[]; + readonly tenantList: List; readonly timezoneList: string[]; } diff --git a/optaweb-employee-rostering-frontend/src/types.ts b/optaweb-employee-rostering-frontend/src/types.ts index 30bbef8c3..593e06b16 100644 --- a/optaweb-employee-rostering-frontend/src/types.ts +++ b/optaweb-employee-rostering-frontend/src/types.ts @@ -23,6 +23,14 @@ export interface PaginationData { export const doNothing = () => { /* Intentionally Empty */ }; +/** + * Used to throw an error on a condition that should never + * happen. + */ +export function error(msg?: string): never { + throw new Error(msg ?? ''); +} + export interface ObjectNumberMap { [index: number]: T; } diff --git a/optaweb-employee-rostering-frontend/src/ui/Alerts.tsx b/optaweb-employee-rostering-frontend/src/ui/Alerts.tsx index 506744f31..79c71de95 100644 --- a/optaweb-employee-rostering-frontend/src/ui/Alerts.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/Alerts.tsx @@ -35,7 +35,7 @@ interface DispatchProps { } const mapStateToProps = (state: AppState): StateProps => ({ - alerts: state.alerts.alertList, + alerts: state.alerts.alertList.toArray(), }); const mapDispatchToProps: DispatchProps = { diff --git a/optaweb-employee-rostering-frontend/src/ui/components/DataTable.tsx b/optaweb-employee-rostering-frontend/src/ui/components/DataTable.tsx index 30ae075df..0e737f57e 100644 --- a/optaweb-employee-rostering-frontend/src/ui/components/DataTable.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/components/DataTable.tsx @@ -28,10 +28,11 @@ import { import { Button, ButtonVariant, Pagination, Level, LevelItem } from '@patternfly/react-core'; import { SaveIcon, CloseIcon, EditIcon, TrashIcon } from '@patternfly/react-icons'; import { Predicate, ReadonlyPartial, Sorter } from 'types'; -import { toggleElement, Stream } from 'util/ImmutableCollectionOperations'; +import { toggleElement, conditionally } from 'util/ImmutableCollectionOperations'; import { WithTranslation } from 'react-i18next'; import { getPropsFromUrl, setPropsInUrl, UrlProps } from 'util/BookmarkableUtils'; import { RouteComponentProps } from 'react-router'; +import { List, Seq } from 'immutable'; import FilterComponent from './FilterComponent'; import { EditableComponent } from './EditableComponent'; @@ -277,7 +278,8 @@ export abstract class DataTable> extends React.Co page: '1', itemsPerPage: '10', filter: null, - sortBy: null, + sortBy: this.getSorters().filter(x => x !== null).length > 0 + ? `${this.getSorters().findIndex(x => x !== null)}` : null, asc: 'true', }); const [page, perPage] = [parseInt(urlProps.page as string, 10), parseInt(urlProps.itemsPerPage as string, 10)]; @@ -285,37 +287,44 @@ export abstract class DataTable> extends React.Co const sortDirection: 'asc'|'desc' = urlProps.asc === 'true' ? 'asc' : 'desc'; const sortBy = urlProps.sortBy ? { index: parseInt(urlProps.sortBy, 10), direction: sortDirection } : {}; - const additionalRows: IRow[] = (this.state.newRowData !== null) - ? [ + const additionalRows: List = (this.state.newRowData !== null) + ? List([ { cells: this.editDataRow(this.state.newRowData, setProperty) .map(c => ({ title: c })) .concat([{ title: this.getAddButtons(this.state.newRowData) }]), }, - ] : []; + ]) : List(); const sorters = this.getSorters(); - const filteredRows = new Stream(this.props.tableData) + const filteredRows = conditionally(Seq(this.props.tableData), // eslint-disable-next-line consistent-return - .conditionally((s) => { + (s) => { if (urlProps.sortBy !== null) { - return s.sort(sorters[parseInt(urlProps.sortBy, 10)] as Sorter, - urlProps.asc === 'true'); + return s.sort(sorters[parseInt(urlProps.sortBy, 10)] as Sorter); } - }) + }).then( // eslint-disable-next-line consistent-return - .conditionally((s) => { + (s) => { + if (urlProps.asc !== 'true') { + return s.reverse(); + } + }, + ).then( + // eslint-disable-next-line consistent-return + (s) => { if (urlProps.filter !== null) { return s.filter(this.getFilter()(urlProps.filter)); } - }); + }, + ).result; - const rowsThatMatchFilter = filteredRows.collect(c => c.length); + const rowsThatMatchFilterCount = filteredRows.count(); const rows = additionalRows.concat(filteredRows - .page(page, perPage) - .map(this.convertDataToTableRow) - .collect(c => c)); + .skip((page - 1) * perPage) + .take(perPage) + .map(this.convertDataToTableRow)); const columns: ICell[] = this.props.columnTitles.map((title, index) => ({ title, @@ -346,7 +355,7 @@ export abstract class DataTable> extends React.Co > extends React.Co /> - +
diff --git a/optaweb-employee-rostering-frontend/src/ui/components/__snapshots__/DataTable.test.tsx.snap b/optaweb-employee-rostering-frontend/src/ui/components/__snapshots__/DataTable.test.tsx.snap index 032429f90..8aa4bb301 100644 --- a/optaweb-employee-rostering-frontend/src/ui/components/__snapshots__/DataTable.test.tsx.snap +++ b/optaweb-employee-rostering-frontend/src/ui/components/__snapshots__/DataTable.test.tsx.snap @@ -299,7 +299,12 @@ exports[`DataTable component should only render data on page 1`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 1, + } + } > @@ -381,7 +386,12 @@ exports[`DataTable component should only render data on page 2`] = ` } onSort={[Function]} rows={Array []} - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 1, + } + } > @@ -688,7 +698,12 @@ exports[`DataTable component should only render rows that match filter 1`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 1, + } + } > @@ -995,7 +1010,12 @@ exports[`DataTable component should only render rows that match filter 2`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 1, + } + } > @@ -1302,7 +1322,12 @@ exports[`DataTable component should only render rows that match filter 3`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 1, + } + } > @@ -1609,7 +1634,12 @@ exports[`DataTable component should render correctly with a few rows 1`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 1, + } + } > @@ -1691,7 +1721,12 @@ exports[`DataTable component should render correctly with no rows 1`] = ` } onSort={[Function]} rows={Array []} - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 1, + } + } > @@ -2038,7 +2073,12 @@ exports[`DataTable component should render editor for new row 1`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 1, + } + } > @@ -2969,7 +3009,12 @@ exports[`DataTable component should render viewer initially 1`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 1, + } + } > diff --git a/optaweb-employee-rostering-frontend/src/ui/header/Toolbar.test.tsx b/optaweb-employee-rostering-frontend/src/ui/header/Toolbar.test.tsx index 71cd14111..2b20ec244 100644 --- a/optaweb-employee-rostering-frontend/src/ui/header/Toolbar.test.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/header/Toolbar.test.tsx @@ -17,6 +17,7 @@ import { shallow } from 'enzyme'; import toJson from 'enzyme-to-json'; import * as React from 'react'; import { getRouterProps } from 'util/BookmarkableTestUtils'; +import { List } from 'immutable'; import { ToolbarComponent, Props } from './Toolbar'; describe('Toolbar Component', () => { @@ -61,7 +62,7 @@ describe('Toolbar Component', () => { }); const noTenants: Props = { - tenantList: [], + tenantList: List(), currentTenantId: 0, refreshTenantList: jest.fn(), changeTenant: jest.fn(), @@ -69,7 +70,7 @@ const noTenants: Props = { }; const twoTenants: Props = { - tenantList: [ + tenantList: List([ { id: 1, version: 0, @@ -80,7 +81,7 @@ const twoTenants: Props = { version: 0, name: 'Tenant 2', }, - ], + ]), currentTenantId: 2, refreshTenantList: jest.fn(), changeTenant: jest.fn(), diff --git a/optaweb-employee-rostering-frontend/src/ui/header/Toolbar.tsx b/optaweb-employee-rostering-frontend/src/ui/header/Toolbar.tsx index 277eec403..4ac27a324 100644 --- a/optaweb-employee-rostering-frontend/src/ui/header/Toolbar.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/header/Toolbar.tsx @@ -31,10 +31,11 @@ import { connect } from 'react-redux'; import { tenantOperations } from 'store/tenant'; import { AppState } from 'store/types'; import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { List } from 'immutable'; interface StateProps { currentTenantId: number; - tenantList: Tenant[]; + tenantList: List; } const mapStateToProps = ({ tenantData }: AppState): StateProps => ({ @@ -116,7 +117,7 @@ export class ToolbarComponent extends React.Component { ); const { tenantList, currentTenantId } = this.props; const { isTenantSelectOpen } = this.state; - if (tenantList.length === 0) { + if (tenantList.size === 0) { return ( @@ -146,7 +147,7 @@ export class ToolbarComponent extends React.Component { {tenant.name} - ))} + )).toArray()} /> diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/admin/AdminPage.test.tsx b/optaweb-employee-rostering-frontend/src/ui/pages/admin/AdminPage.test.tsx index d3ea6b981..0dd7bbd31 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/admin/AdminPage.test.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/pages/admin/AdminPage.test.tsx @@ -21,6 +21,7 @@ import { Tenant } from 'domain/Tenant'; import { getRouterProps } from 'util/BookmarkableTestUtils'; import { DataTableUrlProps } from 'ui/components/DataTable'; import { Button } from '@patternfly/react-core'; +import { List } from 'immutable'; import { AdminPage, Props } from './AdminPage'; describe('Admin Page', () => { @@ -90,7 +91,7 @@ describe('Admin Page', () => { )[1].cells[1]).find(Button).simulate('click'); }); expect(twoTenants.removeTenant).toBeCalled(); - expect(twoTenants.removeTenant).toBeCalledWith(twoTenants.tenantList[1]); + expect(twoTenants.removeTenant).toBeCalledWith(twoTenants.tenantList.get(1)); }); it('should not call remove tenant when the delete tenant button is clicked for the current tenant', () => { @@ -123,7 +124,7 @@ function generateProps(numberOfTenants: number, urlProps: Partial; } const mapStateToProps = (state: AppState): StateProps => ({ @@ -76,14 +76,14 @@ export const AdminPage: React.FC = (props) => { const page = parseInt(urlProps.page as string, 10); const itemsPerPage = parseInt(urlProps.itemsPerPage as string, 10); const filter = stringFilter((tenant: Tenant) => tenant.name)(filterText); - const filteredRows = new Stream(tenantList) + const filteredRows = tenantList .filter(filter); - const numOfFilteredRows = filteredRows.collect(c => c.length); + const numOfFilteredRows = filteredRows.size; const rowsInPage = filteredRows - .page(page, itemsPerPage) - .collect(c => c); + .skip((page - 1) * itemsPerPage) + .take(itemsPerPage); return ( <> @@ -149,7 +149,7 @@ export const AdminPage: React.FC = (props) => { caption={t('tenants')} cells={[t('name'), '']} rows={ - rowsInPage.map(tenant => ( + rowsInPage.map(tenant => ( { cells: [ ({tenant.name}), @@ -176,7 +176,7 @@ export const AdminPage: React.FC = (props) => { ), ], - })) + })).toArray() } > diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/availability/AvailabilityRosterPage.tsx b/optaweb-employee-rostering-frontend/src/ui/pages/availability/AvailabilityRosterPage.tsx index 6ba17b461..02d915dd9 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/availability/AvailabilityRosterPage.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/pages/availability/AvailabilityRosterPage.tsx @@ -291,7 +291,7 @@ export class AvailabilityRosterPage extends React.Component { }); const changedTenant = this.props.shownEmployeeList.length === 0 || (urlProps.employee !== null - && this.props.tenantId !== this.props.shownEmployeeList[0].tenantId); + && this.props.tenantId !== (this.props.shownEmployeeList[0]).tenantId); if (this.props.shownEmployeeList.length === 0 || changedTenant || this.props.rosterState === null) { return ( diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/contract/__snapshots__/ContractsPage.test.tsx.snap b/optaweb-employee-rostering-frontend/src/ui/pages/contract/__snapshots__/ContractsPage.test.tsx.snap index fa82f0e85..76efba548 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/contract/__snapshots__/ContractsPage.test.tsx.snap +++ b/optaweb-employee-rostering-frontend/src/ui/pages/contract/__snapshots__/ContractsPage.test.tsx.snap @@ -447,7 +447,12 @@ exports[`Contracts page should render correctly with a few contracts 1`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 0, + } + } > @@ -553,7 +558,12 @@ exports[`Contracts page should render correctly with no contracts 1`] = ` } onSort={[Function]} rows={Array []} - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 0, + } + } > diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/employee/EmployeePage.test.tsx b/optaweb-employee-rostering-frontend/src/ui/pages/employee/EmployeePage.test.tsx index baeef1dbe..daa8f1f98 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/employee/EmployeePage.test.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/pages/employee/EmployeePage.test.tsx @@ -76,7 +76,8 @@ describe('Employees page', () => { setProperty.mockClear(); const skillProficiencySetCol = mount(editor[2] as React.ReactElement); act(() => { - skillProficiencySetCol.find(MultiTypeaheadSelectInput).props().onChange([twoEmployees.skillList[0]]); + skillProficiencySetCol.find(MultiTypeaheadSelectInput).props() + .onChange([twoEmployees.skillList[0]]); }); expect(setProperty).toBeCalled(); expect(setProperty).toBeCalledWith('skillProficiencySet', [twoEmployees.skillList[0]]); @@ -154,11 +155,20 @@ describe('Employees page', () => { const employeesPage = new EmployeesPage(twoEmployees); const filter = employeesPage.getFilter(); - expect(twoEmployees.tableData.filter(filter('1'))).toEqual([twoEmployees.tableData[0], twoEmployees.tableData[1]]); - expect(twoEmployees.tableData.filter(filter('Skill 1'))).toEqual([twoEmployees.tableData[1]]); + expect(twoEmployees.tableData.filter(filter('1'))).toEqual([ + twoEmployees.tableData[0], + twoEmployees.tableData[1], + ]); + expect(twoEmployees.tableData.filter(filter('Skill 1'))).toEqual([ + twoEmployees.tableData[1], + ]); expect(twoEmployees.tableData.filter(filter('2'))).toEqual([twoEmployees.tableData[1]]); - expect(twoEmployees.tableData.filter(filter('Contract 2'))).toEqual([twoEmployees.tableData[1]]); - expect(twoEmployees.tableData.filter(filter('Employee 2'))).toEqual([twoEmployees.tableData[1]]); + expect(twoEmployees.tableData.filter(filter('Contract 2'))).toEqual([ + twoEmployees.tableData[1], + ]); + expect(twoEmployees.tableData.filter(filter('Employee 2'))).toEqual([ + twoEmployees.tableData[1], + ]); }); it('should return a sorter that sort by name and contract', () => { diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/employee/__snapshots__/EmployeePage.test.tsx.snap b/optaweb-employee-rostering-frontend/src/ui/pages/employee/__snapshots__/EmployeePage.test.tsx.snap index bd9c23468..f6200477c 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/employee/__snapshots__/EmployeePage.test.tsx.snap +++ b/optaweb-employee-rostering-frontend/src/ui/pages/employee/__snapshots__/EmployeePage.test.tsx.snap @@ -611,7 +611,12 @@ exports[`Employees page should render correctly with a few employees 1`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 0, + } + } > diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/rotation/RotationPage.tsx b/optaweb-employee-rostering-frontend/src/ui/pages/rotation/RotationPage.tsx index 621783066..f910bcca8 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/rotation/RotationPage.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/pages/rotation/RotationPage.tsx @@ -52,7 +52,8 @@ export const RotationPage: React.FC<{}> = () => { const [selectedStub, setSelectedStub] = useState('NO_SHIFT'); const [isEditingTimeBuckets, setIsEditingTimeBuckets] = useState(false); - const [shownSpotName, setShownSpotName] = useUrlState('spot', (spotList.length > 0) ? spotList[0].name : undefined); + const [shownSpotName, setShownSpotName] = useUrlState('spot', (spotList.length > 0) + ? spotList[0].name : undefined); const shownSpot = spotList.find(s => s.name === shownSpotName); const shownTimeBuckets = shownSpot ? timeBucketList.filter(tb => tb.spot.id === shownSpot.id) : []; const oldShownTimeBuckets = useRef(shownTimeBuckets.map(tb => tb.id).join(',')); @@ -71,7 +72,7 @@ export const RotationPage: React.FC<{}> = () => { React.useEffect(() => { if (shownSpot === undefined && spotList.length > 0) { - setShownSpotName(spotList[0].name); + setShownSpotName((spotList[0]).name); } }, [spotList, shownSpot, setShownSpotName]); diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/shift/CurrentShiftRosterPage.tsx b/optaweb-employee-rostering-frontend/src/ui/pages/shift/CurrentShiftRosterPage.tsx index 10f6aa6cc..3bd631795 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/shift/CurrentShiftRosterPage.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/pages/shift/CurrentShiftRosterPage.tsx @@ -130,7 +130,8 @@ export class ShiftRosterPage extends React.Component { onUpdateShiftRoster(urlProps: ShiftRosterUrlProps) { if (this.props.rosterState) { - const spot = this.props.allSpotList.find(s => s.name === urlProps.spot) || this.props.allSpotList[0]; + const spot = this.props.allSpotList.find(s => s.name === urlProps.spot) + || (this.props.allSpotList[0] /* can be undefined */); const startDate = moment(urlProps.week || new Date()).startOf('week').toDate(); const endDate = moment(startDate).endOf('week').toDate(); @@ -235,7 +236,7 @@ export class ShiftRosterPage extends React.Component { week: null, }); const changedTenant = this.props.shownSpotList.length === 0 - || this.props.tenantId !== this.props.shownSpotList[0].tenantId; + || this.props.tenantId !== (this.props.shownSpotList[0]).tenantId; if (this.props.shownSpotList.length === 0 || this.state.firstLoad || changedTenant || this.props.rosterState === null) { @@ -262,7 +263,8 @@ export class ShiftRosterPage extends React.Component { const startDate = moment(urlProps.week || new Date()).startOf('week').toDate(); const endDate = moment(startDate).endOf('week').toDate(); - const shownSpot = this.props.allSpotList.find(s => s.name === urlProps.spot) || this.props.shownSpotList[0]; + const shownSpot = this.props.allSpotList.find(s => s.name === urlProps.spot) + || (this.props.shownSpotList[0]); const score: HardMediumSoftScore = this.props.score || { hardScore: 0, mediumScore: 0, softScore: 0 }; const indictmentSummary: IndictmentSummary = this.props.indictmentSummary || { constraintToCountMap: {}, constraintToScoreImpactMap: {} }; @@ -363,7 +365,7 @@ export class ShiftRosterPage extends React.Component { defaultToDate={endDate} /> - key={this.props.shownSpotList[0].id} + key={(this.props.shownSpotList[0]).id} startDate={startDate} endDate={endDate} events={this.props.spotIdToShiftListMap.get(shownSpot.id as number) || []} diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/shift/ExportScheduleModal.tsx b/optaweb-employee-rostering-frontend/src/ui/pages/shift/ExportScheduleModal.tsx index aebef2d08..7ff4e17bf 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/shift/ExportScheduleModal.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/pages/shift/ExportScheduleModal.tsx @@ -124,7 +124,7 @@ export const ExportScheduleModal: React.FC = (props) => { value={exportedSpots} options={props.spotList} optionToStringMap={spot => spot.name} - onChange={setExportedSpots} + onChange={newExportedSpots => setExportedSpots(newExportedSpots)} /> diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/shift/ProvisionShiftsModal.test.tsx b/optaweb-employee-rostering-frontend/src/ui/pages/shift/ProvisionShiftsModal.test.tsx index a922bdd0f..7201ed33c 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/shift/ProvisionShiftsModal.test.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/pages/shift/ProvisionShiftsModal.test.tsx @@ -27,6 +27,7 @@ import { rosterSelectors } from 'store/roster'; import { RosterState } from 'domain/RosterState'; import { Modal, TextInput, Checkbox, AccordionToggle, AccordionContent } from '@patternfly/react-core'; import MultiTypeaheadSelectInput from 'ui/components/MultiTypeaheadSelectInput'; + import { ProvisionShiftsModal, ProvisionShiftsModalProps, SpotTimeBucketSelect, SpotTimeBucketSelectProps, diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/shift/ShiftRosterPage.tsx b/optaweb-employee-rostering-frontend/src/ui/pages/shift/ShiftRosterPage.tsx index 81f29638a..7c1213ec3 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/shift/ShiftRosterPage.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/pages/shift/ShiftRosterPage.tsx @@ -133,7 +133,8 @@ export class ShiftRosterPage extends React.Component { onUpdateShiftRoster(urlProps: ShiftRosterUrlProps) { if (this.props.rosterState) { - const spot = this.props.allSpotList.find(s => s.name === urlProps.spot) || this.props.allSpotList[0]; + const spot = this.props.allSpotList.find(s => s.name === urlProps.spot) + || (this.props.allSpotList[0] /* can be undefined */); const startDate = moment(urlProps.week || moment(this.props.rosterState.firstDraftDate)).startOf('week').toDate(); const endDate = moment(startDate).endOf('week').toDate(); @@ -238,7 +239,7 @@ export class ShiftRosterPage extends React.Component { week: null, }); const changedTenant = this.props.shownSpotList.length === 0 - || this.props.tenantId !== this.props.shownSpotList[0].tenantId; + || this.props.tenantId !== (this.props.shownSpotList[0]).tenantId; if (this.props.shownSpotList.length === 0 || this.state.firstLoad || changedTenant || this.props.rosterState === null) { @@ -265,7 +266,8 @@ export class ShiftRosterPage extends React.Component { const startDate = moment(urlProps.week || moment(this.props.rosterState.firstDraftDate)).startOf('week').toDate(); const endDate = moment(startDate).endOf('week').toDate(); - const shownSpot = this.props.allSpotList.find(s => s.name === urlProps.spot) || this.props.shownSpotList[0]; + const shownSpot = this.props.allSpotList.find(s => s.name === urlProps.spot) + || (this.props.shownSpotList[0]); const score: HardMediumSoftScore = this.props.score || { hardScore: 0, mediumScore: 0, softScore: 0 }; const indictmentSummary: IndictmentSummary = this.props.indictmentSummary || { constraintToCountMap: {}, constraintToScoreImpactMap: {} }; @@ -380,7 +382,7 @@ export class ShiftRosterPage extends React.Component { defaultToDate={endDate} /> - key={this.props.shownSpotList[0].id} + key={(this.props.shownSpotList[0]).id} startDate={startDate} endDate={endDate} events={this.props.spotIdToShiftListMap.get(shownSpot.id as number) || []} diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/skill/__snapshots__/SkillsPage.test.tsx.snap b/optaweb-employee-rostering-frontend/src/ui/pages/skill/__snapshots__/SkillsPage.test.tsx.snap index d10080ee7..4064a52ba 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/skill/__snapshots__/SkillsPage.test.tsx.snap +++ b/optaweb-employee-rostering-frontend/src/ui/pages/skill/__snapshots__/SkillsPage.test.tsx.snap @@ -271,7 +271,12 @@ exports[`Skills page should render correctly with a few skills 1`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 0, + } + } > @@ -345,7 +350,12 @@ exports[`Skills page should render correctly with no skills 1`] = ` } onSort={[Function]} rows={Array []} - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 0, + } + } > diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/spot/SpotsPage.test.tsx b/optaweb-employee-rostering-frontend/src/ui/pages/spot/SpotsPage.test.tsx index 88a2d6f9b..3443fc0c1 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/spot/SpotsPage.test.tsx +++ b/optaweb-employee-rostering-frontend/src/ui/pages/spot/SpotsPage.test.tsx @@ -95,7 +95,10 @@ describe('Spots page', () => { const spotsPage = new SpotsPage(twoSpots); const filter = spotsPage.getFilter(); - expect(twoSpots.tableData.filter(filter('1'))).toEqual([twoSpots.tableData[0], twoSpots.tableData[1]]); + expect(twoSpots.tableData.filter(filter('1'))).toEqual([ + twoSpots.tableData[0], + twoSpots.tableData[1], + ]); expect(twoSpots.tableData.filter(filter('Spot 1'))).toEqual([twoSpots.tableData[0]]); expect(twoSpots.tableData.filter(filter('2'))).toEqual([twoSpots.tableData[1]]); expect(twoSpots.tableData.filter(filter('Skill'))).toEqual([twoSpots.tableData[1]]); diff --git a/optaweb-employee-rostering-frontend/src/ui/pages/spot/__snapshots__/SpotsPage.test.tsx.snap b/optaweb-employee-rostering-frontend/src/ui/pages/spot/__snapshots__/SpotsPage.test.tsx.snap index 133f6672b..80c242f91 100644 --- a/optaweb-employee-rostering-frontend/src/ui/pages/spot/__snapshots__/SpotsPage.test.tsx.snap +++ b/optaweb-employee-rostering-frontend/src/ui/pages/spot/__snapshots__/SpotsPage.test.tsx.snap @@ -403,7 +403,12 @@ exports[`Spots page should render correctly with a few spots 1`] = ` }, ] } - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 0, + } + } > @@ -477,7 +482,12 @@ exports[`Spots page should render correctly with no spots 1`] = ` } onSort={[Function]} rows={Array []} - sortBy={Object {}} + sortBy={ + Object { + "direction": "asc", + "index": 0, + } + } > diff --git a/optaweb-employee-rostering-frontend/src/util/ImmutableCollectionOperations.test.ts b/optaweb-employee-rostering-frontend/src/util/ImmutableCollectionOperations.test.ts index 7a94d5755..f6a087d76 100644 --- a/optaweb-employee-rostering-frontend/src/util/ImmutableCollectionOperations.test.ts +++ b/optaweb-employee-rostering-frontend/src/util/ImmutableCollectionOperations.test.ts @@ -13,9 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { DomainObject } from 'domain/DomainObject'; -import DomainObjectView from 'domain/DomainObjectView'; import * as immutableCollectionOperations from './ImmutableCollectionOperations'; describe('Immutable Collection Operations', () => { @@ -35,92 +32,6 @@ describe('Immutable Collection Operations', () => { expect(original).toEqual(originalCopy); }); - it('should not modify the collection on without element with id', () => { - const object1: DomainObject = { - tenantId: 0, - id: 0, - version: 0, - }; - const object2: DomainObject = { - tenantId: 0, - id: 1, - version: 0, - }; - const collection: DomainObject[] = [object1, object2]; - const copy: DomainObject[] = JSON.parse(JSON.stringify(collection)); - const without1 = immutableCollectionOperations.withoutElementWithId(collection, 0); - expect(collection).toEqual(copy); - expect(without1).toEqual([object2]); - - const without2 = immutableCollectionOperations.withoutElementWithId(collection, 1); - expect(collection).toEqual(copy); - expect(without2).toEqual([object1]); - }); - - it('should not modify the collection on without element', () => { - const object1: DomainObject = { - tenantId: 0, - id: 0, - version: 0, - }; - const object2: DomainObject = { - tenantId: 0, - id: 1, - version: 0, - }; - const collection: DomainObject[] = [object1, object2]; - const copy: DomainObject[] = JSON.parse(JSON.stringify(collection)); - const without1 = immutableCollectionOperations.withoutElement(collection, object1); - expect(collection).toEqual(copy); - expect(without1).toEqual([object2]); - - const without2 = immutableCollectionOperations.withoutElement(collection, object2); - expect(collection).toEqual(copy); - expect(without2).toEqual([object1]); - }); - - it('should not modify the collection on with element', () => { - const object1: DomainObject = { - tenantId: 0, - id: 0, - version: 0, - }; - const addedObject: DomainObject = { - tenantId: 0, - id: 1, - version: 0, - }; - const collection: DomainObject[] = [object1]; - const copy: DomainObject[] = JSON.parse(JSON.stringify(collection)); - const withAdded = immutableCollectionOperations.withElement(collection, addedObject); - expect(collection).toEqual(copy); - expect(withAdded).toEqual([object1, addedObject]); - }); - - it('should not modify the collection on with updated element', () => { - const object1: DomainObject = { - tenantId: 0, - id: 0, - version: 0, - }; - const object2: DomainObject = { - tenantId: 0, - id: 1, - version: 0, - }; - const updatedObject1: DomainObject = { ...object1, version: 1 }; - const updatedObject2: DomainObject = { ...object2, version: 1 }; - const collection: DomainObject[] = [object1, object2]; - const copy: DomainObject[] = JSON.parse(JSON.stringify(collection)); - const withUpdated1 = immutableCollectionOperations.withUpdatedElement(collection, updatedObject1); - expect(collection).toEqual(copy); - expect(withUpdated1).toEqual([object2, updatedObject1]); - - const withUpdated2 = immutableCollectionOperations.withUpdatedElement(collection, updatedObject2); - expect(collection).toEqual(copy); - expect(withUpdated2).toEqual([object1, updatedObject2]); - }); - it('should not modify the collection when element is not present in the collection in toggleElement', () => { const obj1 = 0; const obj2 = 1; @@ -184,221 +95,4 @@ describe('Immutable Collection Operations', () => { expect(view.domainObjMemList).toEqual([6, 7]); expect(view.otherMem).toEqual('Test'); }); - - interface MockDomainObject extends DomainObject { - domainObj: DomainObject; - } - - it('should return a new map with the entry added in mapWithElement', () => { - const map = new Map>([ - [0, { - tenantId: 0, - id: 0, - version: 0, - domainObj: 3, - }], - ]); - - const obj = { - tenantId: 0, - id: 1, - version: 0, - domainObj: { - tenantId: 0, - id: 2, - version: 0, - }, - }; - - const copy = new Map(map); - const mapWithObj = immutableCollectionOperations.mapWithElement(map, obj); - - expect(map).toEqual(copy); - expect(mapWithObj).toEqual(new Map([ - [0, map.get(0)], - [1, { - tenantId: 0, - id: 1, - version: 0, - domainObj: 2, - }], - ])); - }); - - it('should return a new map with the entry removed in mapWithoutElement', () => { - const map = new Map>([ - [0, { - tenantId: 0, - id: 0, - version: 0, - domainObj: 3, - }], - [1, { - tenantId: 0, - id: 1, - version: 0, - domainObj: 2, - }], - ]); - - const obj = { - tenantId: 0, - id: 1, - version: 0, - domainObj: { - tenantId: 0, - id: 2, - version: 0, - }, - }; - - const copy = new Map(map); - const mapWithoutObj = immutableCollectionOperations.mapWithoutElement(map, obj); - - expect(map).toEqual(copy); - expect(mapWithoutObj).toEqual(new Map([ - [0, map.get(0)], - ])); - }); - - it('should return a new map with the entry update in mapWithUpdatedElement', () => { - const map = new Map>([ - [0, { - tenantId: 0, - id: 0, - version: 0, - domainObj: 3, - }], - [1, { - tenantId: 0, - id: 1, - version: 0, - domainObj: 2, - }], - ]); - - const obj = { - tenantId: 0, - id: 1, - version: 1, - domainObj: { - tenantId: 0, - id: 5, - version: 0, - }, - }; - - const copy = new Map(map); - const mapWithUpdatedObj = immutableCollectionOperations.mapWithUpdatedElement(map, obj); - - expect(map).toEqual(copy); - expect(mapWithUpdatedObj).toEqual(new Map([ - [0, map.get(0)], - [1, { - tenantId: 0, - id: 1, - version: 1, - domainObj: 5, - }], - ])); - }); -}); - -describe('Stream operations', () => { - it('should map the collection element in map', () => { - const orig = [1, 2, 3]; - const result = new immutableCollectionOperations.Stream(orig).map(n => n + 1).collect(r => r); - expect(result).not.toBe(orig); - expect(orig).toEqual([1, 2, 3]); - expect(result).toEqual([2, 3, 4]); - }); - - it('should filter the collection element in filter', () => { - const orig = [1, 2, 3]; - const result = new immutableCollectionOperations.Stream(orig).filter(n => n === 1).collect(r => r); - expect(result).not.toBe(orig); - expect(orig).toEqual([1, 2, 3]); - expect(result).toEqual([1]); - }); - - it('should paged the collection element in page', () => { - const orig = [1, 2, 3]; - const result = new immutableCollectionOperations.Stream(orig).page(1, 2).collect(r => r); - expect(result).not.toBe(orig); - expect(orig).toEqual([1, 2, 3]); - expect(result).toEqual([1, 2]); - }); - - it('should sort the collection element in sort', () => { - const orig = [1, 2, 3]; - const result = new immutableCollectionOperations.Stream(orig).sort((a, b) => b - a).collect(r => r); - expect(result).not.toBe(orig); - expect(orig).toEqual([1, 2, 3]); - expect(result).toEqual([3, 2, 1]); - - const revResult = new immutableCollectionOperations.Stream(orig).sort((a, b) => b - a, false).collect(r => r); - expect(revResult).not.toBe(orig); - expect(orig).toEqual([1, 2, 3]); - expect(revResult).toEqual([1, 2, 3]); - }); - - it('should replace the stream in conditionally', () => { - const orig = [1, 2, 3]; - const replaceResult = new immutableCollectionOperations.Stream(orig) - .conditionally(s => s.filter(x => x === 1)).collect(r => r); - expect(replaceResult).not.toBe(orig); - expect(orig).toEqual([1, 2, 3]); - expect(replaceResult).toEqual([1]); - - const noReplaceResult = new immutableCollectionOperations.Stream(orig) - .conditionally(() => undefined).collect(r => r); - expect(noReplaceResult).not.toBe(orig); - expect(orig).toEqual([1, 2, 3]); - expect(noReplaceResult).toEqual([1, 2, 3]); - }); - - it('should collect a result in collect', () => { - const orig = [1, 2, 3]; - const result = new immutableCollectionOperations.Stream(orig).collect(r => r); - expect(result).not.toBe(orig); - expect(orig).toEqual([1, 2, 3]); - expect(result).toEqual([1, 2, 3]); - - const length = new immutableCollectionOperations.Stream(orig).collect(r => r.length); - expect(length).toEqual(3); - }); - - it('streams themselves should be immutable', () => { - const stream = new immutableCollectionOperations.Stream([1, 2, 3]); - const filterStream = stream.filter(n => n === 1); - expect(stream).not.toBe(filterStream); - expect(stream.collect(c => c)).toEqual([1, 2, 3]); - expect(filterStream.collect(c => c)).toEqual([1]); - - const mapStream = stream.map(n => n + 1); - expect(stream).not.toBe(mapStream); - expect(stream.collect(c => c)).toEqual([1, 2, 3]); - expect(mapStream.collect(c => c)).toEqual([2, 3, 4]); - - const pageStream = stream.page(1, 2); - expect(stream).not.toBe(pageStream); - expect(stream.collect(c => c)).toEqual([1, 2, 3]); - expect(pageStream.collect(c => c)).toEqual([1, 2]); - - const sortStream = stream.sort((a, b) => b - a); - expect(stream).not.toBe(sortStream); - expect(stream.collect(c => c)).toEqual([1, 2, 3]); - expect(sortStream.collect(c => c)).toEqual([3, 2, 1]); - - const replaceStream = stream.conditionally(s => s.map(x => x + 1)); - expect(stream).not.toBe(replaceStream); - expect(stream.collect(c => c)).toEqual([1, 2, 3]); - expect(replaceStream.collect(c => c)).toEqual([2, 3, 4]); - - const noReplaceStream = stream.conditionally(() => undefined); - // In the no replace case, the collection doesn't change, so it safe - // for the stream to be the same - expect(stream.collect(c => c)).toEqual([1, 2, 3]); - expect(noReplaceStream.collect(c => c)).toEqual([1, 2, 3]); - }); }); diff --git a/optaweb-employee-rostering-frontend/src/util/ImmutableCollectionOperations.ts b/optaweb-employee-rostering-frontend/src/util/ImmutableCollectionOperations.ts index 5b7cd8c90..150da031d 100644 --- a/optaweb-employee-rostering-frontend/src/util/ImmutableCollectionOperations.ts +++ b/optaweb-employee-rostering-frontend/src/util/ImmutableCollectionOperations.ts @@ -16,7 +16,7 @@ import { DomainObject } from 'domain/DomainObject'; import DomainObjectView from 'domain/DomainObjectView'; -import { Sorter } from 'types'; +import { Map, Seq } from 'immutable'; export function toggleElement(collection: T[], element: T): T[] { if (collection.indexOf(element) !== -1) { @@ -26,22 +26,6 @@ export function toggleElement(collection: T[], element: T): T[] { return [...collection, element]; } -export function withoutElementWithId(collection: T[], removedElementId: number): T[] { - return collection.filter(element => element.id !== removedElementId); -} - -export function withoutElement(collection: T[], removedElement: T): T[] { - return withoutElementWithId(collection, removedElement.id as number); -} - -export function withElement(collection: T[], addedElement: T): T[] { - return [...collection, addedElement]; -} - -export function withUpdatedElement(collection: T[], updatedElement: T): T[] { - return withElement(withoutElement(collection, updatedElement), updatedElement); -} - interface ObjectWithKeys { [key: string]: any; } @@ -71,62 +55,20 @@ export function mapDomainObjectToView(obj: T|DomainObjec } export function createIdMapFromList(collection: T[]): Map> { - const map = new Map>(); - collection.forEach(ele => map.set(ele.id as number, mapDomainObjectToView(ele))); + let map = Map>(); + collection.forEach((ele) => { (map = map.set(ele.id as number, mapDomainObjectToView(ele))); }); return map; } -export function mapWithoutElement(map: Map>, - removedElement: T|DomainObjectView): Map> { - const copy = new Map>(map); - copy.delete(removedElement.id as number); - return copy; -} - -export function mapWithElement(map: Map>, - addedElement: T|DomainObjectView): Map> { - const copy = new Map>(map); - copy.set(addedElement.id as number, mapDomainObjectToView(addedElement)); - return copy; +export interface FluentValue { + result: V; + then: (map: M) => FluentValue; } - -export function mapWithUpdatedElement(map: Map>, - updatedElement: T|DomainObjectView): Map> { - return mapWithElement(map, updatedElement); -} - -// An immutable version of a collection with a lot of helpful methods -export class Stream { - private collection: T[]; - - constructor(collection: T[]) { - this.collection = collection; - } - - map(fn: (ele: T, index: number) => X): Stream { - return new Stream(this.collection.map(fn)); - } - - filter(predicate: (ele: T, index: number) => boolean): Stream { - return new Stream(this.collection.filter(predicate)); - } - - // Note: page starts at 1 - page(page: number, perPage: number): Stream { - return this.filter((v, i) => (page - 1) * perPage <= i && i < page * perPage); - } - - sort(sorter: Sorter, asc = true): Stream { - const comparator: Sorter = asc ? sorter : (a, b) => sorter(b, a); - return new Stream([...this.collection].sort(comparator)); - } - - conditionally(streamMap: (stream: Stream) => Stream|undefined): Stream { - const out = streamMap(this); - return (out !== undefined) ? out : this; - } - - collect(collector: (collection: T[]) => X): X { - return collector([...this.collection]); - } +export function conditionally(seq: Seq, mapper: (seq: Seq) => Seq | undefined): +FluentValue, (seq: Seq) => Seq | undefined> { + const out = mapper(seq) || seq; + return { + result: out, + then: newMapper => conditionally(out, newMapper), + }; }