diff --git a/.eslintrc b/.eslintrc index 0d1ae757..853e0ec2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,4 +1,18 @@ { - "parser": "babel-eslint", - "extends": ["@folio/eslint-config-stripes/acquisitions"] + "extends": ["@folio/eslint-config-stripes", "plugin:import/typescript"], + "env": { + "jest": true + }, + "rules": { + "import/no-named-as-default": "off" + }, + "overrides": [ + { + "files": ["src/test/**/*", "src/**/*.test.ts", "src/**/*.test.tsx"], + "env": { "jest": true }, + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } + } + ] } diff --git a/.github/workflows/build-npm-release.yml b/.github/workflows/build-npm-release.yml index 8d7a17f7..f1d6ab3f 100644 --- a/.github/workflows/build-npm-release.yml +++ b/.github/workflows/build-npm-release.yml @@ -19,9 +19,9 @@ on: jobs: github-actions-ci: - if : ${{ startsWith(github.ref, 'refs/tags/v') }} + if: ${{ startsWith(github.ref, 'refs/tags/v') }} env: - YARN_TEST_OPTIONS: '' + YARN_TEST_OPTIONS: '--coverage' SQ_ROOT_DIR: './src' COMPILE_TRANSLATION_FILES: 'true' PUBLISH_MOD_DESCRIPTOR: 'true' @@ -35,7 +35,7 @@ jobs: BIGTEST_COVERAGE_REPORT_DIR: 'artifacts/coverage/lcov-report/' OKAPI_PULL: '{ "urls" : [ "https://folio-registry.dev.folio.org" ] }' SQ_LCOV_REPORT: 'artifacts/coverage-jest/lcov.info' - SQ_EXCLUSIONS: '**/platform/alias-service.js,**/docs/**,**/node_modules/**,**/examples/**,**/artifacts/**,**/ci/**,Jenkinsfile,**/LICENSE,**/*.css,**/*.md,**/*.json,**/tests/**,**/stories/*.js,**/test/**,**/.stories.js,**/resources/bigtest/interactors/**,**/resources/bigtest/network/**,**/*-test.js,**/*.test.js,**/*-spec.js,**/karma.conf.js,**/jest.config.js' + SQ_EXCLUSIONS: '**/platform/alias-service.js,**/docs/**,**/node_modules/**,**/examples/**,**/artifacts/**,**/ci/**,Jenkinsfile,**/LICENSE,**/*.css,**/*.md,**/*.json,**/tests/**,**/stories/*.js,**/test/**,**/.stories.js,**/resources/bigtest/interactors/**,**/resources/bigtest/network/**,**/*-test.js,**/*.test.js,**/*.test.ts,**/*.test.tsx,**/*-spec.js,**/karma.conf.js,**/jest.config.js' runs-on: ubuntu-latest steps: @@ -88,10 +88,10 @@ jobs: continue-on-error: true - name: Run yarn test - run: xvfb-run --server-args="-screen 0 1024x768x24" yarn test $YARN_TEST_OPTIONS + run: yarn node --expose-gc $(yarn bin jest) --runInBand --logHeapUsage $YARN_TEST_OPTIONS - name: Run yarn formatjs-compile - if : ${{ env.COMPILE_TRANSLATION_FILES == 'true' }} + if: ${{ env.COMPILE_TRANSLATION_FILES == 'true' }} run: yarn formatjs-compile - name: Generate FOLIO module descriptor @@ -146,7 +146,7 @@ jobs: if: always() with: github_token: ${{ github.token }} - files: "${{ env.JEST_JUNIT_OUTPUT_DIR }}/*.xml" + files: '${{ env.JEST_JUNIT_OUTPUT_DIR }}/*.xml' check_name: Jest Unit Test Results comment_mode: update last comment_title: Jest Unit Test Statistics @@ -164,7 +164,7 @@ jobs: if: always() with: github_token: ${{ github.token }} - files: "${{ env.BIGTEST_JUNIT_OUTPUT_DIR }}/*.xml" + files: '${{ env.BIGTEST_JUNIT_OUTPUT_DIR }}/*.xml' check_name: BigTest Unit Test Results comment_mode: update last comment_title: BigTest Unit Test Statistics @@ -244,4 +244,3 @@ jobs: data: ${{ steps.moduleDescriptor.outputs.content }} username: ${{ secrets.FOLIO_REGISTRY_USERNAME }} password: ${{ secrets.FOLIO_REGISTRY_PASSWORD }} - diff --git a/.github/workflows/build-npm.yml b/.github/workflows/build-npm.yml index 47e43de7..2d6e3665 100644 --- a/.github/workflows/build-npm.yml +++ b/.github/workflows/build-npm.yml @@ -10,15 +10,13 @@ # - PUBLISH_MOD_DESCRIPTOR (boolean 'true' or 'false') # - COMPILE_TRANSLATION_FILES (boolean 'true' or 'false') - - name: buildNPM Snapshot on: [push, pull_request] jobs: github-actions-ci: env: - YARN_TEST_OPTIONS: '' + YARN_TEST_OPTIONS: '--coverage' SQ_ROOT_DIR: './src' COMPILE_TRANSLATION_FILES: 'true' PUBLISH_MOD_DESCRIPTOR: 'true' @@ -31,7 +29,7 @@ jobs: BIGTEST_JUNIT_OUTPUT_DIR: 'artifacts/runTest' BIGTEST_COVERAGE_REPORT_DIR: 'artifacts/coverage/lcov-report/' SQ_LCOV_REPORT: 'artifacts/coverage-jest/lcov.info' - SQ_EXCLUSIONS: '**/platform/alias-service.js,**/docs/**,**/node_modules/**,**/examples/**,**/artifacts/**,**/ci/**,Jenkinsfile,**/LICENSE,**/*.css,**/*.md,**/*.json,**/tests/**,**/stories/*.js,**/test/**,**/.stories.js,**/resources/bigtest/interactors/**,**/resources/bigtest/network/**,**/*-test.js,**/*.test.js,**/*-spec.js,**/karma.conf.js,**/jest.config.js' + SQ_EXCLUSIONS: '**/platform/alias-service.js,**/docs/**,**/node_modules/**,**/examples/**,**/artifacts/**,**/ci/**,Jenkinsfile,**/LICENSE,**/*.css,**/*.md,**/*.json,**/tests/**,**/stories/*.js,**/test/**,**/.stories.js,**/resources/bigtest/interactors/**,**/resources/bigtest/network/**,**/*-test.js,**/*.test.js,**/*.test.ts,**/*.test.tsx,**/*-spec.js,**/karma.conf.js,**/jest.config.js' runs-on: ubuntu-latest steps: @@ -70,7 +68,7 @@ jobs: continue-on-error: true - name: Run yarn test - run: xvfb-run --server-args="-screen 0 1024x768x24" yarn test $YARN_TEST_OPTIONS + run: yarn node --expose-gc $(yarn bin jest) --runInBand --logHeapUsage $YARN_TEST_OPTIONS - name: Run yarn formatjs-compile if: ${{ env.COMPILE_TRANSLATION_FILES == 'true' }} @@ -89,7 +87,7 @@ jobs: if: always() with: github_token: ${{ github.token }} - files: "${{ env.JEST_JUNIT_OUTPUT_DIR }}/*.xml" + files: '${{ env.JEST_JUNIT_OUTPUT_DIR }}/*.xml' check_name: Jest Unit Test Results comment_mode: update last comment_title: Jest Unit Test Statistics @@ -107,7 +105,7 @@ jobs: if: always() with: github_token: ${{ github.token }} - files: "${{ env.BIGTEST_JUNIT_OUTPUT_DIR }}/*.xml" + files: '${{ env.BIGTEST_JUNIT_OUTPUT_DIR }}/*.xml' check_name: BigTest Unit Test Results comment_mode: update last comment_title: BigTest Unit Test Statistics @@ -198,4 +196,3 @@ jobs: data: ${{ steps.moduleDescriptor.outputs.content }} username: ${{ secrets.FOLIO_REGISTRY_USERNAME }} password: ${{ secrets.FOLIO_REGISTRY_PASSWORD }} - diff --git a/.gitignore b/.gitignore index 6a8c86a4..119d429e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ stripes.config.js.local # tests /artifacts junit.xml + diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ed4092..0446ce08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Change history for ui-plugin-bursar-export -## 3.1.0 (IN PROGRESS) +## 4.0.0 (IN PROGRESS) +* Support the new bursar export configuration feature and revamped UI. Refs UXPROD-3603. * Also support `feesfines` interface version `19.0`. Refs UIPBEX-55. -* Support `data-export-spring` interface `v2.0`. Refs UXPROD-3903. ## [3.0.0](https://github.com/folio-org/ui-plugin-bursar-export/tree/v3.0.0) (2023-10-16) [Full Changelog](https://github.com/folio-org/ui-plugin-bursar-export/compare/v2.4.0...v3.0.0) diff --git a/index.js b/index.js deleted file mode 100644 index 59cff294..00000000 --- a/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './src'; diff --git a/index.ts b/index.ts new file mode 100644 index 00000000..50f7f6ee --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +export { default } from './src/index'; diff --git a/jest.config.js b/jest.config.js index 2c86ec51..1fcc85f1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,12 +1,45 @@ -const path = require('path'); -const stripesConfig = require('@folio/jest-config-stripes'); -const acqConfig = require('@folio/stripes-acq-components/jest.config'); +const { join } = require('path'); +const config = require('@folio/jest-config-stripes'); +/** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */ module.exports = { - ...stripesConfig, + ...config, + + coverageProvider: 'v8', + + moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json'], + + testMatch: ['**/src/**/?(*.)test.{js,jsx,ts,tsx}'], + + coverageReporters: ['lcov', 'text'], + collectCoverageFrom: [ + '/index.ts', + '/src/**/*.{ts,tsx}', + '!/src/**/*.d.ts', + '!/src/**/*.test.{ts,tsx}', + '!/src/test/**', + '!**/node_modules/**', + ], + setupFiles: [ - ...stripesConfig.setupFiles, - ...acqConfig.setupFiles, - path.join(__dirname, './test/jest/setupFiles.js'), + ...config.setupFiles, + join(__dirname, './test/setupTests.ts') ], + setupFilesAfterEnv: [join(__dirname, './test/jest.setup.ts')], + + preset: 'ts-jest', + transform: { + '^.+\\.(ts|tsx)?$': 'ts-jest', + ...config.transform + }, + + moduleNameMapper: { + '^.+\\.(css|svg)$': 'identity-obj-proxy', + + // Force module uuid to resolve with the CJS entry point, because Jest does not support package.json.exports. See https://github.com/uuidjs/uuid/issues/451 + uuid: require.resolve('uuid'), + }, + + slowTestThreshold: 10, + testTimeout: 20000, }; diff --git a/package.json b/package.json index 5de93788..ce35d33a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@folio/plugin-bursar-export", - "version": "3.1.0", + "version": "4.0.0", "description": "Bursar export", - "main": "index.js", + "main": "src/index.js", "repository": "", "license": "Apache-2.0", "engines": { @@ -17,25 +17,47 @@ "okapiInterfaces": { "users": "15.0 16.0", "feesfines": "16.0 17.0 18.0 19.0", - "data-export-spring": "1.0 2.0" + "data-export-spring": "2.0", + "service-points": "3.0", + "location-units": "2.0", + "locations": "3.0" }, - "stripesDeps": [ - "@folio/stripes-acq-components" - ], "permissionSets": [ { "permissionName": "ui-plugin-bursar-export.bursar-exports.all", - "displayName": "Bursar exports: Bursar admin", + "displayName": "Transfer exports: Modify configuration and start jobs", + "subPermissions": [ + "ui-plugin-bursar-export.bursar-exports.manual", + "data-export.config.item.post", + "data-export.config.item.put" + ], + "visible": true + }, + { + "permissionName": "ui-plugin-bursar-export.bursar-exports.manual", + "displayName": "Transfer exports: Start manual jobs", + "subPermissions": [ + "ui-plugin-bursar-export.bursar-exports.view", + "data-export.job.item.post" + ], + "visible": true + }, + { + "permissionName": "ui-plugin-bursar-export.bursar-exports.view", + "displayName": "Transfer exports: View configuration", "subPermissions": [ "settings.tenant-settings.enabled", "usergroups.collection.get", "owners.collection.get", "transfers.collection.get", "feefines.collection.get", - "data-export.config.collection.get", - "data-export.config.item.post", - "data-export.config.item.put", - "data-export.job.item.post" + "inventory-storage.service-points.collection.get", + "inventory-storage.location-units.institutions.collection.get", + "inventory-storage.location-units.campuses.collection.get", + "inventory-storage.location-units.campuses.collection.get", + "inventory-storage.location-units.libraries.collection.get", + "inventory-storage.locations.collection.get", + "data-export.config.collection.get" ], "visible": true } @@ -47,15 +69,10 @@ "lint": "eslint .", "build-mod-descriptor": "stripes mod descriptor --full --strict | jq '.[]' > module-descriptor.json ", "start": "yarn stripes serve", - "test:unit": "jest --ci --coverage && yarn run test:unit:report", - "test:unit:watch": "jest --ci --coverage --watch", - "test:unit:report": "cp -r ./artifacts/coverage-jest ./artifacts/coverage", - "test:e2e": "echo Not implemented", - "test": "yarn run test:unit && yarn run test:e2e" + "test": "jest", + "coverage": "jest --coverage" }, "devDependencies": { - "@babel/core": "^7.8.0", - "@babel/eslint-parser": "^7.18.2", "@babel/plugin-proposal-class-properties": "^7.0.0", "@babel/plugin-proposal-decorators": "^7.0.0", "@babel/plugin-transform-runtime": "^7.0.0", @@ -65,22 +82,24 @@ "@folio/jest-config-stripes": "^2.0.0", "@folio/stripes": "^9.0.0", "@folio/stripes-cli": "^3.0.0", - "@formatjs/cli": "^6.1.3", + "@formatjs/cli": "^6.2.0", + "@types/react": "^18.2.0", "core-js": "^3.6.1", - "eslint": "^6.2.1", "eslint-plugin-filenames": "^1.3.2", - "eslint-plugin-jest": "^23.0.4", - "faker": "^4.1.0", + "identity-obj-proxy": "^3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-intl": "^6.4.4", "react-query": "^3.6.0", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", - "regenerator-runtime": "^0.13.3" + "regenerator-runtime": "^0.13.3", + "ts-jest": "^29.1.1", + "typescript": "^4.7.4" }, "dependencies": { - "@folio/stripes-acq-components": "~5.0.0", + "@ngneat/falso": "^6.4.0", + "classnames": "^2.3.2", "lodash": "^4.17.5", "prop-types": "^15.5.10", "react-final-form": "^6.3.0", @@ -90,6 +109,7 @@ }, "peerDependencies": { "@folio/stripes": "^9.0.0", + "@types/react": "^18.2.0", "final-form": "^4.18.2", "final-form-arrays": "^3.0.1", "react": "^18.2.0", diff --git a/src/BursarExportPlugin.test.tsx b/src/BursarExportPlugin.test.tsx new file mode 100644 index 00000000..b78f4b78 --- /dev/null +++ b/src/BursarExportPlugin.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import { useStripes } from '@folio/stripes/core'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form, FormProps } from 'react-final-form'; +import withIntlConfiguration from '../test/util/withIntlConfiguration'; +import { formValuesToDto, schedulingToDto } from './api/dto/to'; +import { FORM_ID } from './constants'; +import useInitialValues from './hooks/useInitialValues'; +import BursarExportPlugin from './index'; + +jest.mock('./api/dto/to', () => ({ formValuesToDto: jest.fn(), schedulingToDto: jest.fn() })); + +jest.mock('./api/mutators', () => ({ + useManualSchedulerMutation: () => jest.fn(), + useAutomaticSchedulerMutation: () => jest.fn(), +})); +jest.mock('./hooks/useInitialValues', () => jest.fn()); +jest.mock('@folio/stripes/final-form', () => ({ + __esModule: true, + default: () => (Component: any) => (props: FormProps) => ( +
+ {(formProps) => } + + ), +})); + +const feeFineOwner = { + owner: 'Test owner', + id: 'test_owner_id', +}; +const transferAccount = { + accountName: 'Test account', + ownerId: 'test_owner_id', + id: 'test_account_id', + desc: 'Test description', +}; + +jest.mock('./api/queries', () => ({ + useFeeFineOwners: () => ({ data: [feeFineOwner], isSuccess: true }), + useTransferAccounts: () => ({ data: [transferAccount], isSuccess: true }), +})); + +describe('BursarExportPlugin', () => { + it('renders the plugin with null initial values', () => { + (useInitialValues as jest.Mock).mockReturnValue(null); + + render(withIntlConfiguration()); + + expect(screen.getByText('Transfer configuration')).toBeVisible(); + expect(screen.queryByTestId(FORM_ID)).toBeNull(); + }); + + it('fills out the form and then saves and runs the plugin', async () => { + (useInitialValues as jest.Mock).mockReturnValue({ aggregate: false }); + (useStripes as jest.Mock).mockReturnValue({ hasPerm: () => true }); + + render(withIntlConfiguration()); + + expect(screen.getByText('Transfer configuration')).toBeVisible(); + expect(screen.queryByTestId(FORM_ID)).toBeVisible(); + expect(screen.getByText('Account data format')).toBeVisible(); + expect(screen.getByText('Save')).toBeVisible(); + + expect(screen.queryByText('Transfer to:')).not.toBeNull(); + + const frequencyDropdown = screen.getByRole('combobox', { + name: 'Frequency', + }) as HTMLSelectElement; + await userEvent.selectOptions(frequencyDropdown, 'NONE'); + + const ownerDropdown = screen.getByRole('combobox', { + name: 'Fee/fine owner', + }) as HTMLSelectElement; + await userEvent.selectOptions(ownerDropdown, 'test_owner_id'); + + const accountDropdown = screen.getByRole('combobox', { + name: 'Transfer account', + }) as HTMLSelectElement; + await userEvent.selectOptions(accountDropdown, 'test_account_id'); + + await userEvent.click(screen.getByText('Save')); + + expect(formValuesToDto).toHaveBeenCalled(); + expect(schedulingToDto).toHaveBeenCalled(); + + await userEvent.click(screen.getByText('Run manually')); + + expect(formValuesToDto).toHaveBeenCalled(); + }); +}); diff --git a/src/BursarExportPlugin.tsx b/src/BursarExportPlugin.tsx new file mode 100644 index 00000000..7056106f --- /dev/null +++ b/src/BursarExportPlugin.tsx @@ -0,0 +1,87 @@ +import { Button, LoadingPane, Pane, PaneFooter } from '@folio/stripes/components'; +import { useStripes } from '@folio/stripes/core'; +import { FormApi } from 'final-form'; +import React, { useCallback, useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { formValuesToDto, schedulingToDto } from './api/dto/to'; +import { useAutomaticSchedulerMutation, useManualSchedulerMutation } from './api/mutators'; +import ConfigurationForm from './components/ConfigurationForm'; +import { FORM_ID } from './constants'; +import useInitialValues from './hooks/useInitialValues'; +import { FormValues } from './types'; + +export default function BursarExportPlugin() { + const stripes = useStripes(); + + const initialValues = useInitialValues(); + const scheduleManually = useManualSchedulerMutation(); + const scheduleAutomatically = useAutomaticSchedulerMutation(); + + const formApiRef = useRef>(null); + + const submitCallback = useCallback( + (values: FormValues) => { + scheduleAutomatically({ + bursar: formValuesToDto(values), + scheduling: schedulingToDto(values.scheduling), + }); + }, + [scheduleAutomatically], + ); + + const runManuallyCallback = useCallback(() => { + const values = formApiRef.current?.getState().values; + if (values) { + scheduleManually(formValuesToDto(values)); + } + }, [formApiRef, scheduleManually]); + + const footer = ( + + + + } + renderEnd={ + + } + /> + ); + + if (initialValues === null) { + return ( + } + defaultWidth="fill" + footer={footer} + /> + ); + } + + return ( + } + > + + + ); +} diff --git a/src/BursarExports.js b/src/BursarExports.js deleted file mode 100644 index cf268b0c..00000000 --- a/src/BursarExports.js +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useState } from 'react'; -import { useIntl } from 'react-intl'; - -import { - Button, - LoadingPane, - Pane, - PaneFooter, -} from '@folio/stripes/components'; -import { - useShowCallout, -} from '@folio/stripes-acq-components'; - -import { - useBursarConfigQuery, - useBursarConfigMutation, - usePatronGroupsQuery, -} from './apiQuery'; -import { BursarExportsConfiguration } from './BursarExportsConfiguration'; -import { BursarExportsManualRunner } from './BursarExportsManualRunner'; - -export const BursarExports = () => { - const { formatMessage } = useIntl(); - const showCallout = useShowCallout(); - - const { patronGroups } = usePatronGroupsQuery(); - const { isLoading, bursarConfig } = useBursarConfigQuery(); - const { mutateBursarConfig } = useBursarConfigMutation({ - onSuccess: () => { - return showCallout({ - message: formatMessage({ id: 'ui-plugin-bursar-export.bursarExports.save.success' }), - }); - }, - onError: () => { - return showCallout({ - message: formatMessage({ id: 'ui-plugin-bursar-export.bursarExports.save.error' }), - type: 'error', - }); - }, - }); - - const [bursarConfigForm, setBursarConfigForm] = useState(); - const bursarConfigFormState = bursarConfigForm?.getState(); - - const saveBursarConfig = () => { - return bursarConfigForm?.submit(); - }; - - const paneFooter = ( - - } - renderEnd={ - - } - /> - ); - - if (isLoading) { - return ( - - ); - } - - return ( - - - - ); -}; diff --git a/src/BursarExports.test.js b/src/BursarExports.test.js deleted file mode 100644 index 153b596c..00000000 --- a/src/BursarExports.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react'; - -import { render } from '@folio/jest-config-stripes/testing-library/react'; -import user from '@folio/jest-config-stripes/testing-library/user-event'; - -import { - useBursarConfigQuery, - useBursarConfigMutation, -} from './apiQuery'; -import { BursarExports } from './BursarExports'; - -const BursarExportsConfiguration = 'BursarExportsConfiguration'; -const BursarExportsManualRunner = 'BursarExportsManualRunner'; - -jest.mock('./apiQuery', () => { - return { - useBursarConfigQuery: jest.fn(), - useBursarConfigMutation: jest.fn(), - usePatronGroupsQuery: jest.fn().mockReturnValue({}), - }; -}); - -jest.mock('./BursarExportsConfiguration', () => { - // eslint-disable-next-line global-require - const { useEffect } = require('react'); - - return { - BursarExportsConfiguration: ({ onFormStateChanged, onSubmit }) => { - useEffect(() => { - onFormStateChanged({ - submit: onSubmit, - getState: jest.fn(), - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return BursarExportsConfiguration; - }, - }; -}); - -jest.mock('./BursarExportsManualRunner', () => { - return { - BursarExportsManualRunner: () => BursarExportsManualRunner, - }; -}); - -const renderBursarExports = () => render(); - -describe('BursarExports', () => { - beforeEach(() => { - useBursarConfigQuery.mockClear().mockReturnValue({ - isLoading: false, - bursarConfig: {}, - }); - useBursarConfigMutation.mockReturnValue({ - mutateBursarConfig: jest.fn(), - }); - }); - - it('should render configuration form', () => { - const { getByText } = renderBursarExports(); - - expect(getByText(BursarExportsConfiguration)).toBeDefined(); - }); - - it('should render run manually button', () => { - const { getByText } = renderBursarExports(); - - expect(getByText(BursarExportsManualRunner)).toBeDefined(); - }); - - it('should render save button', () => { - const { getByText } = renderBursarExports(); - - expect(getByText('ui-plugin-bursar-export.bursarExports.save')).toBeDefined(); - }); - - it('should not render form when config is fetching', () => { - useBursarConfigQuery.mockClear().mockReturnValue({ - isLoading: true, - }); - - const { queryByText } = renderBursarExports(); - - expect(queryByText(BursarExportsConfiguration)).toBeNull(); - }); - - it('should call query mutator when form is submitted via save button', async () => { - const mutateBursarConfig = jest.fn(); - - useBursarConfigMutation.mockReturnValue({ mutateBursarConfig }); - - const { getByText } = renderBursarExports(); - - await user.click(getByText('ui-plugin-bursar-export.bursarExports.save')); - - expect(mutateBursarConfig).toHaveBeenCalled(); - }); -}); diff --git a/src/BursarExportsConfiguration/BursarExportsConfiguration.css b/src/BursarExportsConfiguration/BursarExportsConfiguration.css deleted file mode 100644 index 7688f53d..00000000 --- a/src/BursarExportsConfiguration/BursarExportsConfiguration.css +++ /dev/null @@ -1,3 +0,0 @@ -.bursarExportsConfiguration { - height: 100%; -} diff --git a/src/BursarExportsConfiguration/BursarExportsConfiguration.js b/src/BursarExportsConfiguration/BursarExportsConfiguration.js deleted file mode 100644 index 4fe18f98..00000000 --- a/src/BursarExportsConfiguration/BursarExportsConfiguration.js +++ /dev/null @@ -1,265 +0,0 @@ -import React, { useEffect, useMemo } from 'react'; -import PropTypes from 'prop-types'; -import { Field } from 'react-final-form'; -import { FieldArray } from 'react-final-form-arrays'; -import { useIntl } from 'react-intl'; - -import stripesFinalForm from '@folio/stripes/final-form'; -import { - Checkbox, - Col, - Label, - Row, - Select, - TextField, - Timepicker, -} from '@folio/stripes/components'; - -import { - SCHEDULE_PERIODS, - WEEKDAYS, -} from './constants'; -import { diffTransferTypes } from './utils'; -import { validateRequired } from './validation'; -import { BursarItemsField } from './BursarItemsField'; -import { FeeFineOwnerField } from './FeeFineOwnerField'; -import { TransferAccountField } from './TransferAccountField'; -import { PatronGroupsField } from './PatronGroupsField'; - -import styles from './BursarExportsConfiguration.css'; - -const normalizeNumber = value => { - if (!value && value !== 0) return value; - - return Number(value); -}; - -export const BursarExportsConfigurationForm = ({ - form, - handleSubmit, - onFormStateChanged, - pristine, - submitting, - patronGroups, -}) => { - const { formatMessage } = useIntl(); - - useEffect(() => { - onFormStateChanged(form); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pristine, submitting]); - - const schedulePeriodOptions = useMemo(() => ( - Object.keys(SCHEDULE_PERIODS) - .map(periodKey => ({ - label: formatMessage({ - id: `ui-plugin-bursar-export.bursarExports.schedulePeriod.${periodKey}`, - }), - value: SCHEDULE_PERIODS[periodKey], - })) - ), [formatMessage]); - - const formValues = form.getState()?.values || {}; - - return ( -
- - - - - - { - formValues.schedulePeriod !== SCHEDULE_PERIODS.none && ( - - - - ) - } - - - { - formValues.schedulePeriod === SCHEDULE_PERIODS.weeks && ( - - - - - - { - ({ fields }) => WEEKDAYS.map((weekday, index) => ( - - )) - } - - - - ) - } - - { - [SCHEDULE_PERIODS.days, SCHEDULE_PERIODS.weeks].includes(formValues.schedulePeriod) && ( - - - - - - ) - } - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); -}; - -BursarExportsConfigurationForm.propTypes = { - form: PropTypes.object.isRequired, - handleSubmit: PropTypes.func.isRequired, - onFormStateChanged: PropTypes.func.isRequired, - pristine: PropTypes.bool, - submitting: PropTypes.bool, - patronGroups: PropTypes.arrayOf(PropTypes.object), -}; - -export const BursarExportsConfiguration = stripesFinalForm({ - keepDirtyOnReinitialize: true, - subscription: { values: true }, - navigationCheck: true, - mutators: { - changeSchedulePeriod: (args, state, utils) => { - const nextValue = args[0].target.value; - const prevValue = state.formState.values.schedulePeriod; - - utils.changeValue(state, 'schedulePeriod', () => nextValue); - - if (prevValue === SCHEDULE_PERIODS.none) { - utils.changeValue(state, 'scheduleFrequency', () => 1); - } - - if (prevValue === SCHEDULE_PERIODS.weeks) { - utils.changeValue(state, 'weekDays', () => undefined); - } - - if ([SCHEDULE_PERIODS.none, SCHEDULE_PERIODS.hours].includes(nextValue)) { - utils.changeValue(state, 'scheduleTime', () => undefined); - } - - if (nextValue === SCHEDULE_PERIODS.none) { - utils.changeValue(state, 'scheduleFrequency', () => undefined); - } - }, - changeOwner: (args, state, utils) => { - utils.changeValue( - state, - 'exportTypeSpecificParameters.bursarFeeFines.feefineOwnerId', - () => args[0].target.value, - ); - utils.changeValue( - state, - 'exportTypeSpecificParameters.bursarFeeFines.transferAccountId', - () => undefined, - ); - }, - changeTransferTypes: ([ownerId, newTransferTypes], state, utils) => { - const typesMapping = state.formState.values.exportTypeSpecificParameters?.bursarFeeFines?.typeMappings || {}; - const prevTransferTypes = typesMapping[ownerId] || []; - const transferTypesDiff = diffTransferTypes(newTransferTypes, prevTransferTypes); - - if (transferTypesDiff.length) { - utils.changeValue( - state, - 'exportTypeSpecificParameters.bursarFeeFines.typeMappings', - () => ({ - ...typesMapping, - [ownerId]: [...prevTransferTypes, ...transferTypesDiff], - }), - ); - } - }, - }, -})(BursarExportsConfigurationForm); diff --git a/src/BursarExportsConfiguration/BursarExportsConfiguration.test.js b/src/BursarExportsConfiguration/BursarExportsConfiguration.test.js deleted file mode 100644 index ea364527..00000000 --- a/src/BursarExportsConfiguration/BursarExportsConfiguration.test.js +++ /dev/null @@ -1,184 +0,0 @@ -import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; - -import { render } from '@folio/jest-config-stripes/testing-library/react'; -import user from '@folio/jest-config-stripes/testing-library/user-event'; - -import { BursarExportsConfiguration } from './BursarExportsConfiguration'; -import { SCHEDULE_PERIODS } from './constants'; - -jest.mock('./BursarItemsField', () => ({ - BursarItemsField: () => 'BursarItemsField', -})); -jest.mock('./FeeFineOwnerField', () => ({ - FeeFineOwnerField: () => 'FeeFineOwnerField', -})); -jest.mock('./TransferAccountField', () => ({ - TransferAccountField: () => 'TransferAccountField', -})); - -const defaultProps = { - onFormStateChanged: jest.fn(), - onSubmit: jest.fn(), - patronGroups: [{ - id: 'groupId', - group: 'group', - }], - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.none, - }, -}; - -const renderBursarExportsConfiguration = (props = {}) => render( - - - , -); - -describe('BursarExportsConfiguration', () => { - it('should render schedule period field', () => { - const { getByText } = renderBursarExportsConfiguration(); - - expect(getByText('ui-plugin-bursar-export.bursarExports.schedulePeriod')).toBeDefined(); - }); - - it('should render job parameter fields', () => { - const { queryByText } = renderBursarExportsConfiguration({ - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.weeks, - }, - }); - - expect(queryByText('ui-plugin-bursar-export.bursarExports.daysOutstanding')).not.toBeNull(); - expect(queryByText('ui-plugin-bursar-export.bursarExports.patronGroups')).not.toBeNull(); - }); - - it('should render owner field', () => { - const { queryByText } = renderBursarExportsConfiguration(); - - expect(queryByText('FeeFineOwnerField')).not.toBeNull(); - }); - - it('should render transfer account field', () => { - const { queryByText } = renderBursarExportsConfiguration(); - - expect(queryByText('TransferAccountField')).not.toBeNull(); - }); - - it('should render item types field', () => { - const { queryByText } = renderBursarExportsConfiguration(); - - expect(queryByText('BursarItemsField')).not.toBeNull(); - }); - - describe('None period', () => { - it('should should define schedule frequency when period is changed from None', async () => { - const { getByTestId } = renderBursarExportsConfiguration({ - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.none, - }, - }); - - await user.selectOptions(getByTestId('schedule-period'), SCHEDULE_PERIODS.days); - - const schedulePeriodField = getByTestId('schedule-frequency'); - - expect(schedulePeriodField).toBeDefined(); - expect(schedulePeriodField.value).toBeDefined(); - }); - - it('should reset scheduleFrequency when period is changed to None', async () => { - const { queryByTestId, getByTestId } = renderBursarExportsConfiguration({ - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.days, - }, - }); - - await user.selectOptions(getByTestId('schedule-period'), SCHEDULE_PERIODS.none); - - expect(queryByTestId('schedule-frequency')).toBeNull(); - }); - - it('should reset scheduleTime when period is changed to None', async () => { - const { queryByText, getByTestId } = renderBursarExportsConfiguration({ - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.days, - }, - }); - - await user.selectOptions(getByTestId('schedule-period'), SCHEDULE_PERIODS.none); - - expect(queryByText('ui-plugin-bursar-export.bursarExports.scheduleTime')).toBeNull(); - }); - }); - - describe('Hours period', () => { - it('should should display frequency field', () => { - const { queryByText, queryByTestId } = renderBursarExportsConfiguration({ - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.hours, - }, - }); - - expect(queryByTestId('schedule-frequency')).not.toBeNull(); - expect(queryByText('ui-plugin-bursar-export.bursarExports.scheduleTime')).toBeNull(); - expect(queryByText('ui-plugin-bursar-export.bursarExports.scheduleWeekdays')).toBeNull(); - }); - - it('should reset scheduleTime when period is changed to Hours', async () => { - const { queryByText, getByTestId } = renderBursarExportsConfiguration({ - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.days, - }, - }); - - await user.selectOptions(getByTestId('schedule-period'), SCHEDULE_PERIODS.hours); - - expect(queryByText('ui-plugin-bursar-export.bursarExports.scheduleTime')).toBeNull(); - }); - }); - - describe('Days period', () => { - it('should should display frequency and time fields', () => { - const { queryByText, queryByTestId } = renderBursarExportsConfiguration({ - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.days, - patronGroups: [], - }, - }); - - expect(queryByTestId('schedule-frequency')).not.toBeNull(); - expect(queryByText('ui-plugin-bursar-export.bursarExports.scheduleTime')).not.toBeNull(); - expect(queryByText('ui-plugin-bursar-export.bursarExports.scheduleWeekdays')).toBeNull(); - }); - }); - - describe('Weeks period', () => { - it('should should display frequency, time and weekdays fields', () => { - const { queryByText, queryByTestId } = renderBursarExportsConfiguration({ - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.weeks, - }, - weekDays: { - 'day': false, - }, - }); - - expect(queryByTestId('schedule-frequency')).not.toBeNull(); - expect(queryByText('ui-plugin-bursar-export.bursarExports.scheduleTime')).not.toBeNull(); - expect(queryByText('ui-plugin-bursar-export.bursarExports.scheduleWeekdays')).not.toBeNull(); - }); - - it('should reset scheduleWeekdays when period is changed to any periods', async () => { - const { queryByText, getByTestId } = renderBursarExportsConfiguration({ - initialValues: { - schedulePeriod: SCHEDULE_PERIODS.weeks, - }, - }); - - await user.selectOptions(getByTestId('schedule-period'), SCHEDULE_PERIODS.hours); - - expect(queryByText('ui-plugin-bursar-export.bursarExports.scheduleWeekdays')).toBeNull(); - }); - }); -}); diff --git a/src/BursarExportsConfiguration/BursarItemsField/BursarItemsField.css b/src/BursarExportsConfiguration/BursarItemsField/BursarItemsField.css deleted file mode 100644 index 3b591a0c..00000000 --- a/src/BursarExportsConfiguration/BursarItemsField/BursarItemsField.css +++ /dev/null @@ -1,3 +0,0 @@ -.bursarItemsPaddedField { - text-align: right; -} diff --git a/src/BursarExportsConfiguration/BursarItemsField/BursarItemsField.js b/src/BursarExportsConfiguration/BursarItemsField/BursarItemsField.js deleted file mode 100644 index 32bea8f1..00000000 --- a/src/BursarExportsConfiguration/BursarItemsField/BursarItemsField.js +++ /dev/null @@ -1,159 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { Field } from 'react-final-form'; -import { FieldArray } from 'react-final-form-arrays'; -import { useIntl } from 'react-intl'; - -import { - Col, - Headline, - Label, - RepeatableField, - Row, - TextField, -} from '@folio/stripes/components'; -import { - FieldSelectFinal, - usePrevious, -} from '@folio/stripes-acq-components'; - -import { - ITEM_DESCRIPTION_LENGTH, - ITEM_DESCRIPTION_SYMBOL, - ITEM_TYPE_LENGTH, - ITEM_TYPE_SYMBOL, -} from '../constants'; -import { padString } from '../utils'; - -import { BursarItemsFilter } from './BursarItemsFilter'; -import { useOwnerFeeFinesQuery } from './useOwnerFeeFinesQuery'; - -import css from './BursarItemsField.css'; - -const ITEMS_CODES = ['CHARGE', 'PAYMENT']; - -const formatItemType = value => { - if (!value) return ''; - - return padString(value, ITEM_TYPE_SYMBOL, ITEM_TYPE_LENGTH); -}; - -const formatItemDesription = value => { - if (!value) return ''; - - return padString(value, ITEM_DESCRIPTION_SYMBOL, ITEM_DESCRIPTION_LENGTH, false); -}; - -export const BursarItemsField = ({ onChange }) => { - const { formatMessage } = useIntl(); - const [filter, setFilter] = useState({}); - - const ownerId = filter.owner; - const prevOwnerId = usePrevious(ownerId); - const { feeFines } = useOwnerFeeFinesQuery(ownerId, prevOwnerId); - - useEffect(() => { - if (feeFines.length) { - onChange(ownerId, feeFines.map(({ id }) => ({ feefineTypeId: id }))); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [feeFines]); - - const onFilterChange = filterChanges => setFilter(filterState => ({ ...filterState, ...filterChanges })); - - const headLabels = ( - - - - - - - - - - - - - - - ); - - return ( -
- - {formatMessage({ id: 'ui-plugin-bursar-export.bursarExports.itemTypes' })} - - - - - { - const feeFine = feeFines.find(f => f.id === fields.value[i].feefineTypeId) || {}; - - return ( - - - - - - - - - - - - - - - ({ - value: code, - label: formatMessage({ id: `ui-plugin-bursar-export.bursarExports.itemCode.${code}` }), - }))} - name={`${field}.itemCode`} - aria-label={formatMessage({ id: 'ui-plugin-bursar-export.bursarExports.itemCode' })} - /> - - - ); - }} - /> -
- ); -}; - -BursarItemsField.propTypes = { - onChange: PropTypes.func.isRequired, -}; diff --git a/src/BursarExportsConfiguration/BursarItemsField/BursarItemsField.test.js b/src/BursarExportsConfiguration/BursarItemsField/BursarItemsField.test.js deleted file mode 100644 index c2502dd5..00000000 --- a/src/BursarExportsConfiguration/BursarItemsField/BursarItemsField.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { Form } from 'react-final-form'; -import arrayMutators from 'final-form-arrays'; - -import { BursarItemsField } from './BursarItemsField'; -import { useOwnerFeeFinesQuery } from './useOwnerFeeFinesQuery'; - -jest.mock('../FeeFineOwnerField', () => ({ - useFeeFineOwnersQuery: jest.fn().mockReturnValue({ owners: [{ id: 'ownerId' }] }), -})); -jest.mock('./useOwnerFeeFinesQuery', () => ({ - useOwnerFeeFinesQuery: jest.fn().mockReturnValue({ feeFines: [] }), -})); - -const renderBursarItemsField = () => render( -
{ - utils.changeValue( - state, - `exportTypeSpecificParameters.bursarFeeFines.typeMappings[${ownerId}]`, - () => transferTypes, - ); - }, - ...arrayMutators, - }} - render={(props) => ( - - )} - />, -); - -describe('BursarItemsField', () => { - describe('Filter', () => { - it('should display filter by owner', () => { - const { getByText } = renderBursarItemsField(); - - expect(getByText('ui-plugin-bursar-export.bursarExports.owner')).toBeDefined(); - }); - }); - - it('should render item type fields', () => { - const feeFines = [{ id: 'feeFines' }]; - - useOwnerFeeFinesQuery.mockClear().mockImplementation(ownerId => ({ - feeFines: ownerId ? feeFines : [], - })); - - const { getByText } = renderBursarItemsField(); - - expect(getByText('ui-plugin-bursar-export.bursarExports.feeFineType')).toBeDefined(); - }); -}); diff --git a/src/BursarExportsConfiguration/BursarItemsField/BursarItemsFilter.js b/src/BursarExportsConfiguration/BursarItemsField/BursarItemsFilter.js deleted file mode 100644 index 8e214b4b..00000000 --- a/src/BursarExportsConfiguration/BursarItemsField/BursarItemsFilter.js +++ /dev/null @@ -1,55 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; - -import { - Col, - Row, - Selection, -} from '@folio/stripes/components'; -import { - filterSelectValues, -} from '@folio/stripes-acq-components'; - -import { useFeeFineOwnersQuery } from '../FeeFineOwnerField'; - -export const BursarItemsFilter = ({ onChange }) => { - const [owner, setOwner] = useState(); - - const { owners } = useFeeFineOwnersQuery(); - - const dataOptions = owners.map(({ id, owner: ownerName }) => ({ - value: id, - label: ownerName, - })); - - const changeOwner = (selecteOwner) => { - setOwner(selecteOwner); - onChange({ owner: selecteOwner }); - }; - - useEffect(() => { - if (owners?.length) { - changeOwner(owners[0].id); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [owners]); - - return ( - - - } - /> - - - ); -}; - -BursarItemsFilter.propTypes = { - onChange: PropTypes.func.isRequired, -}; diff --git a/src/BursarExportsConfiguration/BursarItemsField/index.js b/src/BursarExportsConfiguration/BursarItemsField/index.js deleted file mode 100644 index 10eee1da..00000000 --- a/src/BursarExportsConfiguration/BursarItemsField/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './BursarItemsField'; diff --git a/src/BursarExportsConfiguration/BursarItemsField/useOwnerFeeFinesQuery.js b/src/BursarExportsConfiguration/BursarItemsField/useOwnerFeeFinesQuery.js deleted file mode 100644 index 534ebdfa..00000000 --- a/src/BursarExportsConfiguration/BursarItemsField/useOwnerFeeFinesQuery.js +++ /dev/null @@ -1,39 +0,0 @@ -import { - useQuery, - useQueryClient, -} from 'react-query'; - -import { useOkapiKy } from '@folio/stripes/core'; -import { LIMIT_MAX } from '@folio/stripes-acq-components'; - -const getQueryKey = ownerId => ['ui-plugin-bursar-export', 'feefines', ownerId]; - -export const useOwnerFeeFinesQuery = (ownerId, prevOwnerId) => { - const queryClient = useQueryClient(); - const ky = useOkapiKy(); - - if (prevOwnerId && prevOwnerId !== ownerId) { - queryClient.removeQueries(getQueryKey(prevOwnerId)); - } - - const { isLoading, data = [] } = useQuery({ - queryKey: getQueryKey(ownerId), - queryFn: async () => { - const kyOptions = { - searchParams: { - limit: LIMIT_MAX, - query: `(automatic==true${ownerId ? ` or ownerId==${ownerId}` : ''}) sortby feeFineType`, - }, - }; - const { feefines = [] } = await ky.get('feefines', kyOptions).json(); - - return feefines; - }, - enabled: Boolean(ownerId), - }); - - return { - isLoading, - feeFines: data, - }; -}; diff --git a/src/BursarExportsConfiguration/BursarItemsField/useOwnerFeeFinesQuery.test.js b/src/BursarExportsConfiguration/BursarItemsField/useOwnerFeeFinesQuery.test.js deleted file mode 100644 index f48612d8..00000000 --- a/src/BursarExportsConfiguration/BursarItemsField/useOwnerFeeFinesQuery.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { QueryClient, QueryClientProvider } from 'react-query'; - -import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; - -import { useOkapiKy } from '@folio/stripes/core'; - -import { useOwnerFeeFinesQuery } from './useOwnerFeeFinesQuery'; - -const queryClient = new QueryClient(); - -// eslint-disable-next-line react/prop-types -const wrapper = ({ children }) => ( - - {children} - -); - -describe('useOwnerFeeFinesQuery', () => { - it('should fetch fee/fines when ownerId is provided', async () => { - const feeFineId = 'feeFineId'; - - useOkapiKy.mockClear().mockReturnValue({ - get: () => ({ - json: () => ({ - isLoading: false, - feefines: [{ id: feeFineId }], - }), - }), - }); - - const { result } = renderHook(() => useOwnerFeeFinesQuery('ownerId'), { wrapper }); - - await waitFor(() => { - return expect(result.current.feeFines.length).toBeTruthy(); - }); - - expect(result.current.feeFines[0].id).toBe(feeFineId); - }); -}); diff --git a/src/BursarExportsConfiguration/FeeFineOwnerField/FeeFineOwnerField.js b/src/BursarExportsConfiguration/FeeFineOwnerField/FeeFineOwnerField.js deleted file mode 100644 index 83b383e9..00000000 --- a/src/BursarExportsConfiguration/FeeFineOwnerField/FeeFineOwnerField.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; - -import { - FieldSelectFinal, -} from '@folio/stripes-acq-components'; - -import { useFeeFineOwnersQuery } from './useFeeFineOwnersQuery'; - -export const FeeFineOwnerField = ({ onChange }) => { - const { formatMessage } = useIntl(); - const { owners } = useFeeFineOwnersQuery(); - - const dataOptions = owners.map(({ id, owner }) => ({ - value: id, - label: owner, - })); - - return ( - - ); -}; - -FeeFineOwnerField.propTypes = { - onChange: PropTypes.func.isRequired, -}; diff --git a/src/BursarExportsConfiguration/FeeFineOwnerField/FeeFineOwnerField.test.js b/src/BursarExportsConfiguration/FeeFineOwnerField/FeeFineOwnerField.test.js deleted file mode 100644 index 816fced3..00000000 --- a/src/BursarExportsConfiguration/FeeFineOwnerField/FeeFineOwnerField.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; - -import { - FieldSelectFinal, -} from '@folio/stripes-acq-components'; - -import { useFeeFineOwnersQuery } from './useFeeFineOwnersQuery'; -import { FeeFineOwnerField } from './FeeFineOwnerField'; - -jest.mock('@folio/stripes-acq-components', () => ({ - FieldSelectFinal: jest.fn(() => 'FieldSelectFinal'), -})); - -jest.mock('./useFeeFineOwnersQuery', () => ({ - useFeeFineOwnersQuery: jest.fn().mockReturnValue({ owners: [] }), -})); - -const renderOwnerField = (onChange = jest.fn()) => render( - , -); - -describe('FeeFineOwnerField', () => { - beforeEach(() => { - FieldSelectFinal.mockClear(); - }); - - it('should render FieldSelectFinal', () => { - renderOwnerField(); - - expect(FieldSelectFinal).toHaveBeenCalled(); - }); - - it('should use owners as data options', () => { - const dataOptions = [{ label: 'Owner', value: 'id' }]; - - useFeeFineOwnersQuery.mockClear().mockReturnValue({ - owners: dataOptions.map(o => ({ - id: o.value, - owner: o.label, - })), - }); - - renderOwnerField(); - - expect(FieldSelectFinal.mock.calls[0][0].dataOptions).toEqual(dataOptions); - }); -}); diff --git a/src/BursarExportsConfiguration/FeeFineOwnerField/index.js b/src/BursarExportsConfiguration/FeeFineOwnerField/index.js deleted file mode 100644 index 35d7b695..00000000 --- a/src/BursarExportsConfiguration/FeeFineOwnerField/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export * from './FeeFineOwnerField'; -export * from './useFeeFineOwnersQuery'; diff --git a/src/BursarExportsConfiguration/FeeFineOwnerField/useFeeFineOwnersQuery.js b/src/BursarExportsConfiguration/FeeFineOwnerField/useFeeFineOwnersQuery.js deleted file mode 100644 index fdc41364..00000000 --- a/src/BursarExportsConfiguration/FeeFineOwnerField/useFeeFineOwnersQuery.js +++ /dev/null @@ -1,28 +0,0 @@ -import { useQuery } from 'react-query'; - -import { useOkapiKy } from '@folio/stripes/core'; -import { LIMIT_MAX } from '@folio/stripes-acq-components'; - -export const useFeeFineOwnersQuery = () => { - const ky = useOkapiKy(); - - const { isLoading, data = [] } = useQuery({ - queryKey: ['ui-plugin-bursar-export', 'owners'], - queryFn: async () => { - const kyOptions = { - searchParams: { - limit: LIMIT_MAX, - query: 'cql.allRecords=1 sortby owner', - }, - }; - const { owners = [] } = await ky.get('owners', kyOptions).json(); - - return owners; - }, - }); - - return { - isLoading, - owners: data, - }; -}; diff --git a/src/BursarExportsConfiguration/FeeFineOwnerField/useFeeFineOwnersQuery.test.js b/src/BursarExportsConfiguration/FeeFineOwnerField/useFeeFineOwnersQuery.test.js deleted file mode 100644 index effa8c09..00000000 --- a/src/BursarExportsConfiguration/FeeFineOwnerField/useFeeFineOwnersQuery.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { QueryClient, QueryClientProvider } from 'react-query'; - -import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; - -import { useOkapiKy } from '@folio/stripes/core'; - -import { useFeeFineOwnersQuery } from './useFeeFineOwnersQuery'; - -const queryClient = new QueryClient(); - -// eslint-disable-next-line react/prop-types -const wrapper = ({ children }) => ( - - {children} - -); - -describe('useFeeFineOwnersQuery', () => { - it('should fetch fee fine owners', async () => { - const ownerId = 'ownerId'; - - useOkapiKy.mockClear().mockReturnValue({ - get: () => ({ - json: () => ({ - isLoading: false, - owners: [{ id: ownerId }], - }), - }), - }); - - const { result } = renderHook(() => useFeeFineOwnersQuery(), { wrapper }); - - await waitFor(() => { - return expect(result.current.owners.length).toBeTruthy(); - }); - - expect(result.current.owners[0].id).toBe(ownerId); - }); -}); diff --git a/src/BursarExportsConfiguration/PatronGroupsField.js b/src/BursarExportsConfiguration/PatronGroupsField.js deleted file mode 100644 index 95a14e4e..00000000 --- a/src/BursarExportsConfiguration/PatronGroupsField.js +++ /dev/null @@ -1,46 +0,0 @@ -import React, { - useCallback, - useMemo, -} from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; -import { find } from 'lodash'; - -import { - FieldMultiSelectionFinal, -} from '@folio/stripes-acq-components'; - -import { validateRequired } from './validation'; - -const itemToString = item => item; - -export const PatronGroupsField = ({ patronGroups = [] }) => { - const { formatMessage } = useIntl(); - - const getOptionLabel = useCallback(({ option }) => { - return find(patronGroups, { id: option })?.group || '-'; - }, [patronGroups]); - - const dataOptions = useMemo(() => { - return patronGroups.map(patronGroup => patronGroup.id); - }, [patronGroups]); - - return ( - - ); -}; - -PatronGroupsField.propTypes = { - patronGroups: PropTypes.arrayOf(PropTypes.object), -}; diff --git a/src/BursarExportsConfiguration/TransferAccountField/TransferAccountField.js b/src/BursarExportsConfiguration/TransferAccountField/TransferAccountField.js deleted file mode 100644 index 209c41d9..00000000 --- a/src/BursarExportsConfiguration/TransferAccountField/TransferAccountField.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; - -import { - FieldSelectFinal, -} from '@folio/stripes-acq-components'; - -import { useTransferAccountsQuery } from './useTransferAccountsQuery'; - -export const TransferAccountField = ({ ownerId }) => { - const { formatMessage } = useIntl(); - const { transferAccounts } = useTransferAccountsQuery(ownerId); - - const dataOptions = transferAccounts.map(({ id, accountName }) => ({ - value: id, - label: accountName, - })); - - return ( - - ); -}; - -TransferAccountField.propTypes = { - ownerId: PropTypes.string, -}; diff --git a/src/BursarExportsConfiguration/TransferAccountField/TransferAccountField.test.js b/src/BursarExportsConfiguration/TransferAccountField/TransferAccountField.test.js deleted file mode 100644 index baa4dfdd..00000000 --- a/src/BursarExportsConfiguration/TransferAccountField/TransferAccountField.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; - -import { - FieldSelectFinal, -} from '@folio/stripes-acq-components'; - -import { useTransferAccountsQuery } from './useTransferAccountsQuery'; -import { TransferAccountField } from './TransferAccountField'; - -jest.mock('@folio/stripes-acq-components', () => ({ - FieldSelectFinal: jest.fn(() => 'FieldSelectFinal'), -})); - -jest.mock('./useTransferAccountsQuery', () => ({ - useTransferAccountsQuery: jest.fn().mockReturnValue({ transferAccounts: [] }), -})); - -const renderTransferAccountField = (ownerId, onChange = jest.fn()) => render( - , -); - -describe('TransferAccountField', () => { - beforeEach(() => { - FieldSelectFinal.mockClear(); - }); - - it('should render FieldSelectFinal', () => { - renderTransferAccountField(); - - expect(FieldSelectFinal).toHaveBeenCalled(); - }); - - it('should not be disabled when ownerId is passed', () => { - renderTransferAccountField('ownerId'); - - expect(FieldSelectFinal.mock.calls[0][0].disabled).toBeFalsy(); - }); - - it('should be disabled when ownerId is not passed', () => { - renderTransferAccountField(); - - expect(FieldSelectFinal.mock.calls[0][0].disabled).toBeTruthy(); - }); - - it('should use transfer accounts as data options', () => { - const dataOptions = [{ label: 'Account', value: 'id' }]; - - useTransferAccountsQuery.mockClear().mockReturnValue({ - transferAccounts: dataOptions.map(o => ({ - id: o.value, - accountName: o.label, - })), - }); - - renderTransferAccountField('ownerId'); - - expect(FieldSelectFinal.mock.calls[0][0].dataOptions).toEqual(dataOptions); - }); -}); diff --git a/src/BursarExportsConfiguration/TransferAccountField/index.js b/src/BursarExportsConfiguration/TransferAccountField/index.js deleted file mode 100644 index 1f5f6275..00000000 --- a/src/BursarExportsConfiguration/TransferAccountField/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './TransferAccountField'; diff --git a/src/BursarExportsConfiguration/TransferAccountField/useTransferAccountsQuery.js b/src/BursarExportsConfiguration/TransferAccountField/useTransferAccountsQuery.js deleted file mode 100644 index 5056b00f..00000000 --- a/src/BursarExportsConfiguration/TransferAccountField/useTransferAccountsQuery.js +++ /dev/null @@ -1,29 +0,0 @@ -import { useQuery } from 'react-query'; - -import { useOkapiKy } from '@folio/stripes/core'; -import { LIMIT_MAX } from '@folio/stripes-acq-components'; - -export const useTransferAccountsQuery = (ownerId) => { - const ky = useOkapiKy(); - - const { isLoading, data = [] } = useQuery({ - queryKey: ['ui-plugin-bursar-export', 'transferAccounts', ownerId], - queryFn: async () => { - const kyOptions = { - searchParams: { - limit: LIMIT_MAX, - query: `ownerId==${ownerId} sortby accountName`, - }, - }; - const { transfers = [] } = await ky.get('transfers', kyOptions).json(); - - return transfers; - }, - enabled: Boolean(ownerId), - }); - - return { - isLoading, - transferAccounts: data, - }; -}; diff --git a/src/BursarExportsConfiguration/TransferAccountField/useTransferAccountsQuery.test.js b/src/BursarExportsConfiguration/TransferAccountField/useTransferAccountsQuery.test.js deleted file mode 100644 index 47fde758..00000000 --- a/src/BursarExportsConfiguration/TransferAccountField/useTransferAccountsQuery.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { QueryClient, QueryClientProvider } from 'react-query'; - -import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; - -import { useOkapiKy } from '@folio/stripes/core'; - -import { useTransferAccountsQuery } from './useTransferAccountsQuery'; - -const queryClient = new QueryClient(); - -// eslint-disable-next-line react/prop-types -const wrapper = ({ children }) => ( - - {children} - -); - -describe('useTransferAccountsQuery', () => { - it('should fetch transfer accounts when ownerId is provided', async () => { - const transfersId = 'transfersId'; - - useOkapiKy.mockClear().mockReturnValue({ - get: () => ({ - json: () => ({ - isLoading: false, - transfers: [{ id: transfersId }], - }), - }), - }); - - const { result } = renderHook(() => useTransferAccountsQuery('ownerId'), { wrapper }); - - await waitFor(() => { - return expect(result.current.transferAccounts.length).toBeTruthy(); - }); - - expect(result.current.transferAccounts[0].id).toBe(transfersId); - }); -}); diff --git a/src/BursarExportsConfiguration/constants.js b/src/BursarExportsConfiguration/constants.js deleted file mode 100644 index d4fce3fd..00000000 --- a/src/BursarExportsConfiguration/constants.js +++ /dev/null @@ -1,14 +0,0 @@ -export const SCHEDULE_PERIODS = { - none: 'NONE', - hours: 'HOUR', - days: 'DAY', - weeks: 'WEEK', -}; - -export const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; - -export const ITEM_TYPE_LENGTH = 12; -export const ITEM_TYPE_SYMBOL = '0'; - -export const ITEM_DESCRIPTION_LENGTH = 29; -export const ITEM_DESCRIPTION_SYMBOL = ' '; diff --git a/src/BursarExportsConfiguration/index.js b/src/BursarExportsConfiguration/index.js deleted file mode 100644 index 28c34c18..00000000 --- a/src/BursarExportsConfiguration/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export * from './BursarExportsConfiguration'; -export * from './constants'; diff --git a/src/BursarExportsConfiguration/utils.js b/src/BursarExportsConfiguration/utils.js deleted file mode 100644 index 9c962e88..00000000 --- a/src/BursarExportsConfiguration/utils.js +++ /dev/null @@ -1,21 +0,0 @@ -export const padString = (value, symbol, length, isLeft = true) => { - const valueLength = value.length; - - if (valueLength > length) { - return value.substr(0, length); - } - - const padValue = Array(length + 1 - valueLength).join(symbol); - - return isLeft ? `${padValue}${value}` : `${value}${padValue}`; -}; - -export const diffTransferTypes = (newTransferTypes = [], prevTransferTypes = []) => { - const prevTransferTypesMap = prevTransferTypes.reduce((acc, ownerType) => { - acc[ownerType.feefineTypeId] = true; - - return acc; - }, {}); - - return newTransferTypes.filter(({ feefineTypeId }) => !prevTransferTypesMap[feefineTypeId]); -}; diff --git a/src/BursarExportsConfiguration/utils.test.js b/src/BursarExportsConfiguration/utils.test.js deleted file mode 100644 index 4e60bd64..00000000 --- a/src/BursarExportsConfiguration/utils.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { - padString, - diffTransferTypes, -} from './utils'; - -describe('utils', () => { - describe('padString', () => { - it('should cut value when its length more that expected length', () => { - expect(padString('long', ' ', 2)).toBe('lo'); - }); - - it('should add symbols to the left when value length is less than expected', () => { - expect(padString('short', '0', 7)).toBe('00short'); - }); - - it('should add symbols to the right when value length is less than expected and left pad is disabled', () => { - expect(padString('short', '0', 7, false)).toBe('short00'); - }); - }); - - describe('diffTransferTypes', () => { - it('should return right difference', () => { - expect( - diffTransferTypes( - [{ feefineTypeId: 'uid1' }, { feefineTypeId: 'uid3' }], - [{ feefineTypeId: 'uid1' }, { feefineTypeId: 'uid2' }], - ), - ).toEqual([{ feefineTypeId: 'uid3' }]); - }); - - it('should not throw errors when arguments are not defined', () => { - expect(diffTransferTypes()).toEqual([]); - }); - }); -}); diff --git a/src/BursarExportsConfiguration/validation.js b/src/BursarExportsConfiguration/validation.js deleted file mode 100644 index e4f174e5..00000000 --- a/src/BursarExportsConfiguration/validation.js +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import { - isEmpty, - isObject, - isPlainObject, - values, -} from 'lodash'; - -export function validateRequired(value) { - if ( - !value - || (isObject(value) && isEmpty(value)) - || (isPlainObject(value) && values(value).every(v => !v)) - ) { - return ; - } - - return undefined; -} diff --git a/src/BursarExportsConfiguration/validation.test.js b/src/BursarExportsConfiguration/validation.test.js deleted file mode 100644 index ff51e345..00000000 --- a/src/BursarExportsConfiguration/validation.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { validateRequired } from './validation'; - -describe('validation', () => { - describe('validateRequired', () => { - it('should not return error message when not empty string is passed', () => { - expect(validateRequired('0001')).not.toBeDefined(); - }); - - it('should not return error message when object with thrity values is passed', () => { - expect(validateRequired({ monday: true })).not.toBeDefined(); - }); - - it('should return error message when falsy value is passed', () => { - expect(validateRequired('')).toBeDefined(); - }); - - it('should return error message when empty object is passed', () => { - expect(validateRequired({})).toBeDefined(); - }); - - it('should return error message when all object values are falsy', () => { - expect(validateRequired({ monday: false })).toBeDefined(); - }); - }); -}); diff --git a/src/BursarExportsManualRunner/BursarExportsManualRunner.js b/src/BursarExportsManualRunner/BursarExportsManualRunner.js deleted file mode 100644 index 73599754..00000000 --- a/src/BursarExportsManualRunner/BursarExportsManualRunner.js +++ /dev/null @@ -1,53 +0,0 @@ -import React, { useCallback } from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; - -import { - Button, -} from '@folio/stripes/components'; -import { - useShowCallout, -} from '@folio/stripes-acq-components'; - -import { - useBursarExportScheduler, -} from '../apiQuery'; - -export const BursarExportsManualRunner = ({ form, disabled }) => { - const { formatMessage } = useIntl(); - const showCallout = useShowCallout(); - - const { isLoading, scheduleBursarExport } = useBursarExportScheduler({ - onSuccess: () => { - return showCallout({ - message: formatMessage({ id: 'ui-plugin-bursar-export.bursarExports.runManually.success' }), - }); - }, - onError: () => { - return showCallout({ - message: formatMessage({ id: 'ui-plugin-bursar-export.bursarExports.runManually.error' }), - type: 'error', - }); - }, - }); - - const scheduleBursarExportWithParams = useCallback(() => { - scheduleBursarExport(form.getState().values.exportTypeSpecificParameters); - }, [scheduleBursarExport, form]); - - return ( - - ); -}; - -BursarExportsManualRunner.propTypes = { - form: PropTypes.object, - disabled: PropTypes.bool, -}; diff --git a/src/BursarExportsManualRunner/BursarExportsManualRunner.test.js b/src/BursarExportsManualRunner/BursarExportsManualRunner.test.js deleted file mode 100644 index 1082e00b..00000000 --- a/src/BursarExportsManualRunner/BursarExportsManualRunner.test.js +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; - -import { render } from '@folio/jest-config-stripes/testing-library/react'; -import user from '@folio/jest-config-stripes/testing-library/user-event'; - -import { - useBursarExportScheduler, -} from '../apiQuery'; -import { BursarExportsManualRunner } from './BursarExportsManualRunner'; - -jest.mock('../apiQuery', () => { - return { - useBursarExportScheduler: jest.fn(), - }; -}); - -const defaultForm = { - getState: () => ({ values: {} }), -}; - -const renderBursarExportsManualRunner = ({ - form = defaultForm, -} = {}) => render(); - -describe('BursarExportsManualRunner', () => { - beforeEach(() => { - useBursarExportScheduler.mockReturnValue({ - scheduleBursarExport: jest.fn(), - }); - }); - - it('should call query action with form params when button is pressed', async () => { - const scheduleBursarExport = jest.fn(); - const exportTypeSpecificParameters = { - daysOutstanding: 2, - patronGroups: ['saf-uis4-sdsa'], - }; - - useBursarExportScheduler.mockReturnValue({ scheduleBursarExport }); - - const { getByText } = renderBursarExportsManualRunner({ - form: { - getState: () => ({ - values: { - exportTypeSpecificParameters, - }, - }), - }, - }); - - await user.click(getByText('ui-plugin-bursar-export.bursarExports.runManually')); - - expect(scheduleBursarExport).toHaveBeenCalledWith(exportTypeSpecificParameters); - }); -}); diff --git a/src/BursarExportsManualRunner/index.js b/src/BursarExportsManualRunner/index.js deleted file mode 100644 index 2b1b3f76..00000000 --- a/src/BursarExportsManualRunner/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './BursarExportsManualRunner'; diff --git a/src/api/dto/dto-types.test.ts b/src/api/dto/dto-types.test.ts new file mode 100644 index 00000000..b8a07166 --- /dev/null +++ b/src/api/dto/dto-types.test.ts @@ -0,0 +1,3 @@ +import { TYPE_ONLY } from './dto-types'; + +test('DTO types load', () => expect(TYPE_ONLY).toBe(true)); diff --git a/src/api/dto/dto-types.ts b/src/api/dto/dto-types.ts new file mode 100644 index 00000000..a90c2e46 --- /dev/null +++ b/src/api/dto/dto-types.ts @@ -0,0 +1,329 @@ +import { SchedulingFrequency } from '../../types'; +import { Weekday } from '../../utils/weekdayUtils'; + +/** + * Bursar export job schema + */ +export interface BursarExportJobDTO { + /** + * Filter for bursar export job + */ + filter: BursarExportFilterDTO; + groupByPatron: boolean; + groupByPatronFilter?: BursarExportFilterAggregate; + header?: BursarExportHeaderFooterTokenDTO[]; + data?: BursarExportDataTokenDTO[]; + footer?: BursarExportHeaderFooterTokenDTO[]; + transferInfo: BursarExportTransferCriteria; +} + +export type BursarExportFilterDTO = + | BursarExportFilterAge + | BursarExportFilterAmount + | BursarExportFilterFeeType + | BursarExportFilterLocation + | BursarExportFilterPatronGroup + | BursarExportFilterServicePoint + | BursarExportFilterCondition + | BursarExportFilterNegation + | BursarExportFilterPass + | BursarExportFilterFeeFineOwner; + +export type BursarExportHeaderFooterTokenDTO = + | BursarExportTokenAggregate + | BursarExportTokenConstant + | BursarExportTokenCurrentDate; + +export type BursarExportDataTokenDTO = + | BursarExportTokenAggregate + | BursarExportTokenConstant + | BursarExportTokenCurrentDate + | BursarExportTokenFeeDate + | BursarExportTokenFeeAmount + | BursarExportTokenFeeMetadata + | BursarExportTokenItemData + | BursarExportTokenUserData + | BursarExportTokenUserDataOptional + | BursarExportTokenConditional; + +export type DateFormatType = + | 'YEAR_LONG' + | 'YEAR_SHORT' + | 'MONTH' + | 'DATE' + | 'HOUR' + | 'MINUTE' + | 'SECOND' + | 'QUARTER' + | 'WEEK_OF_YEAR_ISO' + | 'WEEK_YEAR_ISO' + | 'DAY_OF_YEAR' + | 'YYYYMMDD' + | 'YYYY-MM-DD' + | 'MMDDYYYY' + | 'DDMMYYYY'; + +export type ComparisonOperator = + | 'LESS_THAN_EQUAL' + | 'LESS_THAN' + | 'GREATER_THAN' + | 'GREATER_THAN_EQUAL'; + +/** + * Filter by fees older than certain number of days + */ +export interface BursarExportFilterAge { + type: 'Age'; + numDays: number; + condition: ComparisonOperator; +} +/** + * Filter by fee amount + */ +export interface BursarExportFilterAmount { + type: 'Amount'; + amount: number; + condition: ComparisonOperator; +} +/** + * Filter by fee type + */ +export interface BursarExportFilterFeeType { + type: 'FeeType'; + feeFineTypeId: string; +} +/** + * Filter by location + */ +export interface BursarExportFilterLocation { + type: 'Location'; + locationId: string; +} +/** + * Filter by patron group + */ +export interface BursarExportFilterPatronGroup { + type: 'PatronGroup'; + patronGroupId: string; +} +/** + * Filter by service point + */ +export interface BursarExportFilterServicePoint { + type: 'ServicePoint'; + servicePointId: string; +} +/** + * Filter conditional operations + */ +export interface BursarExportFilterCondition { + type: 'Condition'; + operation: 'AND' | 'OR'; + criteria: BursarExportFilterDTO[]; +} +/** + * Negation of filter for bursar export + */ +export interface BursarExportFilterNegation { + type: 'Negation'; + /** + * Filter for bursar export job + */ + criteria: BursarExportFilterDTO; +} +/** + * Filter that is always true + */ +export interface BursarExportFilterPass { + type: 'Pass'; +} +/** + * Filter by fee fine owner + */ +export interface BursarExportFilterFeeFineOwner { + type: 'FeeFineOwner'; + feeFineOwner: string; +} +/** + * Filter by aggregate data + */ +export interface BursarExportFilterAggregate { + type: 'Aggregate'; + property: 'NUM_ROWS' | 'TOTAL_AMOUNT'; + amount: number; + condition: ComparisonOperator; +} +/** + * Token to represent aggregated result of multiple fees + */ +export interface BursarExportTokenAggregate { + type: 'Aggregate'; + value: 'NUM_ROWS' | 'TOTAL_AMOUNT'; + /** + * only applicable for total amount + */ + decimal: boolean; + lengthControl?: BursarExportTokenLengthControl; +} +/** + * Token to fill in the length of another token + */ +export interface BursarExportTokenLengthControl { + character: string; + length: number; + direction: 'FRONT' | 'BACK'; + truncate: boolean; +} +/** + * Token to represent a constant used as an arbitrary string + */ +export interface BursarExportTokenConstant { + type: 'Constant'; + value: string; +} +/** + * Token to represent part of date + */ +export interface BursarExportTokenCurrentDate { + type: 'CurrentDate'; + /** + * Schema to represent the type of date information + */ + value: DateFormatType; + timezone: string; + lengthControl?: BursarExportTokenLengthControl; +} +/** + * Token to represent part of date about a fee + */ +export interface BursarExportTokenFeeDate { + type: 'FeeDate'; + property: 'CREATED' | 'UPDATED' | 'DUE' | 'RETURNED'; + /** + * Schema to represent the type of date information + */ + value: DateFormatType; + placeholder: string; + timezone: string; + lengthControl?: BursarExportTokenLengthControl; +} +/** + * Token to represent fee amount + */ +export interface BursarExportTokenFeeAmount { + type: 'FeeAmount'; + decimal: boolean; + lengthControl?: BursarExportTokenLengthControl; +} +/** + * Token to represent fee metadata + */ +export interface BursarExportTokenFeeMetadata { + type: 'FeeMetadata'; + value: 'FEE_FINE_TYPE_ID' | 'FEE_FINE_TYPE_NAME'; + lengthControl?: BursarExportTokenLengthControl; +} + +export type ItemDataType = + | 'BARCODE' + | 'NAME' + | 'MATERIAL_TYPE' + | 'INSTITUTION_ID' + | 'CAMPUS_ID' + | 'LIBRARY_ID' + | 'LOCATION_ID'; + +/** + * Token to represent item data + */ +export interface BursarExportTokenItemData { + type: 'ItemData'; + value: ItemDataType; + placeholder: string; + lengthControl?: BursarExportTokenLengthControl; +} +/** + * Token to represent user data + */ +export interface BursarExportTokenUserData { + type: 'UserData'; + value: 'FOLIO_ID'; + lengthControl?: BursarExportTokenLengthControl; +} +export type UserDataOptionalType = + | 'BARCODE' + | 'USERNAME' + | 'FIRST_NAME' + | 'MIDDLE_NAME' + | 'LAST_NAME' + | 'PATRON_GROUP_ID' + | 'EXTERNAL_SYSTEM_ID'; +/** + * Token to represent optional user data + */ +export interface BursarExportTokenUserDataOptional { + type: 'UserDataOptional'; + value: UserDataOptionalType; + placeholder: string; + lengthControl?: BursarExportTokenLengthControl; +} +/** + * Token to represent other data tokens depending on certain conditions + */ +export interface BursarExportTokenConditional { + type: 'Conditional'; + conditions: { + /** + * Filter for bursar export job + */ + condition: BursarExportFilterDTO; + /** + * Usable token for bursar export + */ + value: BursarExportTokenConstant; + }[]; + /** + * Usable token for bursar export + */ + else: BursarExportTokenConstant; +} +/** + * Transfer criteria + */ +export interface BursarExportTransferCriteria { + conditions?: { + /** + * Filter for bursar export job + */ + condition: BursarExportFilterDTO; + account: string; + }[]; + /** + * account to transfer fees/fines that do not meet any specified conditions to + */ + else: { + account: string; + }; +} + +// from mod-data-export-spring +export interface SchedulingDTO { + schedulePeriod: SchedulingFrequency; + scheduleFrequency?: number; + /** straight from timepicker, for DAY and WEEK only */ + scheduleTime?: string; + weekDays?: Weekday[]; +} + +export interface SavedJobConfiguration extends SchedulingDTO { + id: string; + type: 'BURSAR_FEES_FINES'; + tenant: string; + + exportTypeSpecificParameters: { + bursarFeeFines: BursarExportJobDTO; + }; +} + +// for coverage +export const TYPE_ONLY = true; diff --git a/src/api/dto/from/dtoToAggregateCriteria.test.ts b/src/api/dto/from/dtoToAggregateCriteria.test.ts new file mode 100644 index 00000000..6c14a46a --- /dev/null +++ b/src/api/dto/from/dtoToAggregateCriteria.test.ts @@ -0,0 +1,64 @@ +import { IntlShape } from 'react-intl'; +import { StripesType } from '@folio/stripes/core'; +import getIntl from '../../../../test/util/getIntl'; +import { ComparisonOperator, CriteriaAggregate, CriteriaAggregateType } from '../../../types'; +import { BursarExportFilterAggregate } from '../dto-types'; +import dtoToAggregateCriteria from './dtoToAggregateCriteria'; + +// United States +let intlEn: IntlShape; +let intlEu: IntlShape; + +beforeAll(() => { + intlEn = getIntl('en-US', 'EST'); + intlEu = getIntl('fr-FR', 'CET'); +}); + +describe('dtoToAggregateCriteria', () => { + test.each<[BursarExportFilterAggregate | undefined, CriteriaAggregate | undefined]>([ + [undefined, undefined], + [ + { + type: 'Aggregate', + property: 'NUM_ROWS', + condition: 'LESS_THAN_EQUAL', + amount: 1523, + }, + { + type: CriteriaAggregateType.NUM_ROWS, + operator: ComparisonOperator.LESS_THAN_EQUAL, + amount: '$1,523.00', + }, + ], + [ + { + type: 'Aggregate', + property: 'TOTAL_AMOUNT', + condition: 'GREATER_THAN', + amount: 1523, + }, + { + type: CriteriaAggregateType.TOTAL_AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '$15.23', + }, + ], + ])('dtoToAggregateCriteria(%s) === %s', (input, expected) => expect(dtoToAggregateCriteria(input, { currency: 'USD' } as StripesType, intlEn)).toEqual(expected)); + + // test for conversion to eur + test.each<[BursarExportFilterAggregate | undefined, CriteriaAggregate | undefined]>([ + [ + { + type: 'Aggregate', + property: 'TOTAL_AMOUNT', + condition: 'GREATER_THAN', + amount: 1523, + }, + { + type: CriteriaAggregateType.TOTAL_AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '15,23\xa0€', + }, + ], + ])('with EUR dtoToAggregateCriteria(%s) === %s', (input, expected) => expect(dtoToAggregateCriteria(input, { currency: 'EUR' } as StripesType, intlEu)).toEqual(expected)); +}); diff --git a/src/api/dto/from/dtoToAggregateCriteria.ts b/src/api/dto/from/dtoToAggregateCriteria.ts new file mode 100644 index 00000000..b8150ad6 --- /dev/null +++ b/src/api/dto/from/dtoToAggregateCriteria.ts @@ -0,0 +1,33 @@ +import { IntlShape } from 'react-intl'; +import { StripesType } from '@folio/stripes/core'; +import { + ComparisonOperator, + CriteriaAggregate, + CriteriaAggregateType, + CriteriaTokenType, +} from '../../../types'; +import { BursarExportFilterAggregate } from '../dto-types'; + +// inverse of ../to/aggregateCriteriaToFilterDto +export default function dtoToAggregateCriteria( + filter: BursarExportFilterAggregate | undefined, + stripes: StripesType, + intl: IntlShape, +): CriteriaAggregate | undefined { + switch (filter?.property) { + case CriteriaTokenType.NUM_ROWS: + return { + type: CriteriaAggregateType.NUM_ROWS, + operator: filter.condition as ComparisonOperator, + amount: intl.formatNumber(filter.amount, { style: 'currency', currency: stripes.currency }), + }; + case CriteriaTokenType.TOTAL_AMOUNT: + return { + type: CriteriaAggregateType.TOTAL_AMOUNT, + operator: filter.condition as ComparisonOperator, + amountCurrency: intl.formatNumber(filter.amount / 100, { style: 'currency', currency: stripes.currency }), + }; + default: + return undefined; + } +} diff --git a/src/api/dto/from/dtoToCriteria.test.ts b/src/api/dto/from/dtoToCriteria.test.ts new file mode 100644 index 00000000..3627007c --- /dev/null +++ b/src/api/dto/from/dtoToCriteria.test.ts @@ -0,0 +1,218 @@ +import { IntlShape } from 'react-intl'; +import { StripesType } from '@folio/stripes/core'; +import { + AndOrOperator, + ComparisonOperator, + CriteriaGroup, + CriteriaGroupType, + CriteriaTerminal, + CriteriaTerminalType, +} from '../../../types'; +import { FeeFineTypeDTO } from '../../queries/useFeeFineTypes'; +import { LocationDTO } from '../../queries/useLocations'; +import { + BursarExportFilterDTO, + BursarExportFilterFeeType, + BursarExportFilterLocation, + BursarExportFilterNegation, +} from '../dto-types'; +import dtoToCriteria from './dtoToCriteria'; +import getIntl from '../../../../test/util/getIntl'; + +// United States +let intlEn: IntlShape; +let intlEu: IntlShape; + +beforeAll(() => { + intlEn = getIntl('en-US', 'EST'); + intlEu = getIntl('fr-FR', 'CET'); +}); + +describe('DTO to criteria conversion for initial values', () => { + it.each<[BursarExportFilterDTO, CriteriaGroup | CriteriaTerminal]>([ + [ + { type: 'Age', condition: 'LESS_THAN', numDays: 1 }, + { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.LESS_THAN, + numDays: '1', + }, + ], + [ + { type: 'Amount', condition: 'LESS_THAN', amount: 124 }, + { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.LESS_THAN, + amountCurrency: '$1.24', + }, + ], + [ + { type: 'FeeFineOwner', feeFineOwner: 'owner-id' }, + { type: CriteriaTerminalType.FEE_FINE_OWNER, feeFineOwnerId: 'owner-id' }, + ], + [ + { type: 'PatronGroup', patronGroupId: 'pg-id' }, + { type: CriteriaTerminalType.PATRON_GROUP, patronGroupId: 'pg-id' }, + ], + [ + { type: 'ServicePoint', servicePointId: 'sp-id' }, + { type: CriteriaTerminalType.SERVICE_POINT, servicePointId: 'sp-id' }, + ], + [{ type: 'Pass' }, { type: CriteriaTerminalType.PASS }], + ])('converts simple criteria %s to %s', (input, expected) => expect(dtoToCriteria(input, [], [], { currency: 'USD' } as StripesType, intlEn)).toEqual(expected)); + + it.each<[BursarExportFilterDTO, CriteriaGroup]>([ + [ + { + type: 'Condition', + operation: AndOrOperator.AND, + criteria: [{ type: 'PatronGroup', patronGroupId: 'pg-id' }], + }, + { + type: CriteriaGroupType.ALL_OF, + criteria: [{ type: CriteriaTerminalType.PATRON_GROUP, patronGroupId: 'pg-id' }], + }, + ], + [ + { + type: 'Condition', + operation: AndOrOperator.OR, + criteria: [{ type: 'PatronGroup', patronGroupId: 'pg-id' }], + }, + { + type: CriteriaGroupType.ANY_OF, + criteria: [{ type: CriteriaTerminalType.PATRON_GROUP, patronGroupId: 'pg-id' }], + }, + ], + ])('converts condition %s to %s', (input, expected) => expect(dtoToCriteria(input, [], [], { currency: 'USD' } as StripesType, intlEn)).toEqual(expected)); + + it.each<[BursarExportFilterNegation, CriteriaGroup]>([ + [ + { + type: 'Negation', + criteria: { type: 'PatronGroup', patronGroupId: 'pg-id' }, + }, + { + type: CriteriaGroupType.NONE_OF, + criteria: [{ type: CriteriaTerminalType.PATRON_GROUP, patronGroupId: 'pg-id' }], + }, + ], + [ + { + type: 'Negation', + criteria: { + type: 'Condition', + operation: AndOrOperator.AND, + criteria: [{ type: 'PatronGroup', patronGroupId: 'pg-id' }], + }, + }, + { + type: CriteriaGroupType.NONE_OF, + criteria: [ + { + type: CriteriaGroupType.ALL_OF, + criteria: [ + { + type: CriteriaTerminalType.PATRON_GROUP, + patronGroupId: 'pg-id', + }, + ], + }, + ], + }, + ], + [ + { + type: 'Negation', + criteria: { + type: 'Condition', + operation: AndOrOperator.OR, + criteria: [{ type: 'PatronGroup', patronGroupId: 'pg-id' }], + }, + }, + { + type: CriteriaGroupType.NONE_OF, + criteria: [{ type: CriteriaTerminalType.PATRON_GROUP, patronGroupId: 'pg-id' }], + }, + ], + ])('converts negation %s to %s', (input, expected) => expect(dtoToCriteria(input, [], [], { currency: 'USD' } as StripesType, intlEn)).toEqual(expected)); + + it.each<[BursarExportFilterFeeType, FeeFineTypeDTO[], CriteriaTerminal]>([ + [ + { type: 'FeeType', feeFineTypeId: 'fee-fine-type-id' }, + [], + { + type: CriteriaTerminalType.FEE_FINE_TYPE, + feeFineTypeId: 'fee-fine-type-id', + feeFineOwnerId: 'automatic', + }, + ], + [ + { type: 'FeeType', feeFineTypeId: 'fee-fine-type-id' }, + [{ id: 'fee-fine-type-id' } as FeeFineTypeDTO], + { + type: CriteriaTerminalType.FEE_FINE_TYPE, + feeFineTypeId: 'fee-fine-type-id', + feeFineOwnerId: 'automatic', + }, + ], + [ + { type: 'FeeType', feeFineTypeId: 'fee-fine-type-id' }, + [{ id: 'fee-fine-type-id', ownerId: 'owner-id' } as FeeFineTypeDTO], + { + type: CriteriaTerminalType.FEE_FINE_TYPE, + feeFineTypeId: 'fee-fine-type-id', + feeFineOwnerId: 'owner-id', + }, + ], + ])('converts fee type %s with known types %s to %s', (input, feeFineTypes, expected) => expect(dtoToCriteria(input, feeFineTypes, [], { currency: 'USD' } as StripesType, intlEn)).toEqual(expected)); + + it.each<[BursarExportFilterLocation, LocationDTO[], CriteriaTerminal]>([ + [ + { type: 'Location', locationId: 'location-id' }, + [], + { + type: CriteriaTerminalType.LOCATION, + locationId: 'location-id', + }, + ], + [ + { type: 'Location', locationId: 'location-id' }, + [{ id: 'location-id' } as LocationDTO], + { + type: CriteriaTerminalType.LOCATION, + locationId: 'location-id', + }, + ], + [ + { type: 'Location', locationId: 'location-id' }, + [ + { + id: 'location-id', + institutionId: 'institution-id', + campusId: 'campus-id', + libraryId: 'library-id', + } as LocationDTO, + ], + { + type: CriteriaTerminalType.LOCATION, + institutionId: 'institution-id', + campusId: 'campus-id', + libraryId: 'library-id', + locationId: 'location-id', + }, + ], + ])('converts location %s with known locations %s to %s', (input, locations, expected) => expect( + dtoToCriteria(input, [], locations, { currency: 'USD' } as StripesType, intlEn) + ).toEqual(expected)); + + it('converts amount with EUR currency', () => { + const input: BursarExportFilterDTO = { type: 'Amount', condition: 'LESS_THAN', amount: 124 }; + const expected: CriteriaTerminal = { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.LESS_THAN, + amountCurrency: '1,24\xa0€', + }; + expect(dtoToCriteria(input, [], [], { currency: 'EUR' } as StripesType, intlEu)).toEqual(expected); + }); +}); diff --git a/src/api/dto/from/dtoToCriteria.ts b/src/api/dto/from/dtoToCriteria.ts new file mode 100644 index 00000000..080f51f1 --- /dev/null +++ b/src/api/dto/from/dtoToCriteria.ts @@ -0,0 +1,153 @@ +import { StripesType } from '@folio/stripes/core'; +import { IntlShape } from 'react-intl'; +import { + AndOrOperator, + ComparisonOperator, + CriteriaGroup, + CriteriaGroupType, + CriteriaTerminal, + CriteriaTerminalType, +} from '../../../types'; +import { FeeFineTypeDTO } from '../../queries/useFeeFineTypes'; +import { LocationDTO } from '../../queries/useLocations'; +import { + BursarExportFilterCondition, + BursarExportFilterDTO, + BursarExportFilterNegation, +} from '../dto-types'; + +export function dtoToConditionCriteria( + filter: BursarExportFilterCondition, + feeFineTypes: FeeFineTypeDTO[], + locations: LocationDTO[], + stripes: StripesType, + intl: IntlShape, +): CriteriaGroup { + if (filter.operation === AndOrOperator.AND) { + return { + type: CriteriaGroupType.ALL_OF, + // eslint-disable-next-line @typescript-eslint/no-use-before-define + criteria: filter.criteria.map((criteria) => dtoToCriteria(criteria, feeFineTypes, locations, stripes, intl)), + }; + } else { + return { + type: CriteriaGroupType.ANY_OF, + // eslint-disable-next-line @typescript-eslint/no-use-before-define + criteria: filter.criteria.map((criteria) => dtoToCriteria(criteria, feeFineTypes, locations, stripes, intl)), + }; + } +} + +export function dtoToNegationCriteria( + filter: BursarExportFilterNegation, + feeFineTypes: FeeFineTypeDTO[], + locations: LocationDTO[], + stripes: StripesType, + intl: IntlShape, +): CriteriaGroup { + // NOR gets displayed as "None of" in the UI, so we flatten the inner OR + if (filter.criteria.type === 'Condition' && filter.criteria.operation === AndOrOperator.OR) { + return { + type: CriteriaGroupType.NONE_OF, + // eslint-disable-next-line @typescript-eslint/no-use-before-define + criteria: filter.criteria.criteria.map((criteria) => dtoToCriteria(criteria, feeFineTypes, locations, stripes, intl)), + }; + } + + // otherwise, just negate the single inner criteria + return { + type: CriteriaGroupType.NONE_OF, + // eslint-disable-next-line @typescript-eslint/no-use-before-define + criteria: [dtoToCriteria(filter.criteria, feeFineTypes, locations, stripes, intl)], + }; +} + +export function getFeeFineOwnerId(feeFineTypeId: string, feeFineTypes: FeeFineTypeDTO[]) { + const feeFineType = feeFineTypes.find((type) => type.id === feeFineTypeId); + + if (feeFineType?.ownerId) { + return feeFineType.ownerId; + } + + return 'automatic'; +} + +export function getLocationProperties( + locationId: string, + locations: LocationDTO[], +): Partial { + const location = locations.find((loc) => loc.id === locationId); + + if (!location) { + return {}; + } + + return { + institutionId: location.institutionId, + campusId: location.campusId, + libraryId: location.libraryId, + }; +} + +// inverse of ../to/criteriaToFilterDto +export default function dtoToCriteria( + filter: BursarExportFilterDTO, + feeFineTypes: FeeFineTypeDTO[], + locations: LocationDTO[], + stripes: StripesType, + intl: IntlShape, +): CriteriaGroup | CriteriaTerminal { + switch (filter.type) { + case CriteriaTerminalType.AGE: + return { + type: CriteriaTerminalType.AGE, + operator: filter.condition as ComparisonOperator, + numDays: filter.numDays.toString(), + }; + case CriteriaTerminalType.AMOUNT: + return { + type: CriteriaTerminalType.AMOUNT, + operator: filter.condition as ComparisonOperator, + amountCurrency: intl.formatNumber(filter.amount / 100, { style: 'currency', currency: stripes.currency }), + }; + case CriteriaTerminalType.FEE_FINE_OWNER: + return { + type: CriteriaTerminalType.FEE_FINE_OWNER, + feeFineOwnerId: filter.feeFineOwner, + }; + case CriteriaTerminalType.FEE_FINE_TYPE: + return { + type: CriteriaTerminalType.FEE_FINE_TYPE, + feeFineTypeId: filter.feeFineTypeId, + feeFineOwnerId: getFeeFineOwnerId(filter.feeFineTypeId, feeFineTypes), + }; + case CriteriaTerminalType.LOCATION: + return { + type: CriteriaTerminalType.LOCATION, + locationId: filter.locationId, + ...getLocationProperties(filter.locationId, locations), + }; + case CriteriaTerminalType.PATRON_GROUP: + return { + type: CriteriaTerminalType.PATRON_GROUP, + patronGroupId: filter.patronGroupId, + }; + case CriteriaTerminalType.SERVICE_POINT: + return { + type: CriteriaTerminalType.SERVICE_POINT, + servicePointId: filter.servicePointId, + }; + + case 'Condition': + return dtoToConditionCriteria(filter, feeFineTypes, locations, stripes, intl); + + case 'Negation': + return dtoToNegationCriteria(filter, feeFineTypes, locations, stripes, intl); + + case CriteriaTerminalType.PASS: + default: + return { + type: CriteriaTerminalType.PASS, + }; + } +} diff --git a/src/api/dto/from/dtoToData.test.ts b/src/api/dto/from/dtoToData.test.ts new file mode 100644 index 00000000..4a953f84 --- /dev/null +++ b/src/api/dto/from/dtoToData.test.ts @@ -0,0 +1,162 @@ +import { StripesType } from '@folio/stripes/core'; +import { IntlShape } from 'react-intl'; +import getIntl from '../../../../test/util/getIntl'; +import { + ComparisonOperator, + CriteriaTerminalType, + DataToken, + DataTokenType, + DateFormatType, +} from '../../../types'; +import { BursarExportDataTokenDTO } from '../dto-types'; +import dtoToData, { dtoToDataToken } from './dtoToData'; + +// United States +let intlEn: IntlShape; + +beforeAll(() => { + intlEn = getIntl('en-US', 'EST'); +}); + +describe('DTO to data conversion for initial values', () => { + it.each<[BursarExportDataTokenDTO, DataToken]>([ + [{ type: 'Constant', value: '\n' }, { type: DataTokenType.NEWLINE }], + [{ type: 'Constant', value: '\r\n' }, { type: DataTokenType.NEWLINE_MICROSOFT }], + [{ type: 'Constant', value: ',' }, { type: DataTokenType.COMMA }], + [{ type: 'Constant', value: '\t' }, { type: DataTokenType.TAB }], + [ + { type: 'Constant', value: ' ' }, + { type: DataTokenType.SPACE, repeat: '3' }, + ], + [ + { type: 'Constant', value: 'foo' }, + { type: DataTokenType.ARBITRARY_TEXT, text: 'foo' }, + ], + + [{ type: 'Aggregate', value: 'NUM_ROWS', decimal: false }, { type: DataTokenType.AGGREGATE_COUNT }], + [ + { type: 'Aggregate', value: 'TOTAL_AMOUNT', decimal: false }, + { type: DataTokenType.AGGREGATE_TOTAL, decimal: false }, + ], + [ + { type: 'Aggregate', value: 'TOTAL_AMOUNT', decimal: true }, + { type: DataTokenType.AGGREGATE_TOTAL, decimal: true }, + ], + + [ + { type: 'FeeAmount', decimal: false }, + { type: DataTokenType.ACCOUNT_AMOUNT, decimal: false }, + ], + [ + { type: 'FeeAmount', decimal: true }, + { type: DataTokenType.ACCOUNT_AMOUNT, decimal: true }, + ], + + [ + { type: 'CurrentDate', value: 'YEAR_LONG', timezone: 'somewhere' }, + { + type: DataTokenType.CURRENT_DATE, + format: DateFormatType.YEAR_LONG, + timezone: 'somewhere', + }, + ], + [ + { + type: 'FeeDate', + value: 'YEAR_LONG', + timezone: 'somewhere', + placeholder: 'placeholder', + property: 'CREATED', + }, + { + type: DataTokenType.ACCOUNT_DATE, + format: DateFormatType.YEAR_LONG, + timezone: 'somewhere', + placeholder: 'placeholder', + dateProperty: 'CREATED', + }, + ], + + [ + { type: 'FeeMetadata', value: 'FEE_FINE_TYPE_ID' }, + { + type: DataTokenType.FEE_FINE_TYPE, + feeFineAttribute: 'FEE_FINE_TYPE_ID', + }, + ], + [ + { type: 'ItemData', value: 'NAME', placeholder: 'placeholder' }, + { + type: DataTokenType.ITEM_INFO, + itemAttribute: 'NAME', + placeholder: 'placeholder', + }, + ], + [ + { type: 'UserData', value: 'FOLIO_ID' }, + { type: DataTokenType.USER_DATA, userAttribute: 'FOLIO_ID' }, + ], + [ + { + type: 'UserDataOptional', + value: 'FIRST_NAME', + placeholder: 'placeholder', + }, + { + type: DataTokenType.USER_DATA, + userAttribute: 'FIRST_NAME', + placeholder: 'placeholder', + }, + ], + [ + { + type: 'Conditional', + conditions: [ + { + condition: { type: 'Age', condition: 'GREATER_THAN', numDays: 12 }, + value: { type: 'Constant', value: 'foo' }, + }, + ], + else: { type: 'Constant', value: 'else' }, + }, + { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: [ + { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '12', + value: 'foo', + }, + ], + else: 'else', + }, + ], + ])('converts token %s to %s', (input, expected) => expect(dtoToDataToken(input, [], [], { currency: 'USD' } as StripesType, intlEn)).toEqual(expected)); + + it('converts undefined to empty array', () => expect(dtoToData(undefined, [], [], { currency: 'USD' } as StripesType, intlEn)).toEqual([])); + + it('converts token array to data', () => expect( + dtoToData( + [ + { type: 'Constant', value: '\n' }, + { type: 'Constant', value: '\r\n' }, + { type: 'Constant', value: ',' }, + { type: 'Constant', value: '\t' }, + { type: 'Constant', value: ' ' }, + { type: 'Constant', value: 'foo' }, + ], + [], + [], + { currency: 'USD' } as StripesType, + intlEn + ), + ).toEqual([ + { type: DataTokenType.NEWLINE }, + { type: DataTokenType.NEWLINE_MICROSOFT }, + { type: DataTokenType.COMMA }, + { type: DataTokenType.TAB }, + { type: DataTokenType.SPACE, repeat: '3' }, + { type: DataTokenType.ARBITRARY_TEXT, text: 'foo' }, + ])); +}); diff --git a/src/api/dto/from/dtoToData.ts b/src/api/dto/from/dtoToData.ts new file mode 100644 index 00000000..e680421b --- /dev/null +++ b/src/api/dto/from/dtoToData.ts @@ -0,0 +1,149 @@ +import { StripesType } from '@folio/stripes/core'; +import { IntlShape } from 'react-intl'; +import { + ConvenientConstants, + CriteriaTokenType, + DataToken, + DataTokenType, + DateFormatType, +} from '../../../types'; +import { FeeFineTypeDTO } from '../../queries/useFeeFineTypes'; +import { LocationDTO } from '../../queries/useLocations'; +import { + BursarExportDataTokenDTO, + BursarExportTokenAggregate, + BursarExportTokenConstant, +} from '../dto-types'; +import dtoToCriteria from './dtoToCriteria'; +import dtoToLengthControl from './dtoToLengthControl'; + +export function constantToToken(token: BursarExportTokenConstant): DataToken { + if (token.value === ConvenientConstants[DataTokenType.NEWLINE]) { + return { type: DataTokenType.NEWLINE }; + } else if (token.value === ConvenientConstants[DataTokenType.NEWLINE_MICROSOFT]) { + return { type: DataTokenType.NEWLINE_MICROSOFT }; + } else if (token.value === ConvenientConstants[DataTokenType.TAB]) { + return { type: DataTokenType.TAB }; + } else if (token.value === ConvenientConstants[DataTokenType.COMMA]) { + return { type: DataTokenType.COMMA }; + } else if (/^ +$/.test(token.value)) { + return { + type: DataTokenType.SPACE, + repeat: token.value.length.toString(), + }; + } else { + return { type: DataTokenType.ARBITRARY_TEXT, text: token.value }; + } +} + +export function aggregateToToken(token: BursarExportTokenAggregate): DataToken { + if (token.value === CriteriaTokenType.NUM_ROWS) { + return { + type: DataTokenType.AGGREGATE_COUNT, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + } else { + return { + type: DataTokenType.AGGREGATE_TOTAL, + decimal: token.decimal, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + } +} + +export function dtoToDataToken( + token: BursarExportDataTokenDTO, + feeFineTypes: FeeFineTypeDTO[], + locations: LocationDTO[], + stripes: StripesType, + intl: IntlShape, +): DataToken { + switch (token.type) { + case 'Constant': + return constantToToken(token); + + case 'Aggregate': + return aggregateToToken(token); + + case 'CurrentDate': + return { + type: DataTokenType.CURRENT_DATE, + format: token.value as DateFormatType, + timezone: token.timezone, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + + case 'FeeAmount': + return { + type: DataTokenType.ACCOUNT_AMOUNT, + decimal: token.decimal, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + + case 'FeeDate': + return { + type: DataTokenType.ACCOUNT_DATE, + format: token.value as DateFormatType, + timezone: token.timezone, + placeholder: token.placeholder, + dateProperty: token.property, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + + case 'FeeMetadata': + return { + type: DataTokenType.FEE_FINE_TYPE, + feeFineAttribute: token.value, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + + case 'ItemData': + return { + type: DataTokenType.ITEM_INFO, + itemAttribute: token.value, + placeholder: token.placeholder, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + + case 'UserData': + return { + type: DataTokenType.USER_DATA, + userAttribute: token.value, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + + case 'UserDataOptional': + return { + type: DataTokenType.USER_DATA, + userAttribute: token.value, + placeholder: token.placeholder, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + + case 'Conditional': + default: + return { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: token.conditions.map((condition) => ({ + ...dtoToCriteria(condition.condition, feeFineTypes, locations, stripes, intl), + value: condition.value.value, + })), + else: token.else.value, + }; + } +} + +// inverse of ../to/dataToDto +export default function dtoToData( + tokens: BursarExportDataTokenDTO[] | undefined, + feeFineTypes: FeeFineTypeDTO[], + locations: LocationDTO[], + stripes: StripesType, + intl: IntlShape, +): DataToken[] { + if (!tokens) { + return []; + } + + return tokens.map((token) => dtoToDataToken(token, feeFineTypes, locations, stripes, intl)); +} diff --git a/src/api/dto/from/dtoToFormValues.test.ts b/src/api/dto/from/dtoToFormValues.test.ts new file mode 100644 index 00000000..4aad66d0 --- /dev/null +++ b/src/api/dto/from/dtoToFormValues.test.ts @@ -0,0 +1,76 @@ +import { IntlShape } from 'react-intl'; +import { StripesType } from '@folio/stripes/core'; +import { CriteriaTerminalType, DataTokenType, FormValues, SchedulingFrequency } from '../../../types'; +import { SavedJobConfiguration } from '../dto-types'; +import dtoToFormValues from './dtoToFormValues'; +import getIntl from '../../../../test/util/getIntl'; + +// United States +let intlEn: IntlShape; + +beforeAll(() => { + intlEn = getIntl('en-US', 'EST'); +}); + +describe('dtoToFormValues', () => { + test.each<[SavedJobConfiguration | null | undefined, Partial]>([ + [null, {}], + [undefined, {}], + [ + { + id: 'id', + type: 'BURSAR_FEES_FINES', + tenant: 'diku', + schedulePeriod: SchedulingFrequency.Manual, + exportTypeSpecificParameters: { + bursarFeeFines: { + groupByPatron: false, + data: [{ type: 'Constant', value: '\n' }], + filter: { type: 'Pass' }, + transferInfo: { else: { account: 'account' } }, + }, + }, + }, + { + scheduling: { frequency: SchedulingFrequency.Manual }, + criteria: { type: CriteriaTerminalType.PASS }, + aggregate: false, + header: [], + data: [{ type: DataTokenType.NEWLINE }], + footer: [], + transferInfo: { + conditions: [], + else: { account: 'account' }, + }, + }, + ], + [ + { + id: 'id', + type: 'BURSAR_FEES_FINES', + tenant: 'diku', + schedulePeriod: SchedulingFrequency.Manual, + exportTypeSpecificParameters: { + bursarFeeFines: { + groupByPatron: true, + data: [{ type: 'Constant', value: '\n' }], + filter: { type: 'Pass' }, + transferInfo: { else: { account: 'account' } }, + }, + }, + }, + { + scheduling: { frequency: SchedulingFrequency.Manual }, + criteria: { type: CriteriaTerminalType.PASS }, + aggregate: true, + header: [], + dataAggregate: [{ type: DataTokenType.NEWLINE }], + footer: [], + transferInfo: { + conditions: [], + else: { account: 'account' }, + }, + }, + ], + ])('Converts DTO %s to %s', (input, expected) => expect(dtoToFormValues(input, [], [], [], [], { currency: 'USD' } as StripesType, intlEn)).toEqual(expected)); +}); diff --git a/src/api/dto/from/dtoToFormValues.ts b/src/api/dto/from/dtoToFormValues.ts new file mode 100644 index 00000000..f717c66e --- /dev/null +++ b/src/api/dto/from/dtoToFormValues.ts @@ -0,0 +1,73 @@ +import { StripesType } from '@folio/stripes/core'; +import { IntlShape } from 'react-intl'; +import { FormValues } from '../../../types'; +import { LocaleWeekdayInfo } from '../../../utils/weekdayUtils'; +import { FeeFineTypeDTO } from '../../queries/useFeeFineTypes'; +import { LocationDTO } from '../../queries/useLocations'; +import { TransferAccountDTO } from '../../queries/useTransferAccounts'; +import { SavedJobConfiguration } from '../dto-types'; +import dtoToAggregateCriteria from './dtoToAggregateCriteria'; +import dtoToCriteria from './dtoToCriteria'; +import dtoToData from './dtoToData'; +import dtoToHeaderFooter from './dtoToHeaderFooter'; +import dtoToScheduling from './dtoToScheduling'; +import dtoToTransfer from './dtoToTransfer'; + +export default function dtoToFormValues( + values: SavedJobConfiguration | null | undefined, + localeWeekdays: LocaleWeekdayInfo[], + feeFineTypes: FeeFineTypeDTO[], + locations: LocationDTO[], + transferAccounts: TransferAccountDTO[], + stripes: StripesType, + intl: IntlShape, +): Partial { + if (!values) { + return {}; + } + + if (values.exportTypeSpecificParameters.bursarFeeFines.groupByPatron) { + return { + scheduling: dtoToScheduling(values, localeWeekdays), + + criteria: dtoToCriteria(values.exportTypeSpecificParameters.bursarFeeFines.filter, feeFineTypes, locations, stripes, intl), + + aggregate: true, + aggregateFilter: dtoToAggregateCriteria(values.exportTypeSpecificParameters.bursarFeeFines.groupByPatronFilter, stripes, intl), + + header: dtoToHeaderFooter(values.exportTypeSpecificParameters.bursarFeeFines.header), + dataAggregate: dtoToData(values.exportTypeSpecificParameters.bursarFeeFines.data, feeFineTypes, locations, stripes, intl), + footer: dtoToHeaderFooter(values.exportTypeSpecificParameters.bursarFeeFines.header), + + transferInfo: dtoToTransfer( + values.exportTypeSpecificParameters.bursarFeeFines.transferInfo, + feeFineTypes, + locations, + transferAccounts, + stripes, + intl, + ), + }; + } else { + return { + scheduling: dtoToScheduling(values, localeWeekdays), + + criteria: dtoToCriteria(values.exportTypeSpecificParameters.bursarFeeFines.filter, feeFineTypes, locations, stripes, intl), + + aggregate: false, + + header: dtoToHeaderFooter(values.exportTypeSpecificParameters.bursarFeeFines.header), + data: dtoToData(values.exportTypeSpecificParameters.bursarFeeFines.data, feeFineTypes, locations, stripes, intl), + footer: dtoToHeaderFooter(values.exportTypeSpecificParameters.bursarFeeFines.header), + + transferInfo: dtoToTransfer( + values.exportTypeSpecificParameters.bursarFeeFines.transferInfo, + feeFineTypes, + locations, + transferAccounts, + stripes, + intl, + ), + }; + } +} diff --git a/src/api/dto/from/dtoToHeaderFooter.test.ts b/src/api/dto/from/dtoToHeaderFooter.test.ts new file mode 100644 index 00000000..c208e25e --- /dev/null +++ b/src/api/dto/from/dtoToHeaderFooter.test.ts @@ -0,0 +1,63 @@ +import { + HeaderFooterToken, + HeaderFooterTokenType, + DateFormatType, +} from '../../../types'; +import { BursarExportHeaderFooterTokenDTO } from '../dto-types'; +import dtoToHeaderFooter, { dtoToHeaderFooterToken } from './dtoToHeaderFooter'; + +describe('DTO to header/footer conversion for initial values', () => { + it.each<[BursarExportHeaderFooterTokenDTO, HeaderFooterToken]>([ + [{ type: 'Constant', value: '\n' }, { type: HeaderFooterTokenType.NEWLINE }], + [{ type: 'Constant', value: '\r\n' }, { type: HeaderFooterTokenType.NEWLINE_MICROSOFT }], + [{ type: 'Constant', value: ',' }, { type: HeaderFooterTokenType.COMMA }], + [{ type: 'Constant', value: '\t' }, { type: HeaderFooterTokenType.TAB }], + [ + { type: 'Constant', value: ' ' }, + { type: HeaderFooterTokenType.SPACE, repeat: '3' }, + ], + [ + { type: 'Constant', value: 'foo' }, + { type: HeaderFooterTokenType.ARBITRARY_TEXT, text: 'foo' }, + ], + + [{ type: 'Aggregate', value: 'NUM_ROWS', decimal: false }, { type: HeaderFooterTokenType.AGGREGATE_COUNT }], + [ + { type: 'Aggregate', value: 'TOTAL_AMOUNT', decimal: false }, + { type: HeaderFooterTokenType.AGGREGATE_TOTAL, decimal: false }, + ], + [ + { type: 'Aggregate', value: 'TOTAL_AMOUNT', decimal: true }, + { type: HeaderFooterTokenType.AGGREGATE_TOTAL, decimal: true }, + ], + + [ + { type: 'CurrentDate', value: 'YEAR_LONG', timezone: 'somewhere' }, + { + type: HeaderFooterTokenType.CURRENT_DATE, + format: DateFormatType.YEAR_LONG, + timezone: 'somewhere', + }, + ], + ])('converts token %s to %s', (input, expected) => expect(dtoToHeaderFooterToken(input)).toEqual(expected)); + + it('converts undefined to empty array', () => expect(dtoToHeaderFooter(undefined)).toEqual([])); + + it('converts token array to form value array', () => expect( + dtoToHeaderFooter([ + { type: 'Constant', value: '\n' }, + { type: 'Constant', value: '\r\n' }, + { type: 'Constant', value: ',' }, + { type: 'Constant', value: '\t' }, + { type: 'Constant', value: ' ' }, + { type: 'Constant', value: 'foo' }, + ]), + ).toEqual([ + { type: HeaderFooterTokenType.NEWLINE }, + { type: HeaderFooterTokenType.NEWLINE_MICROSOFT }, + { type: HeaderFooterTokenType.COMMA }, + { type: HeaderFooterTokenType.TAB }, + { type: HeaderFooterTokenType.SPACE, repeat: '3' }, + { type: HeaderFooterTokenType.ARBITRARY_TEXT, text: 'foo' }, + ])); +}); diff --git a/src/api/dto/from/dtoToHeaderFooter.ts b/src/api/dto/from/dtoToHeaderFooter.ts new file mode 100644 index 00000000..928e4392 --- /dev/null +++ b/src/api/dto/from/dtoToHeaderFooter.ts @@ -0,0 +1,74 @@ +import { + ConvenientConstants, + CriteriaTokenType, + DateFormatType, + HeaderFooterToken, + HeaderFooterTokenType, +} from '../../../types'; +import { + BursarExportHeaderFooterTokenDTO, + BursarExportTokenAggregate, + BursarExportTokenConstant, +} from '../dto-types'; +import dtoToLengthControl from './dtoToLengthControl'; + +export function constantToToken(token: BursarExportTokenConstant): HeaderFooterToken { + if (token.value === ConvenientConstants[HeaderFooterTokenType.NEWLINE]) { + return { type: HeaderFooterTokenType.NEWLINE }; + } else if (token.value === ConvenientConstants[HeaderFooterTokenType.NEWLINE_MICROSOFT]) { + return { type: HeaderFooterTokenType.NEWLINE_MICROSOFT }; + } else if (token.value === ConvenientConstants[HeaderFooterTokenType.TAB]) { + return { type: HeaderFooterTokenType.TAB }; + } else if (token.value === ConvenientConstants[HeaderFooterTokenType.COMMA]) { + return { type: HeaderFooterTokenType.COMMA }; + } else if (/^ +$/.test(token.value)) { + return { + type: HeaderFooterTokenType.SPACE, + repeat: token.value.length.toString(), + }; + } else { + return { type: HeaderFooterTokenType.ARBITRARY_TEXT, text: token.value }; + } +} + +export function aggregateToToken(token: BursarExportTokenAggregate): HeaderFooterToken { + if (token.value === CriteriaTokenType.NUM_ROWS) { + return { + type: HeaderFooterTokenType.AGGREGATE_COUNT, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + } else { + return { + type: HeaderFooterTokenType.AGGREGATE_TOTAL, + decimal: token.decimal, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + } +} + +export function dtoToHeaderFooterToken(token: BursarExportHeaderFooterTokenDTO): HeaderFooterToken { + switch (token.type) { + case 'Constant': + return constantToToken(token); + + case 'Aggregate': + return aggregateToToken(token); + + case 'CurrentDate': + default: + return { + type: HeaderFooterTokenType.CURRENT_DATE, + format: token.value as DateFormatType, + timezone: token.timezone, + lengthControl: dtoToLengthControl(token.lengthControl), + }; + } +} + +// inverse of ../to/headerFooterToDto +export default function dtoToHeaderFooter(tokens: BursarExportHeaderFooterTokenDTO[] | undefined): HeaderFooterToken[] { + if (tokens === undefined) { + return []; + } + return tokens.map(dtoToHeaderFooterToken); +} diff --git a/src/api/dto/from/dtoToLengthControl.test.ts b/src/api/dto/from/dtoToLengthControl.test.ts new file mode 100644 index 00000000..ed2cb35a --- /dev/null +++ b/src/api/dto/from/dtoToLengthControl.test.ts @@ -0,0 +1,9 @@ +import dtoToLengthControl from './dtoToLengthControl'; + +test.each([ + [undefined, undefined], + [ + { character: 'a', length: 3, direction: 'FRONT', truncate: false }, + { character: 'a', length: '3', direction: 'FRONT', truncate: false }, + ], +] as const)('Length control conversion from %s to %s', (input, expected) => expect(dtoToLengthControl(input)).toEqual(expected)); diff --git a/src/api/dto/from/dtoToLengthControl.ts b/src/api/dto/from/dtoToLengthControl.ts new file mode 100644 index 00000000..5dcebcd4 --- /dev/null +++ b/src/api/dto/from/dtoToLengthControl.ts @@ -0,0 +1,18 @@ +import { LengthControl } from '../../../types'; +import { BursarExportTokenLengthControl } from '../dto-types'; + +// inverse of ../to/lengthControlToDto +export default function dtoToLengthControl( + lengthControl: BursarExportTokenLengthControl | undefined, +): LengthControl | undefined { + if (lengthControl === undefined) { + return undefined; + } + + return { + character: lengthControl.character, + length: lengthControl.length.toString(), + direction: lengthControl.direction, + truncate: lengthControl.truncate, + }; +} diff --git a/src/api/dto/from/dtoToScheduling.test.ts b/src/api/dto/from/dtoToScheduling.test.ts new file mode 100644 index 00000000..f7693215 --- /dev/null +++ b/src/api/dto/from/dtoToScheduling.test.ts @@ -0,0 +1,83 @@ +import * as Weekdays from '../../../../test/data/Weekdays'; +import { FormValues, SchedulingFrequency } from '../../../types'; +import { SchedulingDTO } from '../dto-types'; +import dtoToScheduling from './dtoToScheduling'; + +const LOCALE_WEEKDAYS = [ + { weekday: Weekdays.Sunday, long: 'Sunday', short: 'Sun', narrow: 'S' }, + { weekday: Weekdays.Monday, long: 'Monday', short: 'Mon', narrow: 'M' }, + { weekday: Weekdays.Tuesday, long: 'Tuesday', short: 'Tue', narrow: 'T' }, + { + weekday: Weekdays.Wednesday, + long: 'Wednesday', + short: 'Wed', + narrow: 'W', + }, + { weekday: Weekdays.Thursday, long: 'Thursday', short: 'Thu', narrow: 'T' }, + { weekday: Weekdays.Friday, long: 'Friday', short: 'Fri', narrow: 'F' }, + { weekday: Weekdays.Saturday, long: 'Saturday', short: 'Sat', narrow: 'S' }, +]; + +test.each<[SchedulingDTO, FormValues['scheduling']]>([ + [{ schedulePeriod: SchedulingFrequency.Manual }, { frequency: SchedulingFrequency.Manual }], + [{ schedulePeriod: SchedulingFrequency.Hours }, { frequency: SchedulingFrequency.Hours }], + [ + { schedulePeriod: SchedulingFrequency.Hours, scheduleFrequency: 2 }, + { frequency: SchedulingFrequency.Hours, interval: '2' }, + ], + [ + { + schedulePeriod: SchedulingFrequency.Days, + scheduleFrequency: 3, + scheduleTime: '09:00:00.000Z', + }, + { + frequency: SchedulingFrequency.Days, + interval: '3', + time: '09:00:00.000Z', + }, + ], + [ + { + schedulePeriod: SchedulingFrequency.Weeks, + scheduleFrequency: 4, + scheduleTime: '09:45:00.000Z', + }, + { + frequency: SchedulingFrequency.Weeks, + interval: '4', + time: '09:45:00.000Z', + }, + ], + [ + { + schedulePeriod: SchedulingFrequency.Weeks, + scheduleFrequency: 4, + scheduleTime: '09:45:00.000Z', + weekDays: ['MONDAY', 'WEDNESDAY', 'FRIDAY', 'not-real' as any], + }, + { + frequency: SchedulingFrequency.Weeks, + interval: '4', + time: '09:45:00.000Z', + weekdays: [ + { + label: 'Monday', + value: 'MONDAY', + }, + { + label: 'Wednesday', + value: 'WEDNESDAY', + }, + { + label: 'Friday', + value: 'FRIDAY', + }, + { + label: 'not-real', + value: 'not-real' as any, + }, + ], + }, + ], +])('Converts scheduling DTO %s to %s', (input, expected) => expect(dtoToScheduling(input, LOCALE_WEEKDAYS)).toEqual(expected)); diff --git a/src/api/dto/from/dtoToScheduling.ts b/src/api/dto/from/dtoToScheduling.ts new file mode 100644 index 00000000..1924c680 --- /dev/null +++ b/src/api/dto/from/dtoToScheduling.ts @@ -0,0 +1,39 @@ +import { FormValues, SchedulingFrequency } from '../../../types'; +import { LocaleWeekdayInfo } from '../../../utils/weekdayUtils'; +import { SchedulingDTO } from '../dto-types'; + +export function frequencyToString(freq: number | undefined): string | undefined { + return freq?.toString(); +} + +export default function dtoToScheduling( + values: SchedulingDTO, + localeWeekdays: LocaleWeekdayInfo[], +): FormValues['scheduling'] { + switch (values.schedulePeriod) { + case SchedulingFrequency.Hours: + return { + frequency: SchedulingFrequency.Hours, + interval: frequencyToString(values.scheduleFrequency), + }; + case SchedulingFrequency.Days: + return { + frequency: SchedulingFrequency.Days, + interval: frequencyToString(values.scheduleFrequency), + time: values.scheduleTime, + }; + case SchedulingFrequency.Weeks: + return { + frequency: SchedulingFrequency.Weeks, + interval: frequencyToString(values.scheduleFrequency), + time: values.scheduleTime, + weekdays: values.weekDays?.map((day) => ({ + label: localeWeekdays.find((weekday) => weekday.weekday === day)?.long ?? day, // should not happen + value: day, + })), + }; + case SchedulingFrequency.Manual: + default: + return { frequency: SchedulingFrequency.Manual }; + } +} diff --git a/src/api/dto/from/dtoToTransfer.test.ts b/src/api/dto/from/dtoToTransfer.test.ts new file mode 100644 index 00000000..83ab6e20 --- /dev/null +++ b/src/api/dto/from/dtoToTransfer.test.ts @@ -0,0 +1,69 @@ +import { StripesType } from '@folio/stripes/core'; +import { IntlShape } from 'react-intl'; +import getIntl from '../../../../test/util/getIntl'; +import { ComparisonOperator, CriteriaTerminalType, FormValues } from '../../../types'; +import { TransferAccountDTO } from '../../queries/useTransferAccounts'; +import { BursarExportTransferCriteria } from '../dto-types'; +import dtoToTransfer from './dtoToTransfer'; + +// United States +let intlEn: IntlShape; + +beforeAll(() => { + intlEn = getIntl('en-US', 'EST'); +}); + +describe('dtoToTransfer', () => { + test.each<[BursarExportTransferCriteria, TransferAccountDTO[], FormValues['transferInfo']]>([ + [ + { else: { account: 'account' } }, + [], + { + conditions: [], + else: { + account: 'account', + }, + }, + ], + [ + { else: { account: 'account' } }, + [{ id: 'account', ownerId: 'owner' } as TransferAccountDTO], + { + conditions: [], + else: { + account: 'account', + owner: 'owner', + }, + }, + ], + [ + { + conditions: [{ condition: { type: 'Age', condition: 'GREATER_THAN', numDays: 3 }, account: 'account1' }], + else: { account: 'account2' }, + }, + [ + { id: 'account1', ownerId: 'owner1' }, + { id: 'account2', ownerId: 'owner2' }, + ] as TransferAccountDTO[], + { + conditions: [ + { + condition: { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '3', + }, + account: 'account1', + owner: 'owner1', + }, + ], + else: { + account: 'account2', + owner: 'owner2', + }, + }, + ], + ])('Converts transfer DTO %s with known accounts %s to %s', (input, transferAccounts, expected) => expect(dtoToTransfer( + input, [], [], transferAccounts, { currency: 'USD' } as StripesType, intlEn + )).toEqual(expected)); +}); diff --git a/src/api/dto/from/dtoToTransfer.ts b/src/api/dto/from/dtoToTransfer.ts new file mode 100644 index 00000000..95b676eb --- /dev/null +++ b/src/api/dto/from/dtoToTransfer.ts @@ -0,0 +1,34 @@ +import { StripesType } from '@folio/stripes/core'; +import { IntlShape } from 'react-intl'; +import { FormValues } from '../../../types'; +import { FeeFineTypeDTO } from '../../queries/useFeeFineTypes'; +import { LocationDTO } from '../../queries/useLocations'; +import { TransferAccountDTO } from '../../queries/useTransferAccounts'; +import { BursarExportTransferCriteria } from '../dto-types'; +import dtoToCriteria from './dtoToCriteria'; + +export function getOwnerForAccount(transferAccounts: TransferAccountDTO[], accountId: string) { + return transferAccounts.find((type) => type.id === accountId)?.ownerId; +} + +// inverse of ../to/transferToDto +export default function dtoToTransfer( + tokens: BursarExportTransferCriteria, + feeFineTypes: FeeFineTypeDTO[], + locations: LocationDTO[], + transferAccounts: TransferAccountDTO[], + stripes: StripesType, + intl: IntlShape, +): FormValues['transferInfo'] { + return { + conditions: (tokens.conditions ?? []).map(({ condition, account }) => ({ + condition: dtoToCriteria(condition, feeFineTypes, locations, stripes, intl), + owner: getOwnerForAccount(transferAccounts, account), + account, + })), + else: { + owner: getOwnerForAccount(transferAccounts, tokens.else.account), + account: tokens.else.account, + }, + }; +} diff --git a/src/api/dto/from/index.ts b/src/api/dto/from/index.ts new file mode 100644 index 00000000..df6948a1 --- /dev/null +++ b/src/api/dto/from/index.ts @@ -0,0 +1 @@ +export { default } from './dtoToFormValues'; diff --git a/src/api/dto/to/aggregateCriteriaToFilterDto.test.ts b/src/api/dto/to/aggregateCriteriaToFilterDto.test.ts new file mode 100644 index 00000000..beebe40d --- /dev/null +++ b/src/api/dto/to/aggregateCriteriaToFilterDto.test.ts @@ -0,0 +1,60 @@ +import { + ComparisonOperator, + CriteriaAggregate, + CriteriaAggregateType, +} from '../../../types'; +import aggregateCriteriaToFilterDto from './aggregateCriteriaToFilterDto'; +import { BursarExportFilterAggregate } from '../dto-types'; + +describe('Conversion of aggregate criteria to filter DTO', () => { + it.each<[CriteriaAggregate | undefined, BursarExportFilterAggregate | undefined]>([ + [undefined, undefined], + [{ type: CriteriaAggregateType.PASS }, undefined], + + [ + { type: CriteriaAggregateType.NUM_ROWS, amount: '12' }, + { + type: 'Aggregate', + property: 'NUM_ROWS', + condition: 'GREATER_THAN_EQUAL', + amount: 12, + }, + ], + [ + { + type: CriteriaAggregateType.NUM_ROWS, + amount: '12', + operator: ComparisonOperator.LESS_THAN, + }, + { + type: 'Aggregate', + property: 'NUM_ROWS', + condition: 'LESS_THAN', + amount: 12, + }, + ], + + [ + { type: CriteriaAggregateType.TOTAL_AMOUNT, amountCurrency: '12.34' }, + { + type: 'Aggregate', + property: 'TOTAL_AMOUNT', + condition: 'GREATER_THAN_EQUAL', + amount: 1234, + }, + ], + [ + { + type: CriteriaAggregateType.TOTAL_AMOUNT, + amountCurrency: '12.34', + operator: ComparisonOperator.LESS_THAN, + }, + { + type: 'Aggregate', + property: 'TOTAL_AMOUNT', + condition: 'LESS_THAN', + amount: 1234, + }, + ], + ])('converts %s into %s', (input, expected) => expect(aggregateCriteriaToFilterDto(input)).toEqual(expected)); +}); diff --git a/src/api/dto/to/aggregateCriteriaToFilterDto.ts b/src/api/dto/to/aggregateCriteriaToFilterDto.ts new file mode 100644 index 00000000..95b40fbf --- /dev/null +++ b/src/api/dto/to/aggregateCriteriaToFilterDto.ts @@ -0,0 +1,32 @@ +import { + CriteriaAggregate, + CriteriaAggregateType, + CriteriaTokenType, +} from '../../../types'; +import guardNumber from '../../../utils/guardNumber'; +import { BursarExportFilterAggregate } from '../dto-types'; + +export default function aggregateCriteriaToFilterDto( + criteria: CriteriaAggregate | undefined, +): BursarExportFilterAggregate | undefined { + switch (criteria?.type) { + case CriteriaAggregateType.NUM_ROWS: + return { + type: 'Aggregate', + property: CriteriaTokenType.NUM_ROWS, + condition: criteria.operator ?? 'GREATER_THAN_EQUAL', + amount: guardNumber(criteria.amount, 0), + }; + + case CriteriaAggregateType.TOTAL_AMOUNT: + return { + type: 'Aggregate', + property: CriteriaTokenType.TOTAL_AMOUNT, + condition: criteria.operator ?? 'GREATER_THAN_EQUAL', + amount: guardNumber(criteria.amountCurrency, 0, (value) => value * 100), + }; + + default: + return undefined; + } +} diff --git a/src/api/dto/to/criteriaToFilterDto.test.ts b/src/api/dto/to/criteriaToFilterDto.test.ts new file mode 100644 index 00000000..bb6f9269 --- /dev/null +++ b/src/api/dto/to/criteriaToFilterDto.test.ts @@ -0,0 +1,184 @@ +import { + AndOrOperator, + ComparisonOperator, + CriteriaGroup, + CriteriaGroupType, + CriteriaTerminal, + CriteriaTerminalType, +} from '../../../types'; +import { BursarExportFilterDTO } from '../dto-types'; +import criteriaToFilterDto from './criteriaToFilterDto'; + +describe('Conversion of form values to filter DTO', () => { + it.each<[CriteriaGroup | CriteriaTerminal | undefined, BursarExportFilterDTO]>([ + [ + { type: CriteriaTerminalType.AGE, numDays: '1' }, + { type: 'Age', condition: 'LESS_THAN_EQUAL', numDays: 1 }, + ], + [ + { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '1', + }, + { type: 'Age', condition: 'GREATER_THAN', numDays: 1 }, + ], + + [ + { type: CriteriaTerminalType.AMOUNT, amountCurrency: '12.34' }, + { type: 'Amount', condition: 'LESS_THAN_EQUAL', amount: 1234 }, + ], + [ + { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '12.34', + }, + { type: 'Amount', condition: 'GREATER_THAN', amount: 1234 }, + ], + + [{ type: CriteriaTerminalType.FEE_FINE_TYPE }, { type: 'FeeType', feeFineTypeId: '' }], + [ + { + type: CriteriaTerminalType.FEE_FINE_TYPE, + feeFineTypeId: 'fee-fine-type-id', + }, + { type: 'FeeType', feeFineTypeId: 'fee-fine-type-id' }, + ], + + [{ type: CriteriaTerminalType.FEE_FINE_OWNER }, { type: 'FeeFineOwner', feeFineOwner: '' }], + [ + { + type: CriteriaTerminalType.FEE_FINE_OWNER, + feeFineOwnerId: 'fee-fine-owner-id', + }, + { type: 'FeeFineOwner', feeFineOwner: 'fee-fine-owner-id' }, + ], + + [{ type: CriteriaTerminalType.LOCATION }, { type: 'Location', locationId: '' }], + [ + { + type: CriteriaTerminalType.LOCATION, + locationId: 'location-id', + }, + { type: 'Location', locationId: 'location-id' }, + ], + + [{ type: CriteriaTerminalType.PATRON_GROUP }, { type: 'PatronGroup', patronGroupId: '' }], + [ + { + type: CriteriaTerminalType.PATRON_GROUP, + patronGroupId: 'patron-group-id', + }, + { type: 'PatronGroup', patronGroupId: 'patron-group-id' }, + ], + + [{ type: CriteriaTerminalType.PATRON_GROUP }, { type: 'PatronGroup', patronGroupId: '' }], + [ + { + type: CriteriaTerminalType.PATRON_GROUP, + patronGroupId: 'patron-group-id', + }, + { type: 'PatronGroup', patronGroupId: 'patron-group-id' }, + ], + + [{ type: CriteriaTerminalType.SERVICE_POINT }, { type: 'ServicePoint', servicePointId: '' }], + [ + { + type: CriteriaTerminalType.SERVICE_POINT, + servicePointId: 'service-point-id', + }, + { type: 'ServicePoint', servicePointId: 'service-point-id' }, + ], + + [undefined, { type: 'Pass' }], + [{ type: CriteriaTerminalType.PASS }, { type: 'Pass' }], + [{ type: 'invalid' } as unknown as CriteriaTerminal, { type: 'Pass' }], + ])('converts %s into %s', (input, expected) => expect(criteriaToFilterDto(input)).toEqual(expected)); + + it.each<[CriteriaGroup, BursarExportFilterDTO]>([ + [{ type: CriteriaGroupType.ALL_OF }, { type: 'Condition', operation: AndOrOperator.AND, criteria: [] }], + [{ type: CriteriaGroupType.ANY_OF }, { type: 'Condition', operation: AndOrOperator.OR, criteria: [] }], + [ + { type: CriteriaGroupType.NONE_OF }, + { + type: 'Negation', + criteria: { + type: 'Condition', + operation: AndOrOperator.OR, + criteria: [], + }, + }, + ], + + [ + { + type: CriteriaGroupType.ALL_OF, + criteria: [ + { type: CriteriaTerminalType.AGE, numDays: '12' }, + { + type: CriteriaTerminalType.PATRON_GROUP, + patronGroupId: 'patron-group-id', + }, + ], + }, + { + type: 'Condition', + operation: AndOrOperator.AND, + criteria: [ + { type: 'Age', condition: 'LESS_THAN_EQUAL', numDays: 12 }, + { + type: 'PatronGroup', + patronGroupId: 'patron-group-id', + }, + ], + }, + ], + + [ + { + type: CriteriaGroupType.NONE_OF, + criteria: [ + { + type: CriteriaTerminalType.SERVICE_POINT, + servicePointId: 'service-point-id', + }, + ], + }, + { + type: 'Negation', + criteria: { + type: 'ServicePoint', + servicePointId: 'service-point-id', + }, + }, + ], + + [ + { + type: CriteriaGroupType.NONE_OF, + criteria: [ + { type: CriteriaTerminalType.AGE, numDays: '12' }, + { + type: CriteriaTerminalType.PATRON_GROUP, + patronGroupId: 'patron-group-id', + }, + ], + }, + { + type: 'Negation', + criteria: { + type: 'Condition', + operation: AndOrOperator.OR, + criteria: [ + { type: 'Age', condition: 'LESS_THAN_EQUAL', numDays: 12 }, + { + type: 'PatronGroup', + patronGroupId: 'patron-group-id', + }, + ], + }, + }, + ], + ])('converts group %s into %s', (input, expected) => expect(criteriaToFilterDto(input)).toEqual(expected)); +}); diff --git a/src/api/dto/to/criteriaToFilterDto.ts b/src/api/dto/to/criteriaToFilterDto.ts new file mode 100644 index 00000000..95aa766e --- /dev/null +++ b/src/api/dto/to/criteriaToFilterDto.ts @@ -0,0 +1,102 @@ +import { + AndOrOperator, + CriteriaGroup, + CriteriaGroupType, + CriteriaTerminal, + CriteriaTerminalType, +} from '../../../types'; +import guardNumber from '../../../utils/guardNumber'; +import { BursarExportFilterDTO } from '../dto-types'; + +export default function criteriaToFilterDto(criteria?: CriteriaGroup | CriteriaTerminal): BursarExportFilterDTO { + switch (criteria?.type) { + case CriteriaTerminalType.AGE: + return { + type: 'Age', + condition: criteria.operator ?? 'LESS_THAN_EQUAL', + numDays: guardNumber(criteria.numDays, 0), + }; + + case CriteriaTerminalType.AMOUNT: + return { + type: 'Amount', + condition: criteria.operator ?? 'LESS_THAN_EQUAL', + amount: guardNumber(criteria.amountCurrency, 0, (v) => v * 100), + }; + + case CriteriaTerminalType.FEE_FINE_TYPE: + return { + type: 'FeeType', + feeFineTypeId: criteria.feeFineTypeId ?? '', + }; + + case CriteriaTerminalType.FEE_FINE_OWNER: + return { + type: 'FeeFineOwner', + feeFineOwner: criteria.feeFineOwnerId ?? '', + }; + + case CriteriaTerminalType.LOCATION: + return { + type: 'Location', + locationId: criteria.locationId ?? '', + }; + + case CriteriaTerminalType.PATRON_GROUP: + return { + type: 'PatronGroup', + patronGroupId: criteria.patronGroupId ?? '', + }; + + case CriteriaTerminalType.SERVICE_POINT: + return { + type: 'ServicePoint', + servicePointId: criteria.servicePointId ?? '', + }; + + case CriteriaGroupType.ALL_OF: + case CriteriaGroupType.ANY_OF: + case CriteriaGroupType.NONE_OF: + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return groupToFilterDto(criteria); + + case CriteriaTerminalType.PASS: + default: + return { type: 'Pass' }; + } +} + +export function groupToFilterDto(criteria: CriteriaGroup): BursarExportFilterDTO { + const criteriaList = criteria.criteria ?? []; + + if (criteria.type === CriteriaGroupType.ALL_OF) { + return { + type: 'Condition', + operation: AndOrOperator.AND, + criteria: criteriaList.map(criteriaToFilterDto), + }; + } + if (criteria.type === CriteriaGroupType.ANY_OF) { + return { + type: 'Condition', + operation: AndOrOperator.OR, + criteria: criteriaList.map(criteriaToFilterDto), + }; + } + + if (criteriaList.length === 1) { + return { + type: 'Negation', + criteria: criteriaToFilterDto(criteriaList[0]), + }; + } + + return { + type: 'Negation', + criteria: { + type: 'Condition', + operation: AndOrOperator.OR, + criteria: criteriaList.map(criteriaToFilterDto), + }, + }; +} diff --git a/src/api/dto/to/dataToDto.test.ts b/src/api/dto/to/dataToDto.test.ts new file mode 100644 index 00000000..24b232d0 --- /dev/null +++ b/src/api/dto/to/dataToDto.test.ts @@ -0,0 +1,194 @@ +import { CriteriaTerminalType, DataToken, DataTokenType, DateFormatType } from '../../../types'; +import { BursarExportDataTokenDTO } from '../dto-types'; +import dataToDto, { dataTokenToDto } from './dataToDto'; + +describe('Data token conversion', () => { + test.each<[DataToken, BursarExportDataTokenDTO]>([ + [{ type: DataTokenType.NEWLINE }, { type: 'Constant', value: '\n' }], + [{ type: DataTokenType.NEWLINE_MICROSOFT }, { type: 'Constant', value: '\r\n' }], + [{ type: DataTokenType.TAB }, { type: 'Constant', value: '\t' }], + [{ type: DataTokenType.COMMA }, { type: 'Constant', value: ',' }], + [ + { type: DataTokenType.SPACE, repeat: '' }, + { type: 'Constant', value: '' }, + ], + [ + { type: DataTokenType.SPACE, repeat: '5' }, + { type: 'Constant', value: ' ' }, + ], + [ + { type: DataTokenType.ARBITRARY_TEXT, text: 'foo' }, + { type: 'Constant', value: 'foo' }, + ], + + [ + { type: DataTokenType.AGGREGATE_COUNT }, + { type: 'Aggregate', value: 'NUM_ROWS', decimal: false }, + ], + + [ + { type: DataTokenType.AGGREGATE_TOTAL, decimal: false }, + { type: 'Aggregate', value: 'TOTAL_AMOUNT', decimal: false }, + ], + [ + { type: DataTokenType.AGGREGATE_TOTAL, decimal: true }, + { type: 'Aggregate', value: 'TOTAL_AMOUNT', decimal: true }, + ], + + [ + { type: DataTokenType.ACCOUNT_AMOUNT, decimal: false }, + { type: 'FeeAmount', decimal: false }, + ], + [ + { type: DataTokenType.ACCOUNT_AMOUNT, decimal: true }, + { type: 'FeeAmount', decimal: true }, + ], + + [ + { + type: DataTokenType.CURRENT_DATE, + format: DateFormatType.DATE, + timezone: 'timezone', + }, + { type: 'CurrentDate', value: 'DATE', timezone: 'timezone' }, + ], + + [ + { + type: DataTokenType.ACCOUNT_DATE, + dateProperty: 'DUE', + format: DateFormatType.QUARTER, + timezone: 'timezone', + placeholder: 'placeholder', + }, + { + type: 'FeeDate', + property: 'DUE', + value: 'QUARTER', + timezone: 'timezone', + placeholder: 'placeholder', + }, + ], + [ + { + type: DataTokenType.ACCOUNT_DATE, + dateProperty: 'DUE', + format: DateFormatType.QUARTER, + timezone: 'timezone', + }, + { + type: 'FeeDate', + property: 'DUE', + value: 'QUARTER', + timezone: 'timezone', + placeholder: '', + }, + ], + + [ + { + type: DataTokenType.FEE_FINE_TYPE, + feeFineAttribute: 'FEE_FINE_TYPE_ID', + }, + { type: 'FeeMetadata', value: 'FEE_FINE_TYPE_ID' }, + ], + + [ + { type: DataTokenType.ITEM_INFO, itemAttribute: 'CAMPUS_ID' }, + { type: 'ItemData', value: 'CAMPUS_ID', placeholder: '' }, + ], + [ + { + type: DataTokenType.ITEM_INFO, + itemAttribute: 'BARCODE', + placeholder: 'place', + }, + { type: 'ItemData', value: 'BARCODE', placeholder: 'place' }, + ], + + [ + { + type: DataTokenType.USER_DATA, + userAttribute: 'FOLIO_ID', + // no placeholders needed for this type, so it should be ignored + placeholder: 'foo', + }, + { type: 'UserData', value: 'FOLIO_ID' }, + ], + [ + { type: DataTokenType.USER_DATA, userAttribute: 'PATRON_GROUP_ID' }, + { type: 'UserDataOptional', value: 'PATRON_GROUP_ID', placeholder: '' }, + ], + [ + { type: DataTokenType.USER_DATA, userAttribute: 'EXTERNAL_SYSTEM_ID' }, + { type: 'UserDataOptional', value: 'EXTERNAL_SYSTEM_ID', placeholder: '' }, + ], + [ + { type: DataTokenType.USER_DATA, userAttribute: 'BARCODE' }, + { type: 'UserDataOptional', value: 'BARCODE', placeholder: '' }, + ], + [ + { + type: DataTokenType.USER_DATA, + userAttribute: 'FIRST_NAME', + placeholder: 'fname', + }, + { type: 'UserDataOptional', value: 'FIRST_NAME', placeholder: 'fname' }, + ], + + [ + { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: undefined, + else: 'else', + }, + { + type: 'Conditional', + conditions: [], + else: { type: 'Constant', value: 'else' }, + }, + ], + + [ + { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: [ + { + type: CriteriaTerminalType.PATRON_GROUP, + patronGroupId: 'patronGroupId', + value: 'if', + }, + ], + else: 'else', + }, + { + type: 'Conditional', + conditions: [ + { + condition: { + type: 'PatronGroup', + patronGroupId: 'patronGroupId', + }, + value: { + type: 'Constant', + value: 'if', + }, + }, + ], + else: { type: 'Constant', value: 'else' }, + }, + ], + ])('Converts %s into %s', (token, expected) => expect(dataTokenToDto(token)).toEqual(expected)); + + test.each<[DataToken[] | undefined, BursarExportDataTokenDTO[]]>([ + [undefined, []], + [[], []], + [ + [{ type: DataTokenType.NEWLINE }, { type: DataTokenType.COMMA }], + [ + { type: 'Constant', value: '\n' }, + { type: 'Constant', value: ',' }, + ], + ], + ])('Bulk converts %s into %s', (token, expected) => expect(dataToDto(token)).toEqual(expected)); +}); diff --git a/src/api/dto/to/dataToDto.ts b/src/api/dto/to/dataToDto.ts new file mode 100644 index 00000000..62793e02 --- /dev/null +++ b/src/api/dto/to/dataToDto.ts @@ -0,0 +1,127 @@ +import { ConvenientConstants, CriteriaTokenType, DataToken, DataTokenType } from '../../../types'; +import { guardNumberPositive } from '../../../utils/guardNumber'; +import { BursarExportDataTokenDTO } from '../dto-types'; +import criteriaToFilterDto from './criteriaToFilterDto'; +import lengthControlToDto from './lengthControlToDto'; + +export function userDataToDto(token: DataToken & { type: DataTokenType.USER_DATA }): BursarExportDataTokenDTO { + if (token.userAttribute === 'FOLIO_ID') { + return { + type: 'UserData', + value: token.userAttribute, + lengthControl: lengthControlToDto(token.lengthControl), + }; + + // all others (barcode, username, human name) are optional + } else { + return { + type: 'UserDataOptional', + value: token.userAttribute, + placeholder: token.placeholder ?? '', + lengthControl: lengthControlToDto(token.lengthControl), + }; + } +} + +export function dataTokenToDto(token: DataToken): BursarExportDataTokenDTO { + switch (token.type) { + case DataTokenType.NEWLINE: + case DataTokenType.NEWLINE_MICROSOFT: + case DataTokenType.TAB: + case DataTokenType.COMMA: + return { type: 'Constant', value: ConvenientConstants[token.type] }; + + case DataTokenType.SPACE: + return { + type: 'Constant', + value: ConvenientConstants[token.type].repeat(guardNumberPositive(token.repeat)), + }; + + case DataTokenType.ARBITRARY_TEXT: + return { type: 'Constant', value: token.text }; + + case DataTokenType.AGGREGATE_COUNT: + return { + type: 'Aggregate', + value: CriteriaTokenType.NUM_ROWS, + decimal: false, + lengthControl: lengthControlToDto(token.lengthControl), + }; + + case DataTokenType.AGGREGATE_TOTAL: + return { + type: 'Aggregate', + value: CriteriaTokenType.TOTAL_AMOUNT, + decimal: token.decimal, + lengthControl: lengthControlToDto(token.lengthControl), + }; + + case DataTokenType.ACCOUNT_AMOUNT: + return { + type: 'FeeAmount', + decimal: token.decimal, + lengthControl: lengthControlToDto(token.lengthControl), + }; + + case DataTokenType.CURRENT_DATE: + return { + type: 'CurrentDate', + value: token.format, + timezone: token.timezone, + lengthControl: lengthControlToDto(token.lengthControl), + }; + + case DataTokenType.ACCOUNT_DATE: + return { + type: 'FeeDate', + property: token.dateProperty, + value: token.format, + placeholder: token.placeholder ?? '', + timezone: token.timezone, + lengthControl: lengthControlToDto(token.lengthControl), + }; + + case DataTokenType.FEE_FINE_TYPE: + return { + type: 'FeeMetadata', + value: token.feeFineAttribute, + lengthControl: lengthControlToDto(token.lengthControl), + }; + + case DataTokenType.ITEM_INFO: + return { + type: 'ItemData', + value: token.itemAttribute, + placeholder: token.placeholder ?? '', + lengthControl: lengthControlToDto(token.lengthControl), + }; + + case DataTokenType.USER_DATA: + return userDataToDto(token); + + case DataTokenType.CONSTANT_CONDITIONAL: + default: + return { + type: 'Conditional', + conditions: (token.conditions ?? []).map((condition) => ({ + condition: criteriaToFilterDto(condition), + value: { + type: 'Constant', + value: condition.value, + }, + })), + else: { + type: 'Constant', + value: token.else, + }, + }; + } +} + +export default function dataToDto(tokens: DataToken[] | undefined): BursarExportDataTokenDTO[] { + if (tokens === undefined) { + return []; + } + + return tokens.map(dataTokenToDto); +} diff --git a/src/api/dto/to/formValuesToDto.test.ts b/src/api/dto/to/formValuesToDto.test.ts new file mode 100644 index 00000000..210d9b49 --- /dev/null +++ b/src/api/dto/to/formValuesToDto.test.ts @@ -0,0 +1,85 @@ +import { + CriteriaAggregateType, + CriteriaTerminalType, + FormValues, + SchedulingFrequency, + DataTokenType, + HeaderFooterTokenType, +} from '../../../types'; +import formValuesToDto from './formValuesToDto'; +import { BursarExportJobDTO } from '../dto-types'; + +describe('Form values conversion', () => { + const TEST_VALUE: Omit = { + scheduling: { frequency: SchedulingFrequency.Manual }, + + criteria: { type: CriteriaTerminalType.PASS }, + aggregateFilter: { type: CriteriaAggregateType.NUM_ROWS }, + + header: [ + { type: HeaderFooterTokenType.ARBITRARY_TEXT, text: 'head' }, + { type: HeaderFooterTokenType.AGGREGATE_COUNT }, + ], + + data: [{ type: DataTokenType.ARBITRARY_TEXT, text: 'non-aggregate data' }], + dataAggregate: [{ type: DataTokenType.ARBITRARY_TEXT, text: 'aggregate data' }], + + footer: [{ type: HeaderFooterTokenType.ARBITRARY_TEXT, text: 'foot' }], + + transferInfo: { + conditions: [ + { + condition: { + type: CriteriaTerminalType.SERVICE_POINT, + servicePointId: 'spId', + }, + account: 'if-acct', + }, + ], + else: { account: 'else-sp' }, + }, + }; + + const EXPECTED: Omit = { + filter: { + type: 'Pass', + }, + header: [ + { type: 'Constant', value: 'head' }, + { + type: 'Aggregate', + value: 'NUM_ROWS', + decimal: false, + lengthControl: undefined, + }, + ], + footer: [{ type: 'Constant', value: 'foot' }], + transferInfo: { + conditions: [ + { + condition: { type: 'ServicePoint', servicePointId: 'spId' }, + account: 'if-acct', + }, + ], + else: { account: 'else-sp' }, + }, + }; + + it('converts non-aggregate values', () => expect(formValuesToDto({ ...TEST_VALUE, aggregate: false })).toEqual({ + ...EXPECTED, + groupByPatron: false, + data: [{ type: 'Constant', value: 'non-aggregate data' }], + })); + + it('converts aggregate values', () => expect(formValuesToDto({ ...TEST_VALUE, aggregate: true })).toEqual({ + ...EXPECTED, + groupByPatron: true, + groupByPatronFilter: { + type: 'Aggregate', + amount: 0, + condition: 'GREATER_THAN_EQUAL', + property: 'NUM_ROWS', + }, + data: [{ type: 'Constant', value: 'aggregate data' }], + })); +}); diff --git a/src/api/dto/to/formValuesToDto.ts b/src/api/dto/to/formValuesToDto.ts new file mode 100644 index 00000000..d91c3fae --- /dev/null +++ b/src/api/dto/to/formValuesToDto.ts @@ -0,0 +1,32 @@ +import { FormValues } from '../../../types'; +import { BursarExportJobDTO } from '../dto-types'; +import aggregateCriteriaToFilterDto from './aggregateCriteriaToFilterDto'; +import criteriaToFilterDto from './criteriaToFilterDto'; +import dataToDto from './dataToDto'; +import headerFooterToDto from './headerFooterToDto'; +import transferToDto from './transferToDto'; + +export default function formValuesToDto(values: FormValues): BursarExportJobDTO { + if (values.aggregate) { + return { + filter: criteriaToFilterDto(values.criteria), + groupByPatron: true, + groupByPatronFilter: aggregateCriteriaToFilterDto(values.aggregateFilter), + + header: headerFooterToDto(values.header), + data: dataToDto(values.dataAggregate), + footer: headerFooterToDto(values.footer), + transferInfo: transferToDto(values.transferInfo), + }; + } else { + return { + filter: criteriaToFilterDto(values.criteria), + groupByPatron: false, + + header: headerFooterToDto(values.header), + data: dataToDto(values.data), + footer: headerFooterToDto(values.footer), + transferInfo: transferToDto(values.transferInfo), + }; + } +} diff --git a/src/api/dto/to/headerFooterToDto.test.ts b/src/api/dto/to/headerFooterToDto.test.ts new file mode 100644 index 00000000..efce81f3 --- /dev/null +++ b/src/api/dto/to/headerFooterToDto.test.ts @@ -0,0 +1,60 @@ +import { + DateFormatType, + HeaderFooterToken, + HeaderFooterTokenType, +} from '../../../types'; +import { BursarExportHeaderFooterTokenDTO } from '../dto-types'; +import headerFooterToDto, { headerFooterTokenToDto } from './headerFooterToDto'; + +describe('Header/footer token conversion', () => { + test.each<[HeaderFooterToken[] | undefined, BursarExportHeaderFooterTokenDTO[]]>([ + [undefined, []], + [[], []], + [ + [{ type: HeaderFooterTokenType.NEWLINE }, { type: HeaderFooterTokenType.NEWLINE_MICROSOFT }], + [ + { type: 'Constant', value: '\n' }, + { type: 'Constant', value: '\r\n' }, + ], + ], + ])('headerFooterToDto(%s) = %s', (input, expected) => expect(headerFooterToDto(input)).toEqual(expected)); + + test.each<[HeaderFooterToken, BursarExportHeaderFooterTokenDTO]>([ + [{ type: HeaderFooterTokenType.NEWLINE }, { type: 'Constant', value: '\n' }], + [{ type: HeaderFooterTokenType.NEWLINE_MICROSOFT }, { type: 'Constant', value: '\r\n' }], + [{ type: HeaderFooterTokenType.TAB }, { type: 'Constant', value: '\t' }], + [{ type: HeaderFooterTokenType.COMMA }, { type: 'Constant', value: ',' }], + [ + { type: HeaderFooterTokenType.SPACE, repeat: '' }, + { type: 'Constant', value: '' }, + ], + [ + { type: HeaderFooterTokenType.SPACE, repeat: '5' }, + { type: 'Constant', value: ' ' }, + ], + [ + { type: HeaderFooterTokenType.ARBITRARY_TEXT, text: 'foo' }, + { type: 'Constant', value: 'foo' }, + ], + + [{ type: HeaderFooterTokenType.AGGREGATE_COUNT }, { type: 'Aggregate', value: 'NUM_ROWS', decimal: false }], + + [ + { type: HeaderFooterTokenType.AGGREGATE_TOTAL, decimal: false }, + { type: 'Aggregate', value: 'TOTAL_AMOUNT', decimal: false }, + ], + [ + { type: HeaderFooterTokenType.AGGREGATE_TOTAL, decimal: true }, + { type: 'Aggregate', value: 'TOTAL_AMOUNT', decimal: true }, + ], + + [ + { + type: HeaderFooterTokenType.CURRENT_DATE, + format: DateFormatType.YEAR_LONG, + timezone: 'timezone', + }, + { type: 'CurrentDate', value: 'YEAR_LONG', timezone: 'timezone' }, + ], + ])('headerFooterTokenToDto(%s) = %s', (input, expected) => expect(headerFooterTokenToDto(input)).toEqual(expected)); +}); diff --git a/src/api/dto/to/headerFooterToDto.ts b/src/api/dto/to/headerFooterToDto.ts new file mode 100644 index 00000000..24853998 --- /dev/null +++ b/src/api/dto/to/headerFooterToDto.ts @@ -0,0 +1,56 @@ +import { ConvenientConstants, CriteriaTokenType, HeaderFooterToken, HeaderFooterTokenType } from '../../../types'; +import { guardNumberPositive } from '../../../utils/guardNumber'; +import { BursarExportHeaderFooterTokenDTO } from '../dto-types'; +import lengthControlToDto from './lengthControlToDto'; + +export function headerFooterTokenToDto(token: HeaderFooterToken): BursarExportHeaderFooterTokenDTO { + switch (token.type) { + case HeaderFooterTokenType.NEWLINE: + case HeaderFooterTokenType.NEWLINE_MICROSOFT: + case HeaderFooterTokenType.TAB: + case HeaderFooterTokenType.COMMA: + return { type: 'Constant', value: ConvenientConstants[token.type] }; + + case HeaderFooterTokenType.SPACE: + return { + type: 'Constant', + value: ConvenientConstants[token.type].repeat(guardNumberPositive(token.repeat)), + }; + + case HeaderFooterTokenType.ARBITRARY_TEXT: + return { type: 'Constant', value: token.text }; + + case HeaderFooterTokenType.AGGREGATE_COUNT: + return { + type: 'Aggregate', + value: CriteriaTokenType.NUM_ROWS, + decimal: false, + lengthControl: lengthControlToDto(token.lengthControl), + }; + + case HeaderFooterTokenType.AGGREGATE_TOTAL: + return { + type: 'Aggregate', + value: CriteriaTokenType.TOTAL_AMOUNT, + decimal: token.decimal, + lengthControl: lengthControlToDto(token.lengthControl), + }; + + case HeaderFooterTokenType.CURRENT_DATE: + default: + return { + type: 'CurrentDate', + value: token.format, + timezone: token.timezone, + lengthControl: lengthControlToDto(token.lengthControl), + }; + } +} + +export default function headerFooterToDto(tokens: HeaderFooterToken[] | undefined): BursarExportHeaderFooterTokenDTO[] { + if (tokens === undefined) { + return []; + } + + return tokens.map(headerFooterTokenToDto); +} diff --git a/src/api/dto/to/index.ts b/src/api/dto/to/index.ts new file mode 100644 index 00000000..7080741e --- /dev/null +++ b/src/api/dto/to/index.ts @@ -0,0 +1,2 @@ +export { default as formValuesToDto } from './formValuesToDto'; +export { default as schedulingToDto } from './schedulingToDto'; diff --git a/src/api/dto/to/lengthControlToDto.test.ts b/src/api/dto/to/lengthControlToDto.test.ts new file mode 100644 index 00000000..cd364dd6 --- /dev/null +++ b/src/api/dto/to/lengthControlToDto.test.ts @@ -0,0 +1,50 @@ +import { LengthControl } from '../../../types'; +import { BursarExportTokenLengthControl } from '../dto-types'; +import lengthControlToDto from './lengthControlToDto'; + +describe('Length control conversion', () => { + test.each<[LengthControl | undefined, BursarExportTokenLengthControl | undefined]>([ + [undefined, undefined], + [ + { + character: 'a', + length: '12', + direction: 'FRONT', + truncate: true, + }, + { + character: 'a', + length: 12, + direction: 'FRONT', + truncate: true, + }, + ], + [ + { + length: '12', + direction: 'FRONT', + truncate: true, + }, + { + character: '', + length: 12, + direction: 'FRONT', + truncate: true, + }, + ], + [ + { + character: 'abc', + length: '3.4', + direction: 'BACK', + truncate: false, + }, + { + character: 'a', + length: 3, + direction: 'BACK', + truncate: false, + }, + ], + ])('lengthControlToDto(%s) = %s', (input, expected) => expect(lengthControlToDto(input)).toEqual(expected)); +}); diff --git a/src/api/dto/to/lengthControlToDto.ts b/src/api/dto/to/lengthControlToDto.ts new file mode 100644 index 00000000..4714458b --- /dev/null +++ b/src/api/dto/to/lengthControlToDto.ts @@ -0,0 +1,18 @@ +import { LengthControl } from '../../../types'; +import { guardNumberPositive } from '../../../utils/guardNumber'; +import { BursarExportTokenLengthControl } from '../dto-types'; + +export default function lengthControlToDto( + lengthControl: LengthControl | undefined, +): BursarExportTokenLengthControl | undefined { + if (lengthControl === undefined) { + return undefined; + } + + return { + character: lengthControl.character?.substring(0, 1) ?? '', + length: guardNumberPositive(lengthControl.length), + direction: lengthControl.direction, + truncate: lengthControl.truncate, + }; +} diff --git a/src/api/dto/to/schedulingToDto.test.ts b/src/api/dto/to/schedulingToDto.test.ts new file mode 100644 index 00000000..b079ad25 --- /dev/null +++ b/src/api/dto/to/schedulingToDto.test.ts @@ -0,0 +1,71 @@ +import { FormValues, SchedulingFrequency } from '../../../types'; +import { SchedulingDTO } from '../dto-types'; +import schedulingToDto from './schedulingToDto'; + +describe('Scheduling conversion to DTO', () => { + it.each<[FormValues['scheduling'], SchedulingDTO]>([ + [{ frequency: SchedulingFrequency.Manual }, { schedulePeriod: SchedulingFrequency.Manual }], + [ + { frequency: SchedulingFrequency.Hours, interval: '1' }, + { schedulePeriod: SchedulingFrequency.Hours, scheduleFrequency: 1 }, + ], + [ + { + frequency: SchedulingFrequency.Days, + interval: '1', + }, + { + schedulePeriod: SchedulingFrequency.Days, + scheduleFrequency: 1, + scheduleTime: '00:00:00.000Z', + }, + ], + [ + { + frequency: SchedulingFrequency.Days, + interval: '1', + time: '13:30:05.000Z', + }, + { + schedulePeriod: SchedulingFrequency.Days, + scheduleFrequency: 1, + scheduleTime: '13:30:05.000Z', + }, + ], + [ + { + frequency: SchedulingFrequency.Weeks, + interval: '1', + }, + { + schedulePeriod: SchedulingFrequency.Weeks, + scheduleFrequency: 1, + scheduleTime: '00:00:00.000Z', + weekDays: [], + }, + ], + [ + { + frequency: SchedulingFrequency.Weeks, + interval: '1', + time: '12:27:58.000Z', + weekdays: [ + { + label: 'Monday', + value: 'MONDAY', + }, + { + label: 'Thursday', + value: 'THURSDAY', + }, + ], + }, + { + schedulePeriod: SchedulingFrequency.Weeks, + scheduleFrequency: 1, + scheduleTime: '12:27:58.000Z', + weekDays: ['MONDAY', 'THURSDAY'], + }, + ], + ])('converts %s to %s', (input, expected) => expect(schedulingToDto(input)).toEqual(expected)); +}); diff --git a/src/api/dto/to/schedulingToDto.ts b/src/api/dto/to/schedulingToDto.ts new file mode 100644 index 00000000..aaaa18a6 --- /dev/null +++ b/src/api/dto/to/schedulingToDto.ts @@ -0,0 +1,29 @@ +import { FormValues, SchedulingFrequency } from '../../../types'; +import { guardNumberPositive } from '../../../utils/guardNumber'; +import { SchedulingDTO } from '../dto-types'; + +export default function schedulingToDto(values: FormValues['scheduling']): SchedulingDTO { + switch (values.frequency) { + case SchedulingFrequency.Manual: + return { schedulePeriod: SchedulingFrequency.Manual }; + case SchedulingFrequency.Hours: + return { + schedulePeriod: SchedulingFrequency.Hours, + scheduleFrequency: guardNumberPositive(values.interval), + }; + case SchedulingFrequency.Days: + return { + schedulePeriod: SchedulingFrequency.Days, + scheduleFrequency: guardNumberPositive(values.interval), + scheduleTime: values.time ?? '00:00:00.000Z', + }; + case SchedulingFrequency.Weeks: + default: + return { + schedulePeriod: SchedulingFrequency.Weeks, + scheduleFrequency: guardNumberPositive(values.interval), + scheduleTime: values.time ?? '00:00:00.000Z', + weekDays: values.weekdays?.map(({ value }) => value) ?? [], + }; + } +} diff --git a/src/api/dto/to/transferToDto.test.ts b/src/api/dto/to/transferToDto.test.ts new file mode 100644 index 00000000..e2d5d51a --- /dev/null +++ b/src/api/dto/to/transferToDto.test.ts @@ -0,0 +1,61 @@ +import { CriteriaTerminalType, FormValues } from '../../../types'; +import { BursarExportTransferCriteria } from '../dto-types'; +import transferToDto from './transferToDto'; + +describe('Transfer info conversion', () => { + test.each<[FormValues['transferInfo'], BursarExportTransferCriteria]>([ + [undefined, { conditions: [], else: { account: '' } }], + [{}, { conditions: [], else: { account: '' } }], + [{ else: {} }, { conditions: [], else: { account: '' } }], + [{ conditions: [] }, { conditions: [], else: { account: '' } }], + [ + { conditions: [], else: { account: 'else-value' } }, + { conditions: [], else: { account: 'else-value' } }, + ], + [ + { + conditions: [ + { + condition: { + type: CriteriaTerminalType.PATRON_GROUP, + patronGroupId: 'patronGroup', + }, + }, + ], + else: { account: 'else' }, + }, + { + conditions: [ + { + condition: { type: 'PatronGroup', patronGroupId: 'patronGroup' }, + account: '', + }, + ], + else: { account: 'else' }, + }, + ], + [ + { + conditions: [ + { + condition: { + type: CriteriaTerminalType.SERVICE_POINT, + servicePointId: 'spId', + }, + account: 'if-acct', + }, + ], + else: { account: 'else-sp' }, + }, + { + conditions: [ + { + condition: { type: 'ServicePoint', servicePointId: 'spId' }, + account: 'if-acct', + }, + ], + else: { account: 'else-sp' }, + }, + ], + ])('Converts %s into %s', (token, expected) => expect(transferToDto(token)).toEqual(expected)); +}); diff --git a/src/api/dto/to/transferToDto.ts b/src/api/dto/to/transferToDto.ts new file mode 100644 index 00000000..f087b514 --- /dev/null +++ b/src/api/dto/to/transferToDto.ts @@ -0,0 +1,15 @@ +import { FormValues } from '../../../types'; +import { BursarExportTransferCriteria } from '../dto-types'; +import criteriaToFilterDto from './criteriaToFilterDto'; + +export default function transferToDto( + transferInfo: FormValues['transferInfo'], +): BursarExportTransferCriteria { + return { + conditions: (transferInfo?.conditions ?? []).map(({ condition, account }) => ({ + condition: criteriaToFilterDto(condition), + account: account ?? '', + })), + else: { account: transferInfo?.else?.account ?? '' }, + }; +} diff --git a/src/api/mutators/index.ts b/src/api/mutators/index.ts new file mode 100644 index 00000000..8687518f --- /dev/null +++ b/src/api/mutators/index.ts @@ -0,0 +1,2 @@ +export { default as useAutomaticSchedulerMutation } from './useAutomaticSchedulerMutation'; +export { default as useManualSchedulerMutation } from './useManualSchedulerMutation'; diff --git a/src/api/mutators/useAutomaticSchedulerMutation.test.tsx b/src/api/mutators/useAutomaticSchedulerMutation.test.tsx new file mode 100644 index 00000000..95e75bdb --- /dev/null +++ b/src/api/mutators/useAutomaticSchedulerMutation.test.tsx @@ -0,0 +1,150 @@ +import React, { ReactNode, createContext } from 'react'; +import { waitFor, act, renderHook } from '@folio/jest-config-stripes/testing-library/react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { CalloutContext } from '@folio/stripes/core'; +import useAutomaticSchedulerMutation from './useAutomaticSchedulerMutation'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; + +const getMock = jest.fn(); +const postMock = jest.fn(); +const putMock = jest.fn(); + +jest.mock('@folio/stripes/core', () => ({ + CalloutContext: createContext(null), + useOkapiKy: () => ({ + get: getMock, + post: postMock, + put: putMock, + }), +})); + +describe('Automatic scheduling mutation', () => { + beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + putMock.mockReset(); + }); + + const contextMock = jest.fn(); + const wrapper = ({ children }: { children: ReactNode }) => withIntlConfiguration( + + {children} + , + ); + + it('handles successful responses', async () => { + const { result: mutator } = renderHook(() => useAutomaticSchedulerMutation(), { wrapper }); + + postMock.mockReturnValueOnce(Promise.resolve({})); + + act(() => { + mutator.current({ + bursar: 'bursar data', + scheduling: { schedulingData: 'is here!' }, + } as any); + }); + + await waitFor(() => expect(postMock).toHaveBeenLastCalledWith('data-export-spring/configs', { + json: { + type: 'BURSAR_FEES_FINES', + exportTypeSpecificParameters: { bursarFeeFines: 'bursar data' }, + schedulingData: 'is here!', + }, + })); + + await waitFor(() => expect(contextMock).toHaveBeenLastCalledWith({ + type: 'success', + message: 'Configuration saved', + })); + }); + + it('handles error responses', async () => { + const { result: mutator } = renderHook(() => useAutomaticSchedulerMutation(), { wrapper }); + + postMock.mockReturnValueOnce(Promise.reject(new Error())); + + act(() => { + mutator.current({ + bursar: 'bursar data that fails', + scheduling: { schedulingData: 'is here!' }, + } as any); + }); + + await waitFor(() => expect(postMock).toHaveBeenLastCalledWith('data-export-spring/configs', { + json: { + type: 'BURSAR_FEES_FINES', + exportTypeSpecificParameters: { + bursarFeeFines: 'bursar data that fails', + }, + schedulingData: 'is here!', + }, + })); + + await waitFor(() => expect(contextMock).toHaveBeenLastCalledWith({ + type: 'error', + message: 'Failed to save job', + })); + }); + + it('calls post when no initial config is available', async () => { + getMock.mockReturnValue({ + json: () => { + return Promise.resolve({ totalRecords: 0 }); + }, + }); + + const { result: mutator } = renderHook(() => useAutomaticSchedulerMutation(), { wrapper }); + + postMock.mockReturnValueOnce(Promise.resolve({})); + + // ensure query loads + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + act(() => { + mutator.current({ + bursar: 'bursar data', + scheduling: { schedulingData: 'is here!' }, + } as any); + }); + + await waitFor(() => expect(postMock).toHaveBeenCalled()); + expect(putMock).not.toHaveBeenCalled(); + + // check invalidation + await waitFor(() => expect(getMock).toHaveBeenCalledTimes(2)); + }); + + it('calls put when initial data is returned', async () => { + getMock.mockReturnValue({ + json: () => { + return Promise.resolve({ totalRecords: 1, configs: [{ id: 'foo' }] }); + }, + }); + + const { result: mutator } = renderHook(() => useAutomaticSchedulerMutation(), { wrapper }); + + // ensure query loads + postMock.mockReturnValueOnce(Promise.resolve({})); + + await waitFor(() => expect(getMock).toHaveBeenCalled()); + + act(() => { + mutator.current({ + bursar: 'bursar data', + scheduling: { schedulingData: 'is here!' }, + } as any); + }); + + waitFor(async () => expect(putMock).toHaveBeenCalledWith('data-export-spring/configs/foo', { + json: { + id: 'foo', + exportTypeSpecificParameters: { bursarFeeFines: 'bursar data' }, + schedulingData: 'is here!', + }, + })); + expect(postMock).not.toHaveBeenCalled(); + + // check invalidation + await waitFor(() => expect(getMock).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/api/mutators/useAutomaticSchedulerMutation.ts b/src/api/mutators/useAutomaticSchedulerMutation.ts new file mode 100644 index 00000000..c8720202 --- /dev/null +++ b/src/api/mutators/useAutomaticSchedulerMutation.ts @@ -0,0 +1,58 @@ +import { CalloutContext, useOkapiKy } from '@folio/stripes/core'; +import { useContext } from 'react'; +import { useMutation, useQueryClient } from 'react-query'; +import { useIntl } from 'react-intl'; +import { BursarExportJobDTO, SchedulingDTO } from '../dto/dto-types'; +import useCurrentConfig from '../queries/useCurrentConfig'; + +export default function useAutomaticSchedulerMutation() { + const ky = useOkapiKy(); + const queryClient = useQueryClient(); + const context = useContext(CalloutContext); + const intl = useIntl(); + + const currentConfig = useCurrentConfig(); + + const mutation = useMutation( + async (parameters: { bursar: BursarExportJobDTO; scheduling: SchedulingDTO }) => { + if (currentConfig.data) { + return ky.put(`data-export-spring/configs/${currentConfig.data.id}`, { + json: { + ...currentConfig.data, + exportTypeSpecificParameters: { bursarFeeFines: parameters.bursar }, + ...parameters.scheduling, + }, + }); + } else { + return ky.post('data-export-spring/configs', { + json: { + type: 'BURSAR_FEES_FINES', + exportTypeSpecificParameters: { bursarFeeFines: parameters.bursar }, + ...parameters.scheduling, + }, + }); + } + }, + { + onError: async () => { + context.sendCallout({ + type: 'error', + message: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.scheduler.mutation.automatic.error', + }), + }); + }, + onSuccess: async () => { + context.sendCallout({ + type: 'success', + message: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.scheduler.mutation.automatic.success', + }), + }); + await queryClient.invalidateQueries(['ui-plugin-bursar-export', 'current-config']); + }, + }, + ); + + return mutation.mutate; +} diff --git a/src/api/mutators/useManualSchedulerMutation.test.tsx b/src/api/mutators/useManualSchedulerMutation.test.tsx new file mode 100644 index 00000000..1392f6ab --- /dev/null +++ b/src/api/mutators/useManualSchedulerMutation.test.tsx @@ -0,0 +1,74 @@ +import React, { ReactNode, createContext } from 'react'; +import { waitFor, act, renderHook } from '@folio/jest-config-stripes/testing-library/react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { CalloutContext } from '@folio/stripes/core'; +import useManualSchedulerMutation from './useManualSchedulerMutation'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; + +const kyMock = jest.fn(); + +jest.mock('@folio/stripes/core', () => ({ + CalloutContext: createContext(null), + useOkapiKy: () => ({ + post: kyMock, + }), +})); + +describe('Automatic scheduling mutation', () => { + const contextMock = jest.fn(); + const wrapper = ({ children }: { children: ReactNode }) => withIntlConfiguration( + + {children} + , + ); + + it('handles successful responses', async () => { + const { result: mutator } = renderHook(() => useManualSchedulerMutation(), { + wrapper, + }); + + kyMock.mockReturnValueOnce(Promise.resolve({})); + + act(() => { + mutator.current('bursar data' as any); + }); + + await waitFor(() => expect(kyMock).toHaveBeenLastCalledWith('data-export-spring/jobs', { + json: { + type: 'BURSAR_FEES_FINES', + exportTypeSpecificParameters: { bursarFeeFines: 'bursar data' }, + }, + })); + + await waitFor(() => expect(contextMock).toHaveBeenLastCalledWith({ + type: 'success', + message: 'Job has been scheduled', + })); + }); + + it('handles error responses', async () => { + const { result: mutator } = renderHook(() => useManualSchedulerMutation(), { + wrapper, + }); + + kyMock.mockReturnValueOnce(Promise.reject(new Error())); + + act(() => { + mutator.current('bursar data that fails' as any); + }); + + await waitFor(() => expect(kyMock).toHaveBeenLastCalledWith('data-export-spring/jobs', { + json: { + type: 'BURSAR_FEES_FINES', + exportTypeSpecificParameters: { + bursarFeeFines: 'bursar data that fails', + }, + }, + })); + + await waitFor(() => expect(contextMock).toHaveBeenLastCalledWith({ + type: 'error', + message: 'Failed to start job', + })); + }); +}); diff --git a/src/api/mutators/useManualSchedulerMutation.ts b/src/api/mutators/useManualSchedulerMutation.ts new file mode 100644 index 00000000..832cb233 --- /dev/null +++ b/src/api/mutators/useManualSchedulerMutation.ts @@ -0,0 +1,36 @@ +import { CalloutContext, useOkapiKy } from '@folio/stripes/core'; +import { useContext } from 'react'; +import { useMutation } from 'react-query'; +import { useIntl } from 'react-intl'; +import { BursarExportJobDTO } from '../dto/dto-types'; + +export default function useManualSchedulerMutation() { + const ky = useOkapiKy(); + const context = useContext(CalloutContext); + const intl = useIntl(); + + const mutation = useMutation( + async (parameters: BursarExportJobDTO) => ky.post('data-export-spring/jobs', { + json: { + type: 'BURSAR_FEES_FINES', + exportTypeSpecificParameters: { bursarFeeFines: parameters }, + }, + }), + { + onError: () => context.sendCallout({ + type: 'error', + message: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.scheduler.mutation.manual.error', + }), + }), + onSuccess: () => context.sendCallout({ + type: 'success', + message: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.scheduler.mutation.manual.success', + }), + }), + }, + ); + + return mutation.mutate; +} diff --git a/src/api/queries/constants.ts b/src/api/queries/constants.ts new file mode 100644 index 00000000..6cecd91f --- /dev/null +++ b/src/api/queries/constants.ts @@ -0,0 +1,3 @@ +export const MAX_LIMIT = 2147483647; + +export default ''; diff --git a/src/api/queries/index.ts b/src/api/queries/index.ts new file mode 100644 index 00000000..73251347 --- /dev/null +++ b/src/api/queries/index.ts @@ -0,0 +1,10 @@ +export { default as useCampuses } from './useCampuses'; +export { default as useCurrentConfig } from './useCurrentConfig'; +export { default as useFeeFineOwners } from './useFeeFineOwners'; +export { default as useFeeFineTypes } from './useFeeFineTypes'; +export { default as useInstitutions } from './useInstitutions'; +export { default as useLibraries } from './useLibraries'; +export { default as useLocations } from './useLocations'; +export { default as usePatronGroups } from './usePatronGroups'; +export { default as useServicePoints } from './useServicePoints'; +export { default as useTransferAccounts } from './useTransferAccounts'; diff --git a/src/api/queries/queryTests.test.tsx b/src/api/queries/queryTests.test.tsx new file mode 100644 index 00000000..9580cc78 --- /dev/null +++ b/src/api/queries/queryTests.test.tsx @@ -0,0 +1,393 @@ +import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import React, { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import useLocations from './useLocations'; +import usePatronGroups from './usePatronGroups'; +import useServicePoints from './useServicePoints'; +import useFeeFineTypes from './useFeeFineTypes'; +import useFeeFineOwners from './useFeeFineOwners'; +import useTransferAccounts from './useTransferAccounts'; +import useInstitutions from './useInstitutions'; +import useCampuses from './useCampuses'; +import useLibraries from './useLibraries'; +import useCurrentConfig from './useCurrentConfig'; + +const responseMock = jest.fn(); +const kyMock = jest.fn(() => ({ + json: () => { + return Promise.resolve(responseMock()); + }, +})); + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: () => ({ + get: kyMock, + }), +})); + +describe('API Query Tests', () => { + const queryClient = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + beforeEach(() => queryClient.clear()); + + it('Patron groups query works as expected', async () => { + responseMock.mockResolvedValue({ + usergroups: [ + { id: '1', group: 'staff' }, + { id: '2', group: 'undergraduate' }, + ], + }); + + const { result } = renderHook(() => usePatronGroups(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('groups?limit=2147483647'); + expect(result.current.data).toStrictEqual([ + { id: '1', group: 'staff' }, + { id: '2', group: 'undergraduate' }, + ]); + }); + + it('Service points query works as expected', async () => { + responseMock.mockResolvedValue({ + servicepoints: [ + { id: '1', name: 'Circ desk 1' }, + { id: '2', name: 'Online' }, + ], + }); + + const { result } = renderHook(() => useServicePoints(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('service-points?cql.allRecords=1&limit=2147483647'); + expect(result.current.data).toStrictEqual([ + { id: '1', name: 'Circ desk 1' }, + { id: '2', name: 'Online' }, + ]); + }); + + it('Locations query works as expected', async () => { + responseMock.mockResolvedValue({ + locations: [ + { + id: '1', + name: 'Popular Reading Collection', + code: 'KU/CC/DI/P', + }, + { + id: '2', + name: 'Online', + code: 'E', + }, + { + id: '3', + name: 'SECOND FLOOR', + code: 'KU/CC/DI/2', + }, + { + id: '4', + name: 'Annex', + code: 'KU/CC/DI/A', + }, + ], + }); + + const { result } = renderHook(() => useLocations(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('locations?cql.allRecords=1&limit=2147483647'); + expect(result.current.data).toStrictEqual([ + { + id: '1', + name: 'Popular Reading Collection', + code: 'KU/CC/DI/P', + }, + { + id: '2', + name: 'Online', + code: 'E', + }, + { + id: '3', + name: 'SECOND FLOOR', + code: 'KU/CC/DI/2', + }, + { + id: '4', + name: 'Annex', + code: 'KU/CC/DI/A', + }, + ]); + }); + + it('Fee fine owners query works as expected', async () => { + responseMock.mockResolvedValue({ + owners: [ + { + id: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + owner: 'Owner 1', + }, + { + id: '3da4b49d-ee7a-41fc-bf53-10f626180f7f', + owner: 'Owner 2', + }, + { + id: '4ae65faa-8df7-4fbc-a4db-ccdcc1479b10', + owner: 'Shared', + servicePointOwner: [], + }, + ], + }); + + const { result } = renderHook(() => useFeeFineOwners(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('owners?cql.allRecords=1&limit=2147483647'); + expect(result.current.data).toStrictEqual([ + { + id: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + owner: 'Owner 1', + }, + { + id: '3da4b49d-ee7a-41fc-bf53-10f626180f7f', + owner: 'Owner 2', + }, + { + id: '4ae65faa-8df7-4fbc-a4db-ccdcc1479b10', + owner: 'Shared', + servicePointOwner: [], + }, + ]); + }); + + it('Fee fine types query works as expected', async () => { + responseMock.mockResolvedValue({ + feefines: [ + { + id: '9523cb96-e752-40c2-89da-60f3961a488d', + feeFineType: 'Overdue fine', + automatic: true, + }, + { + id: 'cf238f9f-7018-47b7-b815-bb2db798e19f', + feeFineType: 'Lost item fee', + automatic: true, + }, + { + id: '49223209-4d06-4bb5-8b7e-d0b7e5b8fb10', + ownerId: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + feeFineType: 'Type 1', + automatic: false, + }, + ], + }); + + const { result } = renderHook(() => useFeeFineTypes(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('feefines?cql.allRecords=1&limit=2147483647'); + expect(result.current.data).toStrictEqual([ + { + id: '9523cb96-e752-40c2-89da-60f3961a488d', + feeFineType: 'Overdue fine', + automatic: true, + }, + { + id: 'cf238f9f-7018-47b7-b815-bb2db798e19f', + feeFineType: 'Lost item fee', + automatic: true, + }, + { + id: '49223209-4d06-4bb5-8b7e-d0b7e5b8fb10', + ownerId: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + feeFineType: 'Type 1', + automatic: false, + }, + ]); + }); + + it('Fee fine transfer accounts query works as expected', async () => { + responseMock.mockResolvedValue({ + transfers: [ + { + accountName: 'test account 1', + ownerId: 'b25fd8e7-a0e7-4690-ab0b-94039739c0db', + id: '90c1820f-60bf-4b9a-99f5-d677ea78ddca', + }, + { + accountName: 'test account 2', + ownerId: '60f7a273-1454-4a5d-b379-1f323a74e3f1', + id: 'bb58346b-4025-4236-a0a6-5476eb972066', + }, + ], + }); + + const { result } = renderHook(() => useTransferAccounts(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('transfers?cql.allRecords=1&limit=2147483647'); + expect(result.current.data).toStrictEqual([ + { + accountName: 'test account 1', + ownerId: 'b25fd8e7-a0e7-4690-ab0b-94039739c0db', + id: '90c1820f-60bf-4b9a-99f5-d677ea78ddca', + }, + { + accountName: 'test account 2', + ownerId: '60f7a273-1454-4a5d-b379-1f323a74e3f1', + id: 'bb58346b-4025-4236-a0a6-5476eb972066', + }, + ]); + }); + + it('Institutions query works as expected', async () => { + responseMock.mockResolvedValue({ + locinsts: [ + { + id: '40ee00ca-a518-4b49-be01-0638d0a4ac57', + name: 'Københavns Universitet', + code: 'KU', + }, + ], + }); + + const { result } = renderHook(() => useInstitutions(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('location-units/institutions?cql.allRecords=1&limit=2147483647'); + expect(result.current.data).toStrictEqual([ + { + id: '40ee00ca-a518-4b49-be01-0638d0a4ac57', + name: 'Københavns Universitet', + code: 'KU', + }, + ]); + }); + + it('Campuses query works as expected', async () => { + responseMock.mockResolvedValue({ + loccamps: [ + { + id: '62cf76b7-cca5-4d33-9217-edf42ce1a848', + name: 'City Campus', + code: 'CC', + institutionId: '40ee00ca-a518-4b49-be01-0638d0a4ac57', + }, + { + id: '470ff1dd-937a-4195-bf9e-06bcfcd135df', + name: 'Online', + code: 'E', + institutionId: '40ee00ca-a518-4b49-be01-0638d0a4ac57', + }, + ], + }); + + const { result } = renderHook(() => useCampuses(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('location-units/campuses?cql.allRecords=1&limit=2147483647'); + expect(result.current.data).toStrictEqual([ + { + id: '62cf76b7-cca5-4d33-9217-edf42ce1a848', + name: 'City Campus', + code: 'CC', + institutionId: '40ee00ca-a518-4b49-be01-0638d0a4ac57', + }, + { + id: '470ff1dd-937a-4195-bf9e-06bcfcd135df', + name: 'Online', + code: 'E', + institutionId: '40ee00ca-a518-4b49-be01-0638d0a4ac57', + }, + ]); + }); + + it('Libraries query works as expected', async () => { + responseMock.mockResolvedValue({ + loclibs: [ + { + id: '5d78803e-ca04-4b4a-aeae-2c63b924518b', + name: 'Datalogisk Institut', + code: 'DI', + campusId: '62cf76b7-cca5-4d33-9217-edf42ce1a848', + }, + { + id: 'c2549bb4-19c7-4fcc-8b52-39e612fb7dbe', + name: 'Online', + code: 'E', + campusId: '470ff1dd-937a-4195-bf9e-06bcfcd135df', + }, + ], + }); + + const { result } = renderHook(() => useLibraries(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('location-units/libraries?cql.allRecords=1&limit=2147483647'); + expect(result.current.data).toStrictEqual([ + { + id: '5d78803e-ca04-4b4a-aeae-2c63b924518b', + name: 'Datalogisk Institut', + code: 'DI', + campusId: '62cf76b7-cca5-4d33-9217-edf42ce1a848', + }, + { + id: 'c2549bb4-19c7-4fcc-8b52-39e612fb7dbe', + name: 'Online', + code: 'E', + campusId: '470ff1dd-937a-4195-bf9e-06bcfcd135df', + }, + ]); + }); + + it.each([ + [undefined, null], + [[], null], + [[{ id: 'foo' }], { id: 'foo' }], + ])('Current config query with response %s gives %s', async (configs, expected) => { + responseMock.mockResolvedValue({ + configs, + }); + + const { result } = renderHook(() => useCurrentConfig(), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(kyMock).toHaveBeenCalledWith('data-export-spring/configs', { + searchParams: { limit: 1, query: 'type==BURSAR_FEES_FINES' }, + }); + expect(result.current.data).toStrictEqual(expected); + }); +}); diff --git a/src/api/queries/useCampuses.ts b/src/api/queries/useCampuses.ts new file mode 100644 index 00000000..79380783 --- /dev/null +++ b/src/api/queries/useCampuses.ts @@ -0,0 +1,38 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { MAX_LIMIT } from './constants'; + +/** + * Returned as part of GET /location-units/campuses v2.0 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/locationunit.html#location_units_campuses_get + */ +export interface CampusesResponse { + loccamps: CampusDTO[]; + totalRecords: number; +} + +/** + * Returned as part of GET /location-units/campuses v2.0 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/locationunit.html#location_units_campuses_get + */ +export interface CampusDTO { + /** Unique UUID for this campus */ + id: string; + /** Human-displayable name for this campus */ + name: string; + code: string; + /** the associated institution */ + institutionId: string; + + // don't care about these + metadata?: unknown; +} + +export default function useCampuses() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'campuses'], + async () => (await ky.get(`location-units/campuses?cql.allRecords=1&limit=${MAX_LIMIT}`).json()).loccamps, + ); +} diff --git a/src/api/queries/useCurrentConfig.ts b/src/api/queries/useCurrentConfig.ts new file mode 100644 index 00000000..326471e9 --- /dev/null +++ b/src/api/queries/useCurrentConfig.ts @@ -0,0 +1,23 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { SavedJobConfiguration } from '../dto/dto-types'; + +export interface CurrentConfigResponse { + totalRecords: number; + configs?: SavedJobConfiguration[]; +} + +export default function useCurrentConfig() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'current-config'], + async () => ( + await ky + .get('data-export-spring/configs', { + searchParams: { limit: 1, query: 'type==BURSAR_FEES_FINES' }, + }) + .json() + ).configs?.[0] ?? null, + ); +} diff --git a/src/api/queries/useFeeFineOwners.ts b/src/api/queries/useFeeFineOwners.ts new file mode 100644 index 00000000..a9855b85 --- /dev/null +++ b/src/api/queries/useFeeFineOwners.ts @@ -0,0 +1,45 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { MAX_LIMIT } from './constants'; + +/** + * Returned as part of GET /owners v1 + * @see https://s3.amazonaws.com/foliodocs/api/mod-feesfines/r/owners.html + */ +export interface FeeFineOwnerResponse { + owners: FeeFineOwnerDTO[]; + totalRecords: number; +} + +/** + * Returned as part of GET /owners v1 + * @see https://s3.amazonaws.com/foliodocs/api/mod-feesfines/r/owners.html + */ +export interface FeeFineOwnerDTO { + /** Owner's UUID */ + id: string; + /** Owner's name (human readable) */ + owner: string; + desc: string; + /** Service points associated to with the owner */ + servicePointOwner: { + /** service point ID */ + value: string; + /** service point label */ + label: string; + }[]; + + // don't care about these + defaultChargeNoticeId: string; + defaultActionNoticeId: string; + metadata: unknown; +} + +export default function useFeeFineOwners() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'owners'], + async () => (await ky.get(`owners?cql.allRecords=1&limit=${MAX_LIMIT}`).json()).owners, + ); +} diff --git a/src/api/queries/useFeeFineTypes.ts b/src/api/queries/useFeeFineTypes.ts new file mode 100644 index 00000000..cb3a531a --- /dev/null +++ b/src/api/queries/useFeeFineTypes.ts @@ -0,0 +1,40 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { MAX_LIMIT } from './constants'; + +/** + * Returned as part of GET /feefines v1 + * @see https://s3.amazonaws.com/foliodocs/api/mod-feesfines/r/feefines.html + */ +export interface FeeFineTypeResponse { + feefines: FeeFineTypeDTO[]; + totalRecords: number; +} + +/** + * Returned as part of GET /feefines v1 + * @see https://s3.amazonaws.com/foliodocs/api/mod-feesfines/r/feefines.html + */ +export interface FeeFineTypeDTO { + /** Type UUID */ + id: string; + /** Type name */ + feeFineType: string; + automatic: boolean; + ownerId?: string; + + // don't care about these + defaultAmount?: number; + chargeNoticeId?: string; + actionNoticeId?: string; + metadata: unknown; +} + +export default function useFeeFineTypes() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'types'], + async () => (await ky.get(`feefines?cql.allRecords=1&limit=${MAX_LIMIT}`).json()).feefines, + ); +} diff --git a/src/api/queries/useInstitutions.ts b/src/api/queries/useInstitutions.ts new file mode 100644 index 00000000..05e8a00f --- /dev/null +++ b/src/api/queries/useInstitutions.ts @@ -0,0 +1,37 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { MAX_LIMIT } from './constants'; + +/** + * Returned as part of GET /location-units/institutions v2.0 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/locationunit.html#location_units_institutions_get + */ +export interface InstitutionsResponse { + locinsts: InstitutionDTO[]; + totalRecords: number; +} + +/** + * Returned as part of GET /location-units/institutions v2.0 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/locationunit.html#location_units_institutions_get + */ +export interface InstitutionDTO { + /** Unique UUID for this institution */ + id: string; + /** Human-displayable name for this institution */ + name: string; + code: string; + + // don't care about these + metadata?: unknown; +} + +export default function useInstitutions() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'institutions'], + async () => (await ky.get(`location-units/institutions?cql.allRecords=1&limit=${MAX_LIMIT}`).json()) + .locinsts, + ); +} diff --git a/src/api/queries/useLibraries.ts b/src/api/queries/useLibraries.ts new file mode 100644 index 00000000..a3a30acc --- /dev/null +++ b/src/api/queries/useLibraries.ts @@ -0,0 +1,38 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { MAX_LIMIT } from './constants'; + +/** + * Returned as part of GET /location-units/libraries v2.0 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/locationunit.html#location_units_libraries_get + */ +export interface LibrariesResponse { + loclibs: LibraryDTO[]; + totalRecords: number; +} + +/** + * Returned as part of GET /location-units/libraries v2.0 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/locationunit.html#location_units_libraries_get + */ +export interface LibraryDTO { + /** Unique UUID for this library */ + id: string; + /** Human-displayable name for this library */ + name: string; + code: string; + /** the associated campus */ + campusId: string; + + // don't care about these + metadata?: unknown; +} + +export default function useLibraries() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'libraries'], + async () => (await ky.get(`location-units/libraries?cql.allRecords=1&limit=${MAX_LIMIT}`).json()).loclibs, + ); +} diff --git a/src/api/queries/useLocations.ts b/src/api/queries/useLocations.ts new file mode 100644 index 00000000..314d14f0 --- /dev/null +++ b/src/api/queries/useLocations.ts @@ -0,0 +1,51 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { MAX_LIMIT } from './constants'; + +/** + * Returned as part of GET /locations v3.0 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/location.html#locations_get + */ +export interface LocationsResponse { + locations: LocationDTO[]; + totalRecords: number; +} + +/** + * Returned as part of GET /locations v3.0 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/location.html#locations_get + */ +export interface LocationDTO { + /** Unique UUID for this location */ + id: string; + /** Human-displayable name for this location */ + name: string; + code: string; + + institutionId: string; + campusId: string; + libraryId: string; + primaryServicePoint: string; + + // don't care about these + description?: string; + discoveryDisplayName?: string; + isActive?: boolean; + institution?: unknown; + campus?: unknown; + library?: unknown; + details?: unknown; + primaryServicePointObject?: unknown; + servicePointIds?: string[]; + servicePoints?: unknown[]; + metadata?: unknown; +} + +export default function useLocations() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'locations'], + async () => (await ky.get(`locations?cql.allRecords=1&limit=${MAX_LIMIT}`).json()).locations, + ); +} diff --git a/src/api/queries/usePatronGroups.ts b/src/api/queries/usePatronGroups.ts new file mode 100644 index 00000000..854b36e7 --- /dev/null +++ b/src/api/queries/usePatronGroups.ts @@ -0,0 +1,38 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { MAX_LIMIT } from './constants'; + +/** + * Returned from GET /groups v15.3 + * @see https://s3.amazonaws.com/foliodocs/api/mod-users/r/groups.html#groups_get + */ +export interface PatronGroupResponse { + totalRecords: number; + usergroups: PatronGroupDTO[]; +} + +/** + * Returned as part of GET /groups v15.3 + * @see https://s3.amazonaws.com/foliodocs/api/mod-users/r/groups.html#groups_get + */ +export interface PatronGroupDTO { + /** Unique UUID for this group */ + id: string; + /** Unique, human-readable name of this group (e.g. "staff" or "undergraduate") */ + group: string; + /** A description of the group's members */ + desc?: string; + + // don't care about these + expirationOffsetInDays?: number; + metadata?: unknown; +} + +export default function usePatronGroups() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'patron-groups'], + async () => (await ky.get(`groups?limit=${MAX_LIMIT}`).json()).usergroups, + ); +} diff --git a/src/api/queries/useServicePoints.ts b/src/api/queries/useServicePoints.ts new file mode 100644 index 00000000..5144359a --- /dev/null +++ b/src/api/queries/useServicePoints.ts @@ -0,0 +1,38 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { MAX_LIMIT } from './constants'; + +/** + * Returned as part of GET /service-points v3.3 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/service-point.html + */ +export interface ServicePointResponse { + servicepoints: ServicePointDTO[]; + totalRecords: number; +} + +/** + * Returned as part of GET /service-points v3.3 + * @see https://s3.amazonaws.com/foliodocs/api/mod-inventory-storage/r/service-point.html + */ +export interface ServicePointDTO { + /** Unique UUID for this service point */ + id: string; + /** Human-displayable name for this service point */ + name: string; + code: string; + discoveryDisplayName: string; + + // don't care about these + staffSlips: unknown[]; + metadata: unknown; +} + +export default function useServicePoints() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'service-points'], + async () => (await ky.get(`service-points?cql.allRecords=1&limit=${MAX_LIMIT}`).json()).servicepoints, + ); +} diff --git a/src/api/queries/useTransferAccounts.ts b/src/api/queries/useTransferAccounts.ts new file mode 100644 index 00000000..b6b5be5c --- /dev/null +++ b/src/api/queries/useTransferAccounts.ts @@ -0,0 +1,38 @@ +import { useOkapiKy } from '@folio/stripes/core'; +import { useQuery } from 'react-query'; +import { MAX_LIMIT } from './constants'; + +/** + * Returned as part of GET /transfers v1 + * @see https://s3.amazonaws.com/foliodocs/api/mod-feesfines/r/transfers.html + */ +export interface TransferAccountResponse { + transfers: TransferAccountDTO[]; + totalRecords: number; +} + +/** + * Returned as part of GET /transfers v1 + * @see https://s3.amazonaws.com/foliodocs/api/mod-feesfines/r/transfers.html + */ +export interface TransferAccountDTO { + /** Account UUID */ + id: string; + /** Transfer account's owner's UUID */ + ownerId: string; + /** Account name */ + accountName: string; + desc: string; + + // don't care about these + metadata: unknown; +} + +export default function useTransferAccounts() { + const ky = useOkapiKy(); + + return useQuery( + ['ui-plugin-bursar-export', 'trasfer-accounts'], + async () => (await ky.get(`transfers?cql.allRecords=1&limit=${MAX_LIMIT}`).json()).transfers, + ); +} diff --git a/src/apiQuery.js b/src/apiQuery.js deleted file mode 100644 index 70925e98..00000000 --- a/src/apiQuery.js +++ /dev/null @@ -1,124 +0,0 @@ -import { - useMutation, - useQuery, - useQueryClient, -} from 'react-query'; - -import { useOkapiKy } from '@folio/stripes/core'; -import { LIMIT_MAX } from '@folio/stripes-acq-components'; - -import { SCHEDULE_PERIODS } from './BursarExportsConfiguration'; - -const bursarConfigApi = 'data-export-spring/configs'; -const bursarConfigKey = ['ui-plugin-bursar-export', 'bursarConfig']; -const bursarType = 'BURSAR_FEES_FINES'; - -export const useBursarConfigQuery = (key = bursarConfigKey) => { - const ky = useOkapiKy(); - - const { isFetching, data = {} } = useQuery({ - queryKey: key, - queryFn: async () => { - const kyOptions = { - searchParams: { - limit: 1, - query: `type==${bursarType}`, - }, - }; - const { configs = [] } = await ky.get(bursarConfigApi, kyOptions).json(); - - return configs[0] || { - type: bursarType, - schedulePeriod: SCHEDULE_PERIODS.none, - }; - }, - }); - - return { - isLoading: isFetching, - bursarConfig: { - ...data, - weekDays: data.weekDays?.reduce((acc, weekDay) => ({ - ...acc, - [weekDay.toLowerCase()]: true, - }), {}), - }, - }; -}; - -export const useBursarConfigMutation = (options = {}, key = bursarConfigKey) => { - const ky = useOkapiKy(); - const queryClient = useQueryClient(); - - const { mutateAsync } = useMutation({ - mutationFn: (bursarConfig) => { - const { weekDays = {} } = bursarConfig; - - const json = { - ...bursarConfig, - weekDays: Object.keys(weekDays) - .filter(weekDay => weekDays[weekDay]) - .map(weekDay => weekDay.toUpperCase()), - }; - - const kyMethod = bursarConfig.id ? 'put' : 'post'; - const kyPath = bursarConfig.id ? `${bursarConfigApi}/${bursarConfig.id}` : bursarConfigApi; - - return ky[kyMethod](kyPath, { json }); - }, - ...options, - onSuccess: () => { - queryClient.invalidateQueries(key); - - if (options.onSuccess) options.onSuccess(); - }, - }); - - return { - mutateBursarConfig: mutateAsync, - }; -}; - -export const usePatronGroupsQuery = () => { - const ky = useOkapiKy(); - - const { isLoading, data = [] } = useQuery({ - queryKey: 'bursarPatronGroups', - queryFn: async () => { - const kyOptions = { - searchParams: { - limit: LIMIT_MAX, - }, - }; - const { usergroups = [] } = await ky.get('groups', kyOptions).json(); - - return usergroups; - }, - }); - - return { - isLoading, - patronGroups: data, - }; -}; - -export const useBursarExportScheduler = (options = {}) => { - const ky = useOkapiKy(); - - const { isLoading, mutateAsync } = useMutation({ - mutationFn: (exportTypeSpecificParameters) => { - const json = { - type: bursarType, - exportTypeSpecificParameters, - }; - - return ky.post('data-export-spring/jobs', { json }); - }, - ...options, - }); - - return { - isLoading, - scheduleBursarExport: mutateAsync, - }; -}; diff --git a/src/apiQuery.test.js b/src/apiQuery.test.js deleted file mode 100644 index bab4b6c7..00000000 --- a/src/apiQuery.test.js +++ /dev/null @@ -1,155 +0,0 @@ -import React from 'react'; -import { QueryClient, QueryClientProvider } from 'react-query'; - -import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; - -import { useOkapiKy } from '@folio/stripes/core'; - -import { - useBursarConfigQuery, - useBursarConfigMutation, - useBursarExportScheduler, - usePatronGroupsQuery, -} from './apiQuery'; -import { - SCHEDULE_PERIODS, - WEEKDAYS, -} from './BursarExportsConfiguration'; - -const queryClient = new QueryClient(); - -// eslint-disable-next-line react/prop-types -const wrapper = ({ children }) => ( - - {children} - -); - -describe('Bursar configuration api queries', () => { - describe('useBursarConfigQuery', () => { - it('should return None schedule when config is not set up', async () => { - useOkapiKy.mockClear().mockReturnValue({ - get: () => ({ - json: () => ({ - isLoading: false, - records: [], - }), - }), - }); - - const { result } = renderHook(() => useBursarConfigQuery(), { wrapper }); - - await waitFor(() => { - return expect(result.current.isLoading).toBeFalsy(); - }); - - expect(result.current.bursarConfig.schedulePeriod).toBe(SCHEDULE_PERIODS.none); - }); - - it('should convert weekDays arrays to object', async () => { - useOkapiKy.mockClear().mockReturnValue({ - get: () => ({ - json: () => ({ - isLoading: false, - configs: [{ weekDays: [WEEKDAYS[0]] }], - }), - }), - }); - - const { result } = renderHook(() => useBursarConfigQuery(), { wrapper }); - - await waitFor(() => { - return expect(result.current.bursarConfig.weekDays).toBeTruthy(); - }); - - expect(result.current.bursarConfig.weekDays[WEEKDAYS[0].toLowerCase()]).toBeTruthy(); - }); - }); - - describe('useBursarConfigMutation', () => { - it('should make post request when id is not provided', async () => { - const postMock = jest.fn(); - - useOkapiKy.mockClear().mockReturnValue({ - post: postMock, - }); - - const { result } = renderHook( - () => useBursarConfigMutation(), - { wrapper }, - ); - - await result.current.mutateBursarConfig({ - schedulePeriod: SCHEDULE_PERIODS.none, - exportTypeSpecificParameters: { - bursarFeeFines: {}, - }, - }); - - expect(postMock).toHaveBeenCalled(); - }); - - it('should make put request when id is provided', async () => { - const putMock = jest.fn(); - - useOkapiKy.mockClear().mockReturnValue({ - put: putMock, - }); - - const { result } = renderHook( - () => useBursarConfigMutation(), - { wrapper }, - ); - - await result.current.mutateBursarConfig({ - id: 1, - schedulePeriod: SCHEDULE_PERIODS.none, - weekDays: { [WEEKDAYS[0]]: true, [WEEKDAYS[1]]: false }, - exportTypeSpecificParameters: { - bursarFeeFines: {}, - }, - }); - - expect(putMock).toHaveBeenCalled(); - }); - }); - - describe('usePatronGroupsQuery', () => { - it('should fetch patron groups', async () => { - useOkapiKy.mockClear().mockReturnValue({ - get: () => ({ - json: () => ({ - usergroups: [{ group: 'graduated' }], - }), - }), - }); - - const { result } = renderHook(() => usePatronGroupsQuery(), { wrapper }); - - await waitFor(() => { - return Boolean(result.current.patronGroups.length); - }); - - expect(result.current.patronGroups.length > 0).toBeTruthy(); - }); - }); - - describe('useBursarExportScheduler', () => { - it('should make post request', async () => { - const postMock = jest.fn(); - - useOkapiKy.mockClear().mockReturnValue({ - post: postMock, - }); - - const { result } = renderHook( - () => useBursarExportScheduler(), - { wrapper }, - ); - - await result.current.scheduleBursarExport(); - - expect(postMock).toHaveBeenCalled(); - }); - }); -}); diff --git a/src/components/Card.module.css b/src/components/Card.module.css new file mode 100644 index 00000000..d19c5f72 --- /dev/null +++ b/src/components/Card.module.css @@ -0,0 +1,21 @@ +.cardClass:last-child, +.cardWithLengthControl { + margin-bottom: 0; +} + +.headerClass { + padding: 5px; + justify-content: space-between; +} + +.headerClass span { + flex: initial; +} + +.emptyBody { + padding: 0; +} + +.aggregateCardP { + margin-bottom: 0; +} diff --git a/src/components/ConditionalCard.test.tsx b/src/components/ConditionalCard.test.tsx new file mode 100644 index 00000000..05ccd18e --- /dev/null +++ b/src/components/ConditionalCard.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../test/util/withIntlConfiguration'; +import { ComparisonOperator, CriteriaTerminalType, DataTokenType } from '../types'; +import DataTokenCardBody from './Token/Data/DataTokenCardBody'; + +describe('Conditional card (via constant conditional)', () => { + describe('buttons work as expected', () => { + const submitter = jest.fn(); + + beforeEach(() => { + render( + withIntlConfiguration( + submitter(v)} + initialValues={{ + test: { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: [ + { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '10', + value: 'if value 1', + }, + { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '20', + value: 'if value 2', + }, + ], + else: 'fallback else', + }, + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + }); + + it('delete works as expected', async () => { + await userEvent.click(screen.getAllByRole('button', { name: 'trash' })[2]); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: [ + { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '10', + value: 'if value 1', + }, + ], + else: 'fallback else', + }, + }); + }); + + it('reorder up works as expected', async () => { + await userEvent.click(screen.getAllByRole('button', { name: 'caret-up' })[1]); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: [ + { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '20', + value: 'if value 2', + }, + { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '10', + value: 'if value 1', + }, + ], + else: 'fallback else', + }, + }); + }); + + it('reorder down works as expected', async () => { + await userEvent.click(screen.getAllByRole('button', { name: 'caret-down' })[0]); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: [ + { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '20', + value: 'if value 2', + }, + { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '10', + value: 'if value 1', + }, + ], + else: 'fallback else', + }, + }); + }); + }); +}); diff --git a/src/components/ConditionalCard.tsx b/src/components/ConditionalCard.tsx new file mode 100644 index 00000000..e73bec1e --- /dev/null +++ b/src/components/ConditionalCard.tsx @@ -0,0 +1,49 @@ +import { Card, IconButton } from '@folio/stripes/components'; +import React, { ReactNode, useCallback } from 'react'; +import { useFieldArray } from 'react-final-form-arrays'; +import { FormattedMessage } from 'react-intl'; +import { CriteriaCard } from './Criteria'; + +export default function ConditionalCard({ + children, + conditionName, + fieldArrayName, + patronOnly = false, + index, +}: Readonly<{ + children: ReactNode; + conditionName: string; + fieldArrayName: string; + patronOnly?: boolean; + index: number; +}>) { + const { fields } = useFieldArray(fieldArrayName); + + const handleMoveUpClick = useCallback(() => { + fields.swap(index, index - 1); + }, [fields, index]); + + const handleMoveDownClick = useCallback(() => { + fields.swap(index, index + 1); + }, [fields, index]); + + const handleRemoveClick = useCallback(() => { + fields.remove(index); + }, [fields, index]); + + return ( + } + headerEnd={ + <> + + + + + } + > + + {children} + + ); +} diff --git a/src/components/ConfigurationForm.test.tsx b/src/components/ConfigurationForm.test.tsx new file mode 100644 index 00000000..042478c8 --- /dev/null +++ b/src/components/ConfigurationForm.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form, FormProps } from 'react-final-form'; +import withIntlConfiguration from '../../test/util/withIntlConfiguration'; +import ConfigurationForm from './ConfigurationForm'; + +jest.mock('@folio/stripes/final-form', () => ({ + __esModule: true, + default: () => (Component: any) => (props: FormProps) => ( +
+ {(formProps) => } + + ), +})); + +jest.mock('../api/queries', () => ({ + useFeeFineOwners: () => ({ data: [], isSuccess: true }), + useTransferAccounts: () => ({ data: [], isSuccess: true }), +})); + +describe('Configuration form section', () => { + it('renders the configuration form', () => { + render( + withIntlConfiguration( + , + ), + ); + + expect(screen.getByText('Account data format')).toBeVisible(); + }); + + it('renders the configuration form with aggregate initial true', () => { + render( + withIntlConfiguration( + , + ), + ); + + expect(screen.getByText('Patron data format')).toBeVisible(); + }); +}); diff --git a/src/components/ConfigurationForm.tsx b/src/components/ConfigurationForm.tsx new file mode 100644 index 00000000..ecd9a225 --- /dev/null +++ b/src/components/ConfigurationForm.tsx @@ -0,0 +1,89 @@ +import { Accordion, AccordionSet, Col, ExpandAllButton, Row } from '@folio/stripes/components'; +import stripesFinalForm from '@folio/stripes/final-form'; +import { FormApi } from 'final-form'; +import React, { FormEvent, MutableRefObject, useCallback } from 'react'; +import { FormRenderProps, useField } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import { FORM_ID } from '../constants'; +import { FormValues } from '../types'; +import { + AggregateSection, + CriteriaSection, + DataTokenSection, + ExportPreviewSection, + HeaderFooterSection, + SchedulingSection, + TransferInfoSection +} from './FormSection'; + +interface ConfigurationFormSectionProps { + formApiRef: MutableRefObject | null>; +} + +function ConfigurationFormSection({ handleSubmit, formApiRef, form }: FormRenderProps & ConfigurationFormSectionProps) { + formApiRef.current = form; + + const submitter = useCallback( + (e: FormEvent) => { + handleSubmit(e)?.catch(() => { + throw new Error(); + }); + }, + [handleSubmit], + ); + + const aggregateEnabled = useField('aggregate', { + subscription: { value: true }, + format: (value) => value ?? false, + }).input.value; + + return ( +
+ + + + + + + }> + + + }> + + + }> + + + }> + + + + ) : ( + + ) + } + > + + + }> + + + + }> + + + + }> + + + +
+ ); +} + +export default stripesFinalForm({ + validateOnBlur: false, +})(ConfigurationFormSection); diff --git a/src/components/Criteria/AggregateCriteriaCard.test.tsx b/src/components/Criteria/AggregateCriteriaCard.test.tsx new file mode 100644 index 00000000..e4547020 --- /dev/null +++ b/src/components/Criteria/AggregateCriteriaCard.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { CriteriaAggregateType } from '../../types'; +import AggregateCriteriaCard from './AggregateCriteriaCard'; + +describe('Aggregate criteria card', () => { + const submitter = jest.fn(); + + beforeEach(() => { + render( + withIntlConfiguration( +
submitter(v)}> + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + }); + + it('Treats pass as default', async () => { + expect(screen.getByRole('combobox', { name: 'Filter type' })).toHaveValue(CriteriaAggregateType.PASS); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + aggregateFilter: { + type: CriteriaAggregateType.PASS, + }, + }); + }); + + it('Pass has no extra boxes/options', async () => { + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Filter type' }), CriteriaAggregateType.PASS); + + expect(screen.queryByRole('combobox', { name: 'Comparison operator' })).toBeNull(); + expect(screen.queryByRole('spinbutton')).toBeNull(); + expect(screen.queryByRole('textbox')).toBeNull(); + }); + + it('Quantity has operator and dollar amount', async () => { + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Filter type' }), + CriteriaAggregateType.NUM_ROWS, + ); + + // find to allow for useField hook to update + expect(await screen.findByRole('combobox', { name: 'Comparison operator' })).toBeVisible(); + expect(screen.getByRole('spinbutton')).toBeVisible(); + + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Comparison operator' }), + 'Less than but not equal to', + ); + await userEvent.type(screen.getByRole('spinbutton'), '15'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + aggregateFilter: { + type: CriteriaAggregateType.NUM_ROWS, + operator: 'LESS_THAN', + amount: '15', + }, + }); + }); + + it('Amount has operator and dollar amount', async () => { + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Filter type' }), + CriteriaAggregateType.TOTAL_AMOUNT, + ); + + // find to allow for useField hook to update + expect(await screen.findByRole('combobox', { name: 'Comparison operator' })).toBeVisible(); + expect(screen.getByRole('spinbutton')).toBeVisible(); + + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Comparison operator' }), + 'Greater than or equal to', + ); + await userEvent.type(screen.getByRole('spinbutton'), '10'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + aggregateFilter: { + type: CriteriaAggregateType.TOTAL_AMOUNT, + operator: 'GREATER_THAN_EQUAL', + amountCurrency: '10', + }, + }); + }); +}); diff --git a/src/components/Criteria/AggregateCriteriaCard.tsx b/src/components/Criteria/AggregateCriteriaCard.tsx new file mode 100644 index 00000000..f29772d8 --- /dev/null +++ b/src/components/Criteria/AggregateCriteriaCard.tsx @@ -0,0 +1,117 @@ +import { Card, Col, Row, Select, TextField } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field, useField } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { CriteriaAggregateType } from '../../types'; +import css from '../Card.module.css'; +import OperatorSelect from './OperatorSelect'; + +export default function AggregateCriteriaCard() { + const selectedType = useField('aggregateFilter.type', { + subscription: { value: true }, + format: (value) => value ?? CriteriaAggregateType.PASS, + }).input.value; + + const intl = useIntl(); + + const criteriaOptions = useMemo( + () => [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.aggregate.filter.none', + }), + value: CriteriaAggregateType.PASS, + }, + ...[ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.aggregate.filter.numAccounts', + }), + value: CriteriaAggregateType.NUM_ROWS, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.aggregate.filter.totalAmount', + }), + value: CriteriaAggregateType.TOTAL_AMOUNT, + }, + ].sort((a, b) => a.label.localeCompare(b.label)), + ], + [intl], + ); + + return ( + }> + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={criteriaOptions} + /> + )} + + + + {selectedType !== CriteriaAggregateType.PASS && ( + + + + )} + + {selectedType === CriteriaAggregateType.NUM_ROWS && ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + type="number" + label={ + + } + min={1} + step={1} + /> + )} + + + )} + + {selectedType === CriteriaAggregateType.TOTAL_AMOUNT && ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + type="number" + label={ + + } + min={0} + step={0.01} + /> + )} + + + )} + + +

+ + + +

+
+ ); +} diff --git a/src/components/Criteria/CriteriaAge.test.tsx b/src/components/Criteria/CriteriaAge.test.tsx new file mode 100644 index 00000000..d8f55fc6 --- /dev/null +++ b/src/components/Criteria/CriteriaAge.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues } from '../../types'; +import CriteriaCard from './CriteriaCard'; + +it('Age criteria displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + , + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'Age'); + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Comparison operator' }), + 'Greater than but not equal to', + ); + await userEvent.type(screen.getByRole('spinbutton'), '10'); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + criteria: { + type: 'Age', + numDays: '10', + operator: 'GREATER_THAN', + }, + }); +}); diff --git a/src/components/Criteria/CriteriaAge.tsx b/src/components/Criteria/CriteriaAge.tsx new file mode 100644 index 00000000..280d7794 --- /dev/null +++ b/src/components/Criteria/CriteriaAge.tsx @@ -0,0 +1,31 @@ +import { Col, TextField } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import OperatorSelect from './OperatorSelect'; + +export default function CriteriaAge({ prefix }: Readonly<{ prefix: string }>) { + return ( + <> + + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + type="number" + label={} + min={1} + step={1} + /> + )} + + + + ); +} diff --git a/src/components/Criteria/CriteriaAmount.test.tsx b/src/components/Criteria/CriteriaAmount.test.tsx new file mode 100644 index 00000000..3bb9c9d4 --- /dev/null +++ b/src/components/Criteria/CriteriaAmount.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues } from '../../types'; +import CriteriaCard from './CriteriaCard'; + +it('Amount criteria displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + , + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'Amount'); + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Comparison operator' }), + 'Greater than but not equal to', + ); + await userEvent.type(screen.getByRole('spinbutton'), '12'); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + criteria: { + type: 'Amount', + operator: 'GREATER_THAN', + amountCurrency: '12', + }, + }); +}); diff --git a/src/components/Criteria/CriteriaAmount.tsx b/src/components/Criteria/CriteriaAmount.tsx new file mode 100644 index 00000000..d48c66c8 --- /dev/null +++ b/src/components/Criteria/CriteriaAmount.tsx @@ -0,0 +1,31 @@ +import { Col, TextField } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import OperatorSelect from './OperatorSelect'; + +export default function CriteriaAmount({ prefix }: Readonly<{ prefix: string }>) { + return ( + <> + + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + type="number" + label={} + min={0} + step={0.01} + /> + )} + + + + ); +} diff --git a/src/components/Criteria/CriteriaCard.pass.test.tsx b/src/components/Criteria/CriteriaCard.pass.test.tsx new file mode 100644 index 00000000..4703cfa1 --- /dev/null +++ b/src/components/Criteria/CriteriaCard.pass.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues } from '../../types'; +import CriteriaCard from './CriteriaCard'; + +it('Criteria card with "no criteria" should be empty', async () => { + const { container } = render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={jest.fn()}> + {() => } + , + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'All of:'); + await userEvent.selectOptions(screen.getByRole('combobox'), 'No criteria (always run)'); + + expect(container.querySelector('[data-test-card-body]')).toHaveTextContent(''); + expect(container.querySelector('[data-test-card-body]')).toHaveClass('emptyBody'); +}); diff --git a/src/components/Criteria/CriteriaCard.tsx b/src/components/Criteria/CriteriaCard.tsx new file mode 100644 index 00000000..0b230fd8 --- /dev/null +++ b/src/components/Criteria/CriteriaCard.tsx @@ -0,0 +1,127 @@ +import { Card, Loading, Row } from '@folio/stripes/components'; +import classNames from 'classnames'; +import React, { useMemo } from 'react'; +import { useField } from 'react-final-form'; +import { FieldArray, FieldArrayRenderProps } from 'react-final-form-arrays'; +import { CriteriaGroupType, CriteriaTerminalType } from '../../types'; +import css from '../Card.module.css'; +import CriteriaAge from './CriteriaAge'; +import CriteriaAmount from './CriteriaAmount'; +import CriteriaCardSelect from './CriteriaCardSelect'; +import CriteriaCardToolbox from './CriteriaCardToolbox'; +import CriteriaFeeFineOwner from './CriteriaFeeFineOwner'; +import CriteriaFeeFineType from './CriteriaFeeFineType'; +import CriteriaLocation from './CriteriaLocation'; +import CriteriaPatronGroup from './CriteriaPatronGroup'; +import CriteriaServicePoint from './CriteriaServicePoint'; + +function renderCriteriaNoneOf({ fields }: { fields: FieldArrayRenderProps['fields'] }) { + return ( + fields.map((name, index) => ( + // eslint-disable-next-line @typescript-eslint/no-use-before-define + fields.remove(index)} + /> + )) + ); +} + +export default function CriteriaCard({ + name, + onRemove, + root = false, + patronOnly = false, + alone, +}: Readonly<{ + name: string; + onRemove?: () => void; + root?: boolean; + patronOnly?: boolean; + alone: boolean; +}>) { + const type = useField(`${name}.type`, { + subscription: { value: true }, + format: (value) => value ?? CriteriaTerminalType.PASS, + }).input.value; + + const noneOfReturnType = useMemo(() => ( + + {({ fields }) => renderCriteriaNoneOf({ fields })} + + ), [name]); + + const cardInterior = useMemo(() => { + switch (type) { + case CriteriaTerminalType.PASS: + return
; + + case CriteriaGroupType.ALL_OF: + case CriteriaGroupType.ANY_OF: + case CriteriaGroupType.NONE_OF: + return (noneOfReturnType); + + case CriteriaTerminalType.AGE: + return ( + + + + ); + case CriteriaTerminalType.AMOUNT: + return ( + + + + ); + case CriteriaTerminalType.FEE_FINE_OWNER: + return ( + + + + ); + case CriteriaTerminalType.FEE_FINE_TYPE: + return ( + + + + ); + case CriteriaTerminalType.LOCATION: + return ( + + + + ); + case CriteriaTerminalType.SERVICE_POINT: + return ( + + + + ); + case CriteriaTerminalType.PATRON_GROUP: + return ( + + + + ); + + default: + return ; + } + }, [name, noneOfReturnType, type]); + + return ( + } + headerEnd={} + bodyClass={classNames({ + [css.emptyBody]: type === CriteriaTerminalType.PASS, + })} + > + {cardInterior} + + ); +} diff --git a/src/components/Criteria/CriteriaCard.unknown.test.tsx b/src/components/Criteria/CriteriaCard.unknown.test.tsx new file mode 100644 index 00000000..a8e3fccb --- /dev/null +++ b/src/components/Criteria/CriteriaCard.unknown.test.tsx @@ -0,0 +1,18 @@ +import { render } from '@folio/jest-config-stripes/testing-library/react'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import CriteriaCard from './CriteriaCard'; + +it('Criteria card with unknown type should display loading', () => { + const { container } = render( + withIntlConfiguration( +
+ {() => } + , + ), + ); + + expect(container.querySelector('[data-test-card-body] .spinner')).toBeVisible(); +}); diff --git a/src/components/Criteria/CriteriaCardSelect.test.tsx b/src/components/Criteria/CriteriaCardSelect.test.tsx new file mode 100644 index 00000000..44ef01d2 --- /dev/null +++ b/src/components/Criteria/CriteriaCardSelect.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import { Form } from 'react-final-form'; +import React from 'react'; +import CriteriaCardSelect from './CriteriaCardSelect'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; + +describe('Criteria card selection box', () => { + it('root has no criteria option', () => { + render(withIntlConfiguration(
{() => })); + + expect(screen.getByRole('option', { name: 'No criteria (always run)' })).toBeInTheDocument(); + }); + + it('non patron-only has item/etc options', () => { + render(withIntlConfiguration(
{() => })); + + expect(screen.getByRole('option', { name: 'All of:' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Item location' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Patron group' })).toBeInTheDocument(); + }); + + it('patron-only does not have item/etc options', () => { + render( + withIntlConfiguration(
{() => }), + ); + + expect(screen.getByRole('option', { name: 'All of:' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Item location' })).toBeNull(); + expect(screen.getByRole('option', { name: 'Patron group' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/Criteria/CriteriaCardSelect.tsx b/src/components/Criteria/CriteriaCardSelect.tsx new file mode 100644 index 00000000..04ec5bad --- /dev/null +++ b/src/components/Criteria/CriteriaCardSelect.tsx @@ -0,0 +1,47 @@ +import { Select } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field } from 'react-final-form'; +import { useIntl } from 'react-intl'; +import { CriteriaGroupType, CriteriaTerminalType } from '../../types'; +import useCriteriaCardOptions from '../../hooks/useCriteriaCardOptions'; + +export default function CriteriaCardSelect({ + name, + root = false, + patronOnly = false, +}: Readonly<{ + name: string; + root?: boolean; + patronOnly?: boolean; +}>) { + const selectDefaultValue = useMemo(() => { + if (root) { + return CriteriaTerminalType.PASS; + } else { + return CriteriaGroupType.ALL_OF; + } + }, [root]); + + const intl = useIntl(); + + const selectOptions = useCriteriaCardOptions(root, patronOnly); + + return ( + + {(fieldProps) => ( + + {...fieldProps} + required + marginBottom0 + dataOptions={selectOptions} + /> + )} + + ); +} diff --git a/src/components/Criteria/CriteriaCardToolbox.test.tsx b/src/components/Criteria/CriteriaCardToolbox.test.tsx new file mode 100644 index 00000000..98ed33db --- /dev/null +++ b/src/components/Criteria/CriteriaCardToolbox.test.tsx @@ -0,0 +1,107 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { CriteriaGroupType, CriteriaTerminalType, FormValues } from '../../types'; +import CriteriaCardToolbox from './CriteriaCardToolbox'; + +describe('Criteria card toolbox', () => { + it('Default outer type results in no buttons', () => { + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={jest.fn()}> + {() => } + , + ), + ); + + expect(screen.queryAllByRole('button')).toHaveLength(0); + }); + + it.each([CriteriaGroupType.ALL_OF, CriteriaGroupType.ANY_OF, CriteriaGroupType.NONE_OF])( + 'Outer group type %s results in only add', + (type) => { + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={jest.fn()} initialValues={{ criteria: { type } }}> + {() => } + , + ), + ); + + expect(screen.queryAllByRole('button')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'plus-sign' })).toBeVisible(); + }, + ); + + it.each([CriteriaGroupType.ALL_OF, CriteriaGroupType.ANY_OF, CriteriaGroupType.NONE_OF])( + 'Inner group type %s results in add and delete', + (type) => { + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={jest.fn()} initialValues={{ criteria: { type } }}> + {() => } + , + ), + ); + + expect(screen.queryAllByRole('button')).toHaveLength(2); + expect(screen.getByRole('button', { name: 'plus-sign' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'trash' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'trash' })).not.toBeDisabled(); + }, + ); + + it.each([CriteriaGroupType.ALL_OF, CriteriaGroupType.ANY_OF, CriteriaGroupType.NONE_OF])( + 'Inner group type alone %s results in add and disabled delete', + (type) => { + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={jest.fn()} initialValues={{ criteria: { type } }}> + {() => } + , + ), + ); + + expect(screen.queryAllByRole('button')).toHaveLength(2); + expect(screen.getByRole('button', { name: 'plus-sign' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'trash' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'trash' })).toBeDisabled(); + }, + ); + + it.each([CriteriaTerminalType.AGE, CriteriaTerminalType.LOCATION])( + 'Inner type alone %s has disabled delete', + (type) => { + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={jest.fn()} initialValues={{ criteria: { type } }}> + {() => } + , + ), + ); + + expect(screen.queryAllByRole('button')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'trash' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'trash' })).toBeDisabled(); + }, + ); + + it.each([CriteriaTerminalType.AGE, CriteriaTerminalType.LOCATION])( + 'Inner type not alone %s has enabled delete', + (type) => { + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={jest.fn()} initialValues={{ criteria: { type } }}> + {() => } + , + ), + ); + + expect(screen.queryAllByRole('button')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'trash' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'trash' })).toHaveAttribute('disabled', ''); + }, + ); +}); diff --git a/src/components/Criteria/CriteriaCardToolbox.tsx b/src/components/Criteria/CriteriaCardToolbox.tsx new file mode 100644 index 00000000..45e360f3 --- /dev/null +++ b/src/components/Criteria/CriteriaCardToolbox.tsx @@ -0,0 +1,48 @@ +import { IconButton } from '@folio/stripes/components'; +import React, { useCallback } from 'react'; +import { useField } from 'react-final-form'; +import { useFieldArray } from 'react-final-form-arrays'; +import { CriteriaGroup, CriteriaGroupType, CriteriaTerminal, CriteriaTerminalType } from '../../types'; + +export interface CriteriaCardToolboxProps { + prefix: string; + root: boolean; + alone: boolean; + onRemove?: () => void; +} + +export default function CriteriaCardToolbox({ + prefix, + root = false, + alone = false, + onRemove, +}: Readonly) { + const type = useField( + `${prefix}type`, + // format ensures undefined is treated as default (pass) + { + subscription: { value: true }, + format: (value) => value || CriteriaTerminalType.PASS, + }, + ).input.value; + const criteria = useFieldArray(`${prefix}criteria`); + + const addCallback = useCallback(() => { + criteria.fields.push({ + type: CriteriaGroupType.ALL_OF, + criteria: [], + }); + }, [criteria]); + + return ( +
+ {( + [CriteriaGroupType.ALL_OF, CriteriaGroupType.ANY_OF, CriteriaGroupType.NONE_OF] as ( + | CriteriaGroupType + | CriteriaTerminalType + )[] + ).includes(type) && } + {!root && } +
+ ); +} diff --git a/src/components/Criteria/CriteriaFeeFineOwner.test.tsx b/src/components/Criteria/CriteriaFeeFineOwner.test.tsx new file mode 100644 index 00000000..55af1bee --- /dev/null +++ b/src/components/Criteria/CriteriaFeeFineOwner.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues } from '../../types'; +import CriteriaCard from './CriteriaCard'; + +const getResponse = jest.fn((endpoint: string) => { + if (endpoint.startsWith('owners')) { + return { + owners: [ + { + id: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + owner: 'Owner 1', + }, + { + id: '3da4b49d-ee7a-41fc-bf53-10f626180f7f', + owner: 'Owner 2', + }, + ], + }; + } else { + fail(`Unexpected endpoint: ${endpoint}`); + return {}; + } +}); + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: () => ({ + get: (endpoint: string) => ({ + json: () => Promise.resolve(getResponse(endpoint)), + }), + }), +})); + +describe('Fee/fine owner criteria displays appropriate form', () => { + const submitter = jest.fn(); + + beforeEach(async () => { + render( + withIntlConfiguration( + + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + +
, + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'Fee/fine owner'); + }); + + it('Selecting an owner works as expected', async () => { + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Fee/fine owner' }), 'Owner 1'); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + criteria: { + type: 'FeeFineOwner', + feeFineOwnerId: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + }, + }); + }); +}); diff --git a/src/components/Criteria/CriteriaFeeFineOwner.tsx b/src/components/Criteria/CriteriaFeeFineOwner.tsx new file mode 100644 index 00000000..b6baed12 --- /dev/null +++ b/src/components/Criteria/CriteriaFeeFineOwner.tsx @@ -0,0 +1,42 @@ +import { Col, Select } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import { useFeeFineOwners } from '../../api/queries'; + +export default function CriteriaFeeFineOwner({ prefix }: Readonly<{ prefix: string }>) { + const feeFineOwners = useFeeFineOwners(); + + const ownersSelectOptions = useMemo(() => { + if (!feeFineOwners.isSuccess) { + return [{ label: '', value: '', disabled: true }]; + } + + return [ + { label: '', value: '', disabled: true }, + ...feeFineOwners.data + .map((owner) => ({ + label: owner.owner, + value: owner.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [feeFineOwners]); + + return ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={ownersSelectOptions} + /> + )} + + + ); +} diff --git a/src/components/Criteria/CriteriaFeeFineType.test.tsx b/src/components/Criteria/CriteriaFeeFineType.test.tsx new file mode 100644 index 00000000..e0b4037d --- /dev/null +++ b/src/components/Criteria/CriteriaFeeFineType.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues } from '../../types'; +import CriteriaCard from './CriteriaCard'; + +const getResponse = jest.fn((endpoint: string) => { + if (endpoint.startsWith('owners')) { + return { + owners: [ + { + id: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + owner: 'Owner 1', + }, + { + id: '3da4b49d-ee7a-41fc-bf53-10f626180f7f', + owner: 'Owner 2', + }, + ], + }; + } else if (endpoint.startsWith('feefines')) { + return { + feefines: [ + { + id: '9523cb96-e752-40c2-89da-60f3961a488d', + feeFineType: 'Overdue fine', + automatic: true, + }, + { + id: 'cf238f9f-7018-47b7-b815-bb2db798e19f', + feeFineType: 'Lost item fee', + automatic: true, + }, + { + id: '49223209-4d06-4bb5-8b7e-d0b7e5b8fb10', + ownerId: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + feeFineType: 'Type 1', + automatic: false, + }, + { + id: '5fc86d9c-b26d-5ac4-87d9-289171846190', + ownerId: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + feeFineType: 'Type 2', + automatic: false, + }, + { + id: '9cfceb6d-e061-5512-ba43-22c349b8c728', + ownerId: '3da4b49d-ee7a-41fc-bf53-10f626180f7f', + feeFineType: 'Type 3', + automatic: false, + }, + ], + }; + } else { + fail(`Unexpected endpoint: ${endpoint}`); + return {}; + } +}); + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: () => ({ + get: (endpoint: string) => ({ + json: () => Promise.resolve(getResponse(endpoint)), + }), + }), +})); + +describe('Fee/fine type criteria displays appropriate form', () => { + const submitter = jest.fn(); + + beforeEach(async () => { + render( + withIntlConfiguration( + + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + +
, + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'Fee/fine type'); + }); + + it('Automatic works as expected', async () => { + // check default fill in + expect(screen.getByRole('combobox', { name: 'Fee/fine owner' })).toHaveDisplayValue('Automatic'); + + expect(screen.getByRole('option', { name: 'Overdue fine' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Lost item fee' })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Type 1' })).toBeNull(); + expect(screen.queryByRole('option', { name: 'Type 2' })).toBeNull(); + expect(screen.queryByRole('option', { name: 'Type 3' })).toBeNull(); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Fee/fine type' }), 'Lost item fee'); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + criteria: { + type: 'FeeType', + feeFineOwnerId: 'automatic', + feeFineTypeId: 'cf238f9f-7018-47b7-b815-bb2db798e19f', + }, + }); + }); + + it('Selecting an owner works as expected', async () => { + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Fee/fine owner' }), 'Owner 1'); + + expect(screen.getByRole('option', { name: 'Type 1' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Type 2' })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Type 3' })).toBeNull(); + expect(screen.queryByRole('option', { name: 'Overdue fine' })).toBeNull(); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Fee/fine type' }), 'Type 2'); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + criteria: { + type: 'FeeType', + feeFineOwnerId: '9cb8f9fd-4386-45d0-bb6e-aa8b33e577b0', + feeFineTypeId: '5fc86d9c-b26d-5ac4-87d9-289171846190', + }, + }); + }); +}); diff --git a/src/components/Criteria/CriteriaFeeFineType.tsx b/src/components/Criteria/CriteriaFeeFineType.tsx new file mode 100644 index 00000000..ff38508f --- /dev/null +++ b/src/components/Criteria/CriteriaFeeFineType.tsx @@ -0,0 +1,100 @@ +import { Col, Select } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field, useField } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useFeeFineOwners, useFeeFineTypes } from '../../api/queries'; + +export default function CriteriaFeeFineType({ prefix }: Readonly<{ prefix: string }>) { + const feeFineOwners = useFeeFineOwners(); + const feeFineTypes = useFeeFineTypes(); + const intl = useIntl(); + + const selectedOwner = useField(`${prefix}feeFineOwnerId`, { + subscription: { value: true }, + // provide default value for when the field is not yet initialized + format: (value) => value ?? 'automatic', + }).input.value; + + const ownersSelectOptions = useMemo(() => { + const defaultOption = { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.type.automatic', + }), + value: 'automatic', + }; + + if (!feeFineOwners.isSuccess) { + return [defaultOption]; + } + + return [ + defaultOption, + ...feeFineOwners.data + .map((owner) => ({ + label: owner.owner, + value: owner.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [feeFineOwners, intl]); + + const typeSelectOptions = useMemo(() => { + if (!feeFineTypes.isSuccess) { + return []; + } + + if (selectedOwner === 'automatic') { + return feeFineTypes.data + .filter((type) => type.automatic) + .map((type) => ({ + label: type.feeFineType, + value: type.id, + })); + } + + return feeFineTypes.data + .filter((type) => type.ownerId === selectedOwner) + .map((type) => ({ + label: type.feeFineType, + value: type.id, + })); + }, [selectedOwner, feeFineTypes]); + + const typeSelectOptionsForDisplay = useMemo( + () => [{ label: '', value: undefined }, ...typeSelectOptions].sort((a, b) => a.label.localeCompare(b.label)), + [typeSelectOptions], + ); + + return ( + <> + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={ownersSelectOptions} + /> + )} + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={typeSelectOptionsForDisplay} + /> + )} + + + + ); +} diff --git a/src/components/Criteria/CriteriaLocation.test.tsx b/src/components/Criteria/CriteriaLocation.test.tsx new file mode 100644 index 00000000..ff759ef4 --- /dev/null +++ b/src/components/Criteria/CriteriaLocation.test.tsx @@ -0,0 +1,181 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues } from '../../types'; +import CriteriaCard from './CriteriaCard'; + +const getResponse = jest.fn((endpoint: string) => { + if (endpoint.startsWith('location-units/institutions')) { + return { + locinsts: [ + { + id: 'institutionId', + name: 'Test institution', + }, + ], + }; + } else if (endpoint.startsWith('location-units/campuses')) { + return { + loccamps: [ + { + id: 'campusId1', + name: 'Matching campus 1', + institutionId: 'institutionId', + }, + { + id: 'campusId2', + name: 'Matching campus 2', + institutionId: 'institutionId', + }, + { + id: 'campusIdIrrelevant', + name: 'Non-matching campus', + institutionId: 'institutionIrrelevant', + }, + ], + }; + } else if (endpoint.startsWith('location-units/libraries')) { + return { + loclibs: [ + { + id: 'libraryId', + name: 'Matching library', + campusId: 'campusId1', + }, + { + id: 'libraryIdIrrelevant', + name: 'Non-matching library', + campusId: 'campusIdIrrelevant', + }, + ], + }; + } else if (endpoint.startsWith('locations')) { + return { + locations: [ + { + id: 'locationId1', + name: 'Matching location 1', + libraryId: 'libraryId', + }, + { + id: 'locationId2', + name: 'Matching location 2', + libraryId: 'libraryId', + }, + { + id: 'locationIrrelevant', + name: 'Non-matching location', + libraryId: 'libraryIdIrrelevant', + }, + ], + }; + } else { + fail(`Unexpected endpoint: ${endpoint}`); + return {}; + } +}); + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: () => ({ + get: (endpoint: string) => ({ + json: () => Promise.resolve(getResponse(endpoint)), + }), + }), +})); + +it('Location criteria displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( + + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + +
, + ), + ); + + function expectOptionInDocument(option: string) { + expect(screen.getByRole('option', { name: option })).toBeVisible(); + } + function expectOptionNotInDocument(option: string) { + expect(screen.queryByRole('option', { name: option })).toBeNull(); + } + + await userEvent.selectOptions(screen.getByRole('combobox'), 'Item location'); + + expectOptionInDocument('Test institution'); + expectOptionNotInDocument('Matching campus 1'); + expectOptionNotInDocument('Matching campus 2'); + expectOptionNotInDocument('Non-matching campus'); + expectOptionNotInDocument('Matching library'); + expectOptionNotInDocument('Non-matching library'); + expectOptionNotInDocument('Matching location 1'); + expectOptionNotInDocument('Matching location 2'); + expectOptionNotInDocument('Non-matching location'); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Institution' }), 'Test institution'); + + expectOptionInDocument('Matching campus 1'); + expectOptionInDocument('Matching campus 2'); + expectOptionNotInDocument('Non-matching campus'); + expectOptionNotInDocument('Matching library'); + expectOptionNotInDocument('Non-matching library'); + expectOptionNotInDocument('Matching location 1'); + expectOptionNotInDocument('Matching location 2'); + expectOptionNotInDocument('Non-matching location'); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Campus' }), 'Matching campus 1'); + + expectOptionInDocument('Matching library'); + expectOptionNotInDocument('Non-matching library'); + expectOptionNotInDocument('Matching location 1'); + expectOptionNotInDocument('Matching location 2'); + expectOptionNotInDocument('Non-matching location'); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Campus' }), 'Matching campus 2'); + + expectOptionNotInDocument('Matching library'); + expectOptionNotInDocument('Non-matching library'); + expectOptionNotInDocument('Matching location 1'); + expectOptionNotInDocument('Matching location 2'); + expectOptionNotInDocument('Non-matching location'); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Campus' }), 'Matching campus 1'); + + expectOptionInDocument('Matching library'); + expectOptionNotInDocument('Non-matching library'); + expectOptionNotInDocument('Matching location 1'); + expectOptionNotInDocument('Matching location 2'); + expectOptionNotInDocument('Non-matching location'); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Library' }), 'Matching library'); + + expectOptionInDocument('Matching location 1'); + expectOptionInDocument('Matching location 2'); + expectOptionNotInDocument('Non-matching location'); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Location' }), 'Matching location 1'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + criteria: { + type: 'Location', + institutionId: 'institutionId', + campusId: 'campusId1', + libraryId: 'libraryId', + locationId: 'locationId1', + }, + }); +}); diff --git a/src/components/Criteria/CriteriaLocation.tsx b/src/components/Criteria/CriteriaLocation.tsx new file mode 100644 index 00000000..7a13aff8 --- /dev/null +++ b/src/components/Criteria/CriteriaLocation.tsx @@ -0,0 +1,150 @@ +import { Col, Select } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field, useField } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import { useCampuses, useInstitutions, useLibraries, useLocations } from '../../api/queries'; + +export default function CriteriaLocation({ prefix }: Readonly<{ prefix: string }>) { + const institutions = useInstitutions(); + const campuses = useCampuses(); + const libraries = useLibraries(); + const locations = useLocations(); + + const selectedInstitution = useField(`${prefix}institutionId`, { + subscription: { value: true }, + }).input.value; + const selectedCampus = useField(`${prefix}campusId`, { + subscription: { value: true }, + }).input.value; + const selectedLibrary = useField(`${prefix}libraryId`, { + subscription: { value: true }, + }).input.value; + + const institutionSelectOptions = useMemo(() => { + if (!institutions.isSuccess) { + return [{ label: '', value: undefined }]; + } + + return [ + { label: '', value: undefined }, + ...institutions.data + .map((institution) => ({ + label: institution.name, + value: institution.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [institutions]); + + const campusSelectOptions = useMemo(() => { + if (!selectedInstitution || !campuses.isSuccess) { + return [{ label: '', value: undefined }]; + } + + return [ + { label: '', value: undefined }, + ...campuses.data + .filter((campus) => campus.institutionId === selectedInstitution) + .map((campus) => ({ + label: campus.name, + value: campus.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [selectedInstitution, campuses]); + + const librarySelectOptions = useMemo(() => { + if (!selectedCampus || !libraries.isSuccess) { + return [{ label: '', value: undefined }]; + } + + return [ + { label: '', value: undefined }, + ...libraries.data + .filter((library) => library.campusId === selectedCampus) + .map((library) => ({ + label: library.name, + value: library.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [selectedCampus, libraries]); + + const locationSelectOptions = useMemo(() => { + if (!selectedLibrary || !locations.isSuccess) { + return [{ label: '', value: undefined }]; + } + + return [ + { label: '', value: undefined }, + ...locations.data + .filter((location) => location.libraryId === selectedLibrary) + .map((location) => ({ + label: location.name, + value: location.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [selectedLibrary, locations]); + + return ( + <> + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={institutionSelectOptions} + /> + )} + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={campusSelectOptions} + /> + )} + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={librarySelectOptions} + /> + )} + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={locationSelectOptions} + /> + )} + + + + ); +} diff --git a/src/components/Criteria/CriteriaPatronGroup.test.tsx b/src/components/Criteria/CriteriaPatronGroup.test.tsx new file mode 100644 index 00000000..8937d3cb --- /dev/null +++ b/src/components/Criteria/CriteriaPatronGroup.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues } from '../../types'; +import CriteriaCard from './CriteriaCard'; + +const getResponse = jest.fn((endpoint: string) => { + if (endpoint.startsWith('groups')) { + return { + usergroups: [ + { + id: '503a81cd-6c26-400f-b620-14c08943697c', + group: 'faculty', + desc: 'Faculty Member', + }, + { + id: '3684a786-6671-4268-8ed0-9db82ebca60b', + group: 'staff', + desc: 'Staff Member', + }, + { + id: 'ad0bc554-d5bc-463c-85d1-5562127ae91b', + group: 'graduate', + desc: 'Graduate Student', + }, + { + id: 'bdc2b6d4-5ceb-4a12-ab46-249b9a68473e', + group: 'undergrad', + desc: 'Undergraduate Student', + }, + ], + }; + } else { + fail(`Unexpected endpoint: ${endpoint}`); + return {}; + } +}); + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: () => ({ + get: (endpoint: string) => ({ + json: () => Promise.resolve(getResponse(endpoint)), + }), + }), +})); + +it('Patron group type criteria displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( + + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + +
, + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'Patron group'); + + expect(screen.getByRole('option', { name: 'faculty' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'undergrad' })).toBeVisible(); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Patron group' }), 'staff'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + criteria: { + type: 'PatronGroup', + patronGroupId: '3684a786-6671-4268-8ed0-9db82ebca60b', + }, + }); +}); diff --git a/src/components/Criteria/CriteriaPatronGroup.tsx b/src/components/Criteria/CriteriaPatronGroup.tsx new file mode 100644 index 00000000..c83b2a59 --- /dev/null +++ b/src/components/Criteria/CriteriaPatronGroup.tsx @@ -0,0 +1,42 @@ +import { Col, Select } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import { usePatronGroups } from '../../api/queries'; + +export default function CriteriaPatronGroup({ prefix }: Readonly<{ prefix: string }>) { + const patronGroups = usePatronGroups(); + + const selectOptions = useMemo(() => { + if (!patronGroups.isSuccess) { + return [{ label: '', value: undefined }]; + } + + return [ + { label: '', value: undefined }, + ...patronGroups.data + .map((sp) => ({ + label: sp.group, + value: sp.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [patronGroups]); + + return ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={selectOptions} + /> + )} + + + ); +} diff --git a/src/components/Criteria/CriteriaServicePoint.test.tsx b/src/components/Criteria/CriteriaServicePoint.test.tsx new file mode 100644 index 00000000..293d23a8 --- /dev/null +++ b/src/components/Criteria/CriteriaServicePoint.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues } from '../../types'; +import CriteriaCard from './CriteriaCard'; + +const getResponse = jest.fn((endpoint: string) => { + if (endpoint.startsWith('service-points')) { + return { + servicepoints: [ + { + id: '3a40852d-49fd-4df2-a1f9-6e2641a6e91f', + name: 'Circ Desk 1', + }, + { + id: 'c4c90014-c8c9-4ade-8f24-b5e313319f4b', + name: 'Circ Desk 2', + }, + { + id: '7c5abc9f-f3d7-4856-b8d7-6712462ca007', + name: 'Online', + }, + ], + totalRecords: 4, + }; + } else { + fail(`Unexpected endpoint: ${endpoint}`); + return {}; + } +}); + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: () => ({ + get: (endpoint: string) => ({ + json: () => Promise.resolve(getResponse(endpoint)), + }), + }), +})); + +it('Service point type criteria displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( + + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + +
, + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'Item service point'); + + expect(screen.getByRole('option', { name: 'Circ Desk 1' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Online' })).toBeVisible(); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Service point' }), 'Circ Desk 1'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + criteria: { + type: 'ServicePoint', + servicePointId: '3a40852d-49fd-4df2-a1f9-6e2641a6e91f', + }, + }); +}); diff --git a/src/components/Criteria/CriteriaServicePoint.tsx b/src/components/Criteria/CriteriaServicePoint.tsx new file mode 100644 index 00000000..5f006b64 --- /dev/null +++ b/src/components/Criteria/CriteriaServicePoint.tsx @@ -0,0 +1,42 @@ +import { Col, Select } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import { useServicePoints } from '../../api/queries'; + +export default function CriteriaServicePoint({ prefix }: Readonly<{ prefix: string }>) { + const servicePoints = useServicePoints(); + + const selectOptions = useMemo(() => { + if (!servicePoints.isSuccess) { + return [{ label: '', value: undefined }]; + } + + return [ + { label: '', value: undefined }, + ...servicePoints.data + .map((sp) => ({ + label: sp.name, + value: sp.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [servicePoints]); + + return ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={selectOptions} + /> + )} + + + ); +} diff --git a/src/components/Criteria/OperatorSelect.tsx b/src/components/Criteria/OperatorSelect.tsx new file mode 100644 index 00000000..25d12771 --- /dev/null +++ b/src/components/Criteria/OperatorSelect.tsx @@ -0,0 +1,49 @@ +import { Select } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { ComparisonOperator } from '../../types'; + +export default function OperatorSelect({ name }: Readonly<{ name: string }>) { + const intl = useIntl(); + return ( + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={[ + { label: '', value: undefined }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.aggregate.filter.operator.less', + }), + value: ComparisonOperator.LESS_THAN, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.aggregate.filter.operator.lessEqual', + }), + value: ComparisonOperator.LESS_THAN_EQUAL, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.aggregate.filter.operator.greater', + }), + value: ComparisonOperator.GREATER_THAN, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.aggregate.filter.operator.greaterEqual', + }), + value: ComparisonOperator.GREATER_THAN_EQUAL, + }, + ]} + /> + )} + + ); +} diff --git a/src/components/Criteria/index.ts b/src/components/Criteria/index.ts new file mode 100644 index 00000000..490d5195 --- /dev/null +++ b/src/components/Criteria/index.ts @@ -0,0 +1,2 @@ +export { default as AggregateCriteriaCard } from './AggregateCriteriaCard'; +export { default as CriteriaCard } from './CriteriaCard'; diff --git a/src/components/ExportPreview/ExportPreviewData.test.tsx b/src/components/ExportPreview/ExportPreviewData.test.tsx new file mode 100644 index 00000000..f9a4acf1 --- /dev/null +++ b/src/components/ExportPreview/ExportPreviewData.test.tsx @@ -0,0 +1,71 @@ +import { render, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import ExportPreviewData from './ExportPreviewData'; +import { DataTokenType, HeaderFooterTokenType } from '../../types'; + +jest.mock('@ngneat/falso', () => ({ + randFloat: jest.fn(() => 12.34), + randNumber: jest.fn(() => 2), +})); + +describe('Export preview data component', () => { + it('handles undefined data gracefully', () => { + const { container } = render( +
+ {() => } + , + ); + expect(container).toHaveTextContent(''); + }); + + const SAMPLE_DATA = { + aggregate: false, + header: [ + { type: HeaderFooterTokenType.ARBITRARY_TEXT, text: 'HEADER' }, + { type: HeaderFooterTokenType.SPACE, repeat: '1' }, + ], + data: [ + { type: DataTokenType.ARBITRARY_TEXT, text: 'DATA' }, + { type: DataTokenType.SPACE, repeat: '1' }, + ], + dataAggregate: [ + { type: DataTokenType.ARBITRARY_TEXT, text: 'DATA_AGGREGATE' }, + { type: DataTokenType.SPACE, repeat: '1' }, + ], + footer: [{ type: HeaderFooterTokenType.ARBITRARY_TEXT, text: 'FOOTER' }], + preview: { invisible: false }, + }; + + it('renders from form context', async () => { + const { container } = render( +
+ {() => } + , + ); + await waitFor(() => { expect(container).toHaveTextContent('HEADER DATA DATA FOOTER'); }); + }); + + it('shows whitespace when selected', async () => { + const { container } = render( +
+ {() => } + , + ); + await waitFor(() => { expect(container).toHaveTextContent('HEADER•DATA•DATA•FOOTER'); }); + }); + + it('uses aggregate data when applicable', async () => { + const { container } = render( +
+ {() => } + , + ); + await waitFor(() => { expect(container).toHaveTextContent('HEADER DATA_AGGREGATE DATA_AGGREGATE FOOTER'); }); + }); +}); diff --git a/src/components/ExportPreview/ExportPreviewData.tsx b/src/components/ExportPreview/ExportPreviewData.tsx new file mode 100644 index 00000000..b6e3392a --- /dev/null +++ b/src/components/ExportPreview/ExportPreviewData.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from 'react'; +import { useField } from 'react-final-form'; +import { useFieldArray } from 'react-final-form-arrays'; +import { DataToken, HeaderFooterToken } from '../../types'; +import createPreviewData from './createPreviewData'; +import createHeaderFooterString from './createHeaderFooterString'; +import RenderInvisibles from './RenderInvisibles'; + +export default function ExportPreviewData() { + const isAggregate = useField('aggregate', { + subscription: { value: true }, + format: (value) => value ?? false, + }).input.value; + + const header = + useFieldArray('header', { + subscription: { value: true }, + }).fields.value; + const data = + useFieldArray('data', { + subscription: { value: true }, + }).fields.value; + const dataAggregate = + useFieldArray('dataAggregate', { + subscription: { value: true }, + }).fields.value; + const footer = + useFieldArray('footer', { + subscription: { value: true }, + }).fields.value; + + const showInvisible = useField('preview.invisible', { + subscription: { value: true }, + format: (value) => value ?? false, + }).input.value; + + const [contents, setContents] = useState(''); + + useEffect(() => { + const fetchData = async () => { + const dataToUse = (isAggregate ? dataAggregate : data) ?? []; + const { dataPreview, totalAmount, totalCount } = await createPreviewData(dataToUse, isAggregate); + const headerPreview = createHeaderFooterString(header ?? [], totalAmount, totalCount); + const footerPreview = createHeaderFooterString(footer ?? [], totalAmount, totalCount); + + // Concatenate header, data, and footer then update state + setContents(headerPreview + dataPreview + footerPreview); + }; + + fetchData(); + }, [header, data, dataAggregate, footer, isAggregate]); + + return ; +} diff --git a/src/components/ExportPreview/RenderInvisibles.modules.css b/src/components/ExportPreview/RenderInvisibles.modules.css new file mode 100644 index 00000000..d71b3b05 --- /dev/null +++ b/src/components/ExportPreview/RenderInvisibles.modules.css @@ -0,0 +1,3 @@ +.invisible { + color: gray; +} diff --git a/src/components/ExportPreview/RenderInvisibles.test.tsx b/src/components/ExportPreview/RenderInvisibles.test.tsx new file mode 100644 index 00000000..451e858a --- /dev/null +++ b/src/components/ExportPreview/RenderInvisibles.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render } from '@folio/jest-config-stripes/testing-library/react'; +import HandleInvisible, { splitAndInsert } from './RenderInvisibles'; + +const TEST_SEARCH = 'REPLACE_ME'; +const TEST_REPLACEMENT = 'REPLACEMENT!'; + +test.each([ + [

Heading 1

, [

Heading 1

]], + [undefined, [undefined]], + ['unrelated', ['unrelated']], + ['REPLACE_ME', ['REPLACEMENT!']], + ['REPLACE_ME REPLACE_ME', ['REPLACEMENT!', ' ', 'REPLACEMENT!']], + ['abc REPLACE_ME def', ['abc ', 'REPLACEMENT!', ' def']], + ['abc REPLACE_ME def REPLACE_ME', ['abc ', 'REPLACEMENT!', ' def ', 'REPLACEMENT!']], +])('splitAndInsert(%s) = %s', (haystack, expected) => expect(splitAndInsert(haystack, TEST_SEARCH, TEST_REPLACEMENT)).toEqual(expected)); + +test.each([ + ['abcdef', 'abcdef'], + ['abc\ndef', 'abc
def'], + ['abc\r\ndef', 'abc
def'], + ['a b\tc\rd\ne', 'a b\tcd
e'], +])('Rendering with text=%s without showInvisible gives inner HTML=%s', (text, innerHTML) => { + const { container } = render(); + expect( + Array.from(container.childNodes) + .map((el) => (el as HTMLElement).innerHTML) + .join(''), + ).toEqual(innerHTML); +}); + +test.each([ + ['abcdef', 'abcdef'], + ['abc\ndef', 'abc\\ndef'], + ['abc\r\ndef', 'abc\\r\\ndef'], + ['a b\tc\rd\ne', 'a•b\\tc\\rd\\ne'], +])('Rendering with text=%s and showInvisible gives inner text=%s', (text, textContent) => { + const { container } = render(); + expect(container).toHaveTextContent(textContent); +}); diff --git a/src/components/ExportPreview/RenderInvisibles.tsx b/src/components/ExportPreview/RenderInvisibles.tsx new file mode 100644 index 00000000..4f31db7b --- /dev/null +++ b/src/components/ExportPreview/RenderInvisibles.tsx @@ -0,0 +1,60 @@ +import React, { ReactNode } from 'react'; +import css from './RenderInvisibles.modules.css'; + +export function splitAndInsert(haystack: string | ReactNode, needle: string, replacement: ReactNode): ReactNode[] { + if (typeof haystack !== 'string') { + return [haystack]; + } + + return haystack + .split(needle) + .flatMap((piece, index, array) => { + if (index === array.length - 1) { + return [piece]; + } + + return [piece, replacement]; + }) + .filter((piece) => piece !== ''); +} + +function Invisible({ children }: Readonly<{ children: ReactNode }>) { + return {children}; +} + +export default function HandleInvisible({ text, showInvisible }: Readonly<{ text: string; showInvisible: boolean }>) { + let pieces: ReactNode[] = [text]; + + // denote appropriately + if (showInvisible) { + pieces = pieces + .flatMap((piece) => splitAndInsert(piece, '\r', \r)) + .flatMap((piece) => splitAndInsert( + piece, + '\n', + <> + \n +
+ , + )) + .flatMap((piece) => splitAndInsert(piece, '\t', \t)) + .flatMap((piece) => splitAndInsert(piece, ' ', )); + } else { + pieces = pieces.flatMap((piece, index) => splitAndInsert(piece, '\n',
)); + } + + pieces = pieces.map((piece) => { + if (typeof piece === 'string') { + return piece.replaceAll('\n', '').replaceAll('\r', ''); + } + return piece; + }); + + return ( + <> + {pieces.map((piece, i) => ( + {piece} + ))} + + ); +} diff --git a/src/components/ExportPreview/createHeaderFooterString.test.ts b/src/components/ExportPreview/createHeaderFooterString.test.ts new file mode 100644 index 00000000..ed8e8cd5 --- /dev/null +++ b/src/components/ExportPreview/createHeaderFooterString.test.ts @@ -0,0 +1,40 @@ +import { DateFormatType, HeaderFooterToken, HeaderFooterTokenType } from '../../types'; +import createHeaderFooterString, { tokenToString } from './createHeaderFooterString'; + +describe('Preview data generation', () => { + const TEST_AMOUNT = 12.34; + const TEST_COUNT = 5; + + test.each([ + [{}, ''], + [{ type: HeaderFooterTokenType.ARBITRARY_TEXT }, ''], + [{ type: HeaderFooterTokenType.ARBITRARY_TEXT, text: 'foo' }, 'foo'], + [{ type: HeaderFooterTokenType.NEWLINE }, '\n'], + [{ type: HeaderFooterTokenType.NEWLINE_MICROSOFT }, '\r\n'], + [{ type: HeaderFooterTokenType.TAB }, '\t'], + [{ type: HeaderFooterTokenType.COMMA }, ','], + [{ type: HeaderFooterTokenType.SPACE, repeat: '3' }, ' '], + [ + { + type: HeaderFooterTokenType.CURRENT_DATE, + format: DateFormatType.YEAR_LONG, + }, + new Date().getFullYear().toString(), + ], + [{ type: HeaderFooterTokenType.AGGREGATE_TOTAL, decimal: true }, '12.34'], + [{ type: HeaderFooterTokenType.AGGREGATE_TOTAL, decimal: false }, '1234'], + [{ type: HeaderFooterTokenType.AGGREGATE_COUNT }, '5'], + ] as const)('tokenToString(%o)=%s', (token, expected) => expect(tokenToString(token as HeaderFooterToken, TEST_AMOUNT, TEST_COUNT)).toBe(expected)); + + test('createHeaderFooterString works as expected', () => expect( + createHeaderFooterString( + [ + { type: HeaderFooterTokenType.AGGREGATE_COUNT }, + { type: HeaderFooterTokenType.COMMA }, + { type: HeaderFooterTokenType.AGGREGATE_TOTAL, decimal: true }, + ] as HeaderFooterToken[], + 12.34, + 5, + ), + ).toStrictEqual('5,12.34')); +}); diff --git a/src/components/ExportPreview/createHeaderFooterString.ts b/src/components/ExportPreview/createHeaderFooterString.ts new file mode 100644 index 00000000..c126d95e --- /dev/null +++ b/src/components/ExportPreview/createHeaderFooterString.ts @@ -0,0 +1,43 @@ +import { HeaderFooterToken, HeaderFooterTokenType } from '../../types'; +import { applyDecimalFormat, applyLengthControl, formatDate } from '../../utils/exportPreviewUtils'; +import { guardNumberPositive } from '../../utils/guardNumber'; + +export function tokenToString(token: HeaderFooterToken, amount: number, count: number): string { + switch (token.type) { + case HeaderFooterTokenType.ARBITRARY_TEXT: + return token.text ?? ''; + case HeaderFooterTokenType.NEWLINE: + return '\n'; + case HeaderFooterTokenType.NEWLINE_MICROSOFT: + return '\r\n'; + case HeaderFooterTokenType.TAB: + return '\t'; + case HeaderFooterTokenType.COMMA: + return ','; + case HeaderFooterTokenType.SPACE: + return ' '.repeat(guardNumberPositive(token.repeat)); + + case HeaderFooterTokenType.CURRENT_DATE: + return applyLengthControl( + formatDate(token.format, new Date()).toString(), + token.lengthControl, + ); + + case HeaderFooterTokenType.AGGREGATE_TOTAL: + return applyLengthControl(applyDecimalFormat(amount, token.decimal), token.lengthControl); + + case HeaderFooterTokenType.AGGREGATE_COUNT: + return applyLengthControl(count.toString(), token.lengthControl); + + default: + return ''; + } +} + +export default function createHeaderFooterString( + tokens: HeaderFooterToken[], + totalAmount: number, + totalCount: number, +): string { + return tokens.map((token) => tokenToString(token, totalAmount, totalCount)).join(''); +} diff --git a/src/components/ExportPreview/createPreviewData.test.ts b/src/components/ExportPreview/createPreviewData.test.ts new file mode 100644 index 00000000..b5123b9a --- /dev/null +++ b/src/components/ExportPreview/createPreviewData.test.ts @@ -0,0 +1,138 @@ +import { CriteriaTerminalType, DataToken, DataTokenType, DateFormatType } from '../../types'; +import createPreviewData, { + formatFeeFineToken, + formatItemToken, + formatUserToken, + generateEntry, + tokenToString, +} from './createPreviewData'; + +jest.mock('@ngneat/falso', () => ({ + rand: jest.fn(([first]) => first), + randFirstName: jest.fn(() => 'FIRST_NAME'), + randFloat: jest.fn(() => 12.34), + randLastName: jest.fn(() => 'LAST_NAME'), + randNumber: jest.fn(() => 7), + randPassword: jest.fn(() => 'ALPHANUMERIC'), + randPastDate: jest.fn(() => new Date(2001, 0, 1)), + randUserName: jest.fn(() => 'USERNAME'), + randUuid: jest.fn(() => 'UUID'), + randWord: jest.fn(() => 'WORD'), + randTextRange: jest.fn(() => 'WORDS WORDS WORDS'), +})); + +describe('Preview data generation', () => { + test.each([ + ['FEE_FINE_TYPE_ID', 'UUID'], + ['FEE_FINE_TYPE_NAME', 'WORD WORD'], + ] as const)('formatFeeFineToken(%s)=%s', async (type, expected) => expect(await formatFeeFineToken(type)).toBe(expected)); + + test.each([ + ['BARCODE', 'ALPHANUMERIC'], + ['NAME', 'WORDS WORDS WORDS'], + ['MATERIAL_TYPE', 'WORD'], + ['LIBRARY_ID', 'UUID'], + ] as const)('formatItemToken(%s)=%s', async (type, expected) => expect(await formatItemToken(type)).toBe(expected)); + + test.each([ + ['FOLIO_ID', 'UUID'], + ['BARCODE', 'ALPHANUMERIC'], + ['EXTERNAL_SYSTEM_ID', 'ALPHANUMERIC'], + ['USERNAME', 'USERNAME'], + ['FIRST_NAME', 'FIRST_NAME'], + ['MIDDLE_NAME', 'LAST_NAME'], + ['LAST_NAME', 'LAST_NAME'], + ] as const)('formatUserToken(%s)=%s', async (type, expected) => expect(await formatUserToken(type)).toBe(expected)); + + const TEST_AMOUNT = 12.34; + const TEST_COUNT = 5; + + test.each<[Partial, string]>([ + [{} as DataToken, ''], + [{ type: DataTokenType.ARBITRARY_TEXT }, ''], + [{ type: DataTokenType.ARBITRARY_TEXT, text: 'foo' }, 'foo'], + [{ type: DataTokenType.NEWLINE }, '\n'], + [{ type: DataTokenType.NEWLINE_MICROSOFT }, '\r\n'], + [{ type: DataTokenType.TAB }, '\t'], + [{ type: DataTokenType.COMMA }, ','], + [{ type: DataTokenType.SPACE, repeat: '3' }, ' '], + [{ type: DataTokenType.CURRENT_DATE, format: DateFormatType.YEAR_LONG }, new Date().getFullYear().toString()], + [{ type: DataTokenType.AGGREGATE_TOTAL, decimal: true }, '12.34'], + [{ type: DataTokenType.ACCOUNT_AMOUNT, decimal: false }, '1234'], + [{ type: DataTokenType.ACCOUNT_DATE, format: DateFormatType.YEAR_LONG }, '2001'], + [ + { + type: DataTokenType.FEE_FINE_TYPE, + feeFineAttribute: 'FEE_FINE_TYPE_ID', + }, + 'UUID', + ], + [{ type: DataTokenType.ITEM_INFO }, 'UUID'], + [{ type: DataTokenType.USER_DATA, userAttribute: 'BARCODE' }, 'ALPHANUMERIC'], + [ + { + type: DataTokenType.CONSTANT_CONDITIONAL, + else: 'else', + }, + 'else', + ], + [ + { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: [{ type: CriteriaTerminalType.PASS, value: 'foo' }], + else: 'else', + }, + 'foo', + ], + [{ type: DataTokenType.AGGREGATE_COUNT }, '5'], + ])('tokenToString(%o)=%s', async (token, expected) => expect(await tokenToString(token as DataToken, TEST_AMOUNT, TEST_COUNT)).toBe(expected)); + + test.each([ + [true, 7, ['7', ',', '12.34']], + [false, 1, ['1', ',', '12.34']], + ])('generateEntry(aggregate=%s) gives count=%s and elements=%s', async (aggregate, expectedCount, expectedElements) => expect( + await generateEntry( + [ + { type: DataTokenType.AGGREGATE_COUNT }, + { type: DataTokenType.COMMA }, + { type: DataTokenType.AGGREGATE_TOTAL, decimal: true }, + ], + aggregate, + ), + ).toStrictEqual({ + amount: 12.34, + count: expectedCount, + elements: expectedElements, + })); + + test.each([ + [ + [ + { type: DataTokenType.AGGREGATE_COUNT }, + { type: DataTokenType.SPACE, repeat: 1 }, + { type: DataTokenType.AGGREGATE_TOTAL, decimal: true }, + { type: DataTokenType.COMMA }, + ], + false, + '1 12.34,'.repeat(7), + 7, + ], + [ + [ + { type: DataTokenType.AGGREGATE_COUNT }, + { type: DataTokenType.SPACE, repeat: 1 }, + { type: DataTokenType.AGGREGATE_TOTAL, decimal: true }, + { type: DataTokenType.COMMA }, + ], + true, + '7 12.34,'.repeat(7), + 7 * 7, + ], + ])('createPreviewData(%s, %s) to be data=%s, count=%s', async (tokens, aggregate, expectedData, expectedCount) => { + expect(await createPreviewData(tokens as DataToken[], aggregate)).toHaveProperty('dataPreview', expectedData); + expect(await createPreviewData(tokens as DataToken[], aggregate)).toHaveProperty('totalCount', expectedCount); + + // must round to test because floats + expect((await createPreviewData(tokens as DataToken[], aggregate)).totalAmount.toFixed(2)).toBe((12.34 * 7).toFixed(2)); + }); +}); diff --git a/src/components/ExportPreview/createPreviewData.ts b/src/components/ExportPreview/createPreviewData.ts new file mode 100644 index 00000000..86657979 --- /dev/null +++ b/src/components/ExportPreview/createPreviewData.ts @@ -0,0 +1,164 @@ +import { DataToken, DataTokenType, ItemAttribute, UserAttribute } from '../../types'; +import { applyDecimalFormat, applyLengthControl, formatDate } from '../../utils/exportPreviewUtils'; +import { guardNumberPositive } from '../../utils/guardNumber'; + +async function lazyLoadFaker() { + const module = await import('@ngneat/falso'); + return module; +} + +export async function formatFeeFineToken( + attribute: 'FEE_FINE_TYPE_ID' | 'FEE_FINE_TYPE_NAME', +): Promise { + const faker = await lazyLoadFaker(); + if (attribute === 'FEE_FINE_TYPE_ID') { + return faker.randUuid(); + } else { + return `${faker.randWord()} ${faker.randWord()}`; + } +} + +export async function formatItemToken(attribute: ItemAttribute) { + const faker = await lazyLoadFaker(); + switch (attribute) { + case 'BARCODE': + return faker.randPassword({ size: 11 }); + case 'NAME': + return faker.randTextRange({ min: 10, max: 50 }); + case 'MATERIAL_TYPE': + return faker.randWord(); + default: + return faker.randUuid(); + } +} + +export async function formatUserToken(attribute: UserAttribute) { + const faker = await lazyLoadFaker(); + switch (attribute) { + case 'BARCODE': + case 'EXTERNAL_SYSTEM_ID': + return faker.randPassword({ size: 10 }); + case 'USERNAME': + return faker.randUserName(); + case 'FIRST_NAME': + return faker.randFirstName(); + case 'MIDDLE_NAME': + return faker.randLastName(); + case 'LAST_NAME': + return faker.randLastName(); + default: + return faker.randUuid(); + } +} + +export async function tokenToString( + dataToken: DataToken, + amount: number, + count: number, +): Promise { + const faker = await lazyLoadFaker(); + switch (dataToken.type) { + case DataTokenType.ARBITRARY_TEXT: + return dataToken.text ?? ''; + case DataTokenType.NEWLINE: + return '\n'; + case DataTokenType.NEWLINE_MICROSOFT: + return '\r\n'; + case DataTokenType.TAB: + return '\t'; + case DataTokenType.COMMA: + return ','; + case DataTokenType.SPACE: + return ' '.repeat(guardNumberPositive(dataToken.repeat)); + + case DataTokenType.CURRENT_DATE: + return applyLengthControl( + formatDate(dataToken.format, new Date()).toString(), + dataToken.lengthControl, + ); + + case DataTokenType.AGGREGATE_TOTAL: + case DataTokenType.ACCOUNT_AMOUNT: + return applyLengthControl( + applyDecimalFormat(amount, dataToken.decimal), + dataToken.lengthControl, + ); + + case DataTokenType.ACCOUNT_DATE: + return applyLengthControl( + formatDate(dataToken.format, faker.randPastDate()).toString(), + dataToken.lengthControl, + ); + + case DataTokenType.FEE_FINE_TYPE: + return applyLengthControl( + await formatFeeFineToken(dataToken.feeFineAttribute), + dataToken.lengthControl, + ); + + case DataTokenType.ITEM_INFO: + return applyLengthControl( + await formatItemToken(dataToken.itemAttribute), + dataToken.lengthControl, + ); + + case DataTokenType.USER_DATA: + return applyLengthControl( + await formatUserToken(dataToken.userAttribute), + dataToken.lengthControl, + ); + + case DataTokenType.CONSTANT_CONDITIONAL: + return faker.rand([ + ...(dataToken.conditions ?? []).map((cond) => cond.value).filter((v) => v), + dataToken.else, + ]); + + case DataTokenType.AGGREGATE_COUNT: + return applyLengthControl(count.toString(), dataToken.lengthControl); + + default: + return ''; + } +} + +export async function generateEntry( + tokens: DataToken[], + isAggregate: boolean, +): Promise<{ elements: string[]; amount: number; count: number }> { + const faker = await lazyLoadFaker(); + const amount = faker.randFloat({ min: 5, max: 100, precision: 0.01 }); + const count = isAggregate ? faker.randNumber({ min: 1, max: 10 }) : 1; + + const elements = await Promise.all(tokens.map((token) => tokenToString(token, amount, count))); + + return Promise.resolve({ + elements, + amount, + count, + }); +} + +export default async function createPreviewData( + tokens: DataToken[], + isAggregate: boolean, +): Promise<{ dataPreview: string; totalAmount: number; totalCount: number }> { + const faker = await lazyLoadFaker(); + const numEntries = faker.randNumber({ min: 3, max: 12 }); + + const results: string[] = []; + let totalAmount = 0; + let totalCount = 0; + for (let i = 0; i < numEntries; i++) { + const { elements, amount, count } = await generateEntry(tokens, isAggregate); + results.push(...elements); + totalAmount += amount; + totalCount += count; + } + + return Promise.resolve({ + dataPreview: results.join(''), + totalAmount, + totalCount, + }); +} diff --git a/src/components/ExportPreview/index.ts b/src/components/ExportPreview/index.ts new file mode 100644 index 00000000..b924d028 --- /dev/null +++ b/src/components/ExportPreview/index.ts @@ -0,0 +1 @@ +export { default } from './ExportPreviewData'; diff --git a/src/components/FormSection/AggregateSection.test.tsx b/src/components/FormSection/AggregateSection.test.tsx new file mode 100644 index 00000000..eae8a31e --- /dev/null +++ b/src/components/FormSection/AggregateSection.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { CriteriaAggregateType, FormValues } from '../../types'; +import AggregateSection from './AggregateSection'; + +test('Aggregate section displays criteria on check', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( + onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + , + ), + ); + + // do not display criteria initially + expect(screen.queryByRole('combobox')).toBeNull(); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(submitter).toHaveBeenCalledWith({ + aggregate: false, + }); + + await userEvent.click(screen.getByRole('checkbox')); + expect(await screen.findByRole('combobox')).toBeVisible(); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(submitter).toHaveBeenCalledWith({ + aggregate: true, + aggregateFilter: { + type: CriteriaAggregateType.PASS, + }, + }); + + await userEvent.click(screen.getByRole('checkbox')); + expect(screen.queryByRole('combobox')).toBeNull(); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(submitter).toHaveBeenCalledWith({ + aggregate: false, + aggregateFilter: { + type: CriteriaAggregateType.PASS, + }, + }); +}); diff --git a/src/components/FormSection/AggregateSection.tsx b/src/components/FormSection/AggregateSection.tsx new file mode 100644 index 00000000..f82e8b49 --- /dev/null +++ b/src/components/FormSection/AggregateSection.tsx @@ -0,0 +1,37 @@ +import { Checkbox } from '@folio/stripes/components'; +import React from 'react'; +import { Field, useField } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { AggregateCriteriaCard } from '../Criteria'; + +export default function AggregateSection() { + const isAggregateEnabled = useField('aggregate', { + subscription: { value: true }, + format: (value) => value ?? false, + }).input.value; + + const intl = useIntl(); + + return ( +
+ + {(fieldProps) => ( + + )} + +

+ + + +

+ + {isAggregateEnabled && } +
+ ); +} diff --git a/src/components/FormSection/CriteriaSection.buttons.test.tsx b/src/components/FormSection/CriteriaSection.buttons.test.tsx new file mode 100644 index 00000000..fb0268fe --- /dev/null +++ b/src/components/FormSection/CriteriaSection.buttons.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { CriteriaGroupType, FormValues } from '../../types'; +import CriteriaSection from './CriteriaSection'; + +describe('Buttons work as expected', () => { + const submitter = jest.fn(); + + function renderWithValue(value: FormValues['criteria']) { + render( + withIntlConfiguration( + + mutators={{ ...arrayMutators }} + onSubmit={(v) => submitter(v)} + initialValues={{ criteria: value }} + > + {({ handleSubmit }) => ( +
+ + + + )} + , + ), + ); + } + + it('Outer add button works as expected', async () => { + renderWithValue({ type: CriteriaGroupType.ALL_OF }); + + await userEvent.click(screen.getByRole('button', { name: 'plus-sign' })); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + criteria: { + type: CriteriaGroupType.ALL_OF, + criteria: [ + { + type: CriteriaGroupType.ALL_OF, + criteria: [], + }, + ], + }, + }); + }); + + it('Inner add button works as expected', async () => { + renderWithValue({ + type: CriteriaGroupType.ALL_OF, + criteria: [{ type: CriteriaGroupType.ANY_OF, criteria: [] }], + }); + + await userEvent.click((await screen.findAllByRole('button', { name: 'plus-sign' }))[1]); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + criteria: { + type: CriteriaGroupType.ALL_OF, + criteria: [ + { + type: CriteriaGroupType.ANY_OF, + criteria: [ + { + type: CriteriaGroupType.ALL_OF, + criteria: [], + }, + ], + }, + ], + }, + }); + }); + + it('Delete button works as expected', async () => { + renderWithValue({ + type: CriteriaGroupType.ALL_OF, + criteria: [ + { type: CriteriaGroupType.ANY_OF, criteria: [] }, + { type: CriteriaGroupType.NONE_OF, criteria: [] }, + ], + }); + + await userEvent.click((await screen.findAllByRole('button', { name: 'trash' }))[0]); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + criteria: { + type: CriteriaGroupType.ALL_OF, + criteria: [ + { + type: CriteriaGroupType.NONE_OF, + criteria: [], + }, + ], + }, + }); + }); +}); diff --git a/src/components/FormSection/CriteriaSection.tsx b/src/components/FormSection/CriteriaSection.tsx new file mode 100644 index 00000000..132adcc2 --- /dev/null +++ b/src/components/FormSection/CriteriaSection.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { CriteriaCard } from '../Criteria'; + +export default function CriteriaSection() { + return ; +} diff --git a/src/components/FormSection/DataTokenSection.test.tsx b/src/components/FormSection/DataTokenSection.test.tsx new file mode 100644 index 00000000..46ca3fab --- /dev/null +++ b/src/components/FormSection/DataTokenSection.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { DataTokenType, FormValues } from '../../types'; +import DataTokenSection from './DataTokenSection'; + +test('Add button works as expected', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + , + ), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + data: [ + { type: DataTokenType.NEWLINE }, + { type: DataTokenType.NEWLINE }, + { type: DataTokenType.NEWLINE }, + ], + }); +}); + +test('Add button in aggregate mode works as expected', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( + + mutators={{ ...arrayMutators }} + onSubmit={(v) => submitter(v)} + initialValues={{ aggregate: true }} + > + {({ handleSubmit }) => ( +
+ + + + )} + , + ), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + aggregate: true, + dataAggregate: [ + { type: DataTokenType.NEWLINE }, + { type: DataTokenType.NEWLINE }, + { type: DataTokenType.NEWLINE }, + ], + }); +}); diff --git a/src/components/FormSection/DataTokenSection.tsx b/src/components/FormSection/DataTokenSection.tsx new file mode 100644 index 00000000..0e0a53d2 --- /dev/null +++ b/src/components/FormSection/DataTokenSection.tsx @@ -0,0 +1,42 @@ +import { Button } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { FieldArray } from 'react-final-form-arrays'; +import { useField } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import { DataTokenCard } from '../Token'; +import { DataTokenType } from '../../types'; + +export default function DataTokenSection() { + const aggregate = useField('aggregate', { + subscription: { value: true }, + format: (value) => value ?? false, + }).input.value; + + const name = useMemo(() => { + if (aggregate) { + return 'dataAggregate'; + } else { + return 'data'; + } + }, [aggregate]); + + return ( + + {({ fields }) => ( + <> + {fields.map((innerName, index) => ( + + ))} + + + )} + + ); +} diff --git a/src/components/FormSection/ExportPreviewSection.module.css b/src/components/FormSection/ExportPreviewSection.module.css new file mode 100644 index 00000000..2296cc65 --- /dev/null +++ b/src/components/FormSection/ExportPreviewSection.module.css @@ -0,0 +1,16 @@ +.preview { + padding: 0.5rem; + + font-family: monospace; + font-size: 1rem; + + overflow-x: auto; + white-space: pre; +} + +.preview.wrap { + overflow-x: initial; + white-space: pre-wrap; + line-break: anywhere; + word-break: break-all; +} diff --git a/src/components/FormSection/ExportPreviewSection.test.tsx b/src/components/FormSection/ExportPreviewSection.test.tsx new file mode 100644 index 00000000..c8c72b6a --- /dev/null +++ b/src/components/FormSection/ExportPreviewSection.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import ExportPreviewSection from './ExportPreviewSection'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; + +jest.mock('@ngneat/falso', () => ({ + randFloat: jest.fn(() => 12.34), + randNumber: jest.fn(() => 2), +})); + +describe('Export preview component', () => { + it('wraps lines when selected', async () => { + const { container } = render( + withIntlConfiguration( +
+ {() => } + , + ), + ); + // when undefined + expect(container.querySelector('.wrap')).toBeVisible(); + + await userEvent.click(screen.getByRole('checkbox', { name: 'Wrap long lines' })); + expect(container.querySelector('.wrap')).toBeNull(); + + await userEvent.click(screen.getByRole('checkbox', { name: 'Wrap long lines' })); + expect(container.querySelector('.wrap')).toBeVisible(); + }); +}); diff --git a/src/components/FormSection/ExportPreviewSection.tsx b/src/components/FormSection/ExportPreviewSection.tsx new file mode 100644 index 00000000..9c47d208 --- /dev/null +++ b/src/components/FormSection/ExportPreviewSection.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Card, Checkbox } from '@folio/stripes/components'; +import classNames from 'classnames'; +import { Field, useField } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import ExportPreview from '../ExportPreview'; +import css from './ExportPreviewSection.module.css'; + +export default function ExportPreviewSection() { + const wrap = useField('preview.wrap', { + subscription: { value: true }, + format: (value) => value ?? true, + }).input.value; + + const intl = useIntl(); + + return ( + <> + } + bodyClass={classNames(css.preview, { [css.wrap]: wrap })} + > + + +

+ + + +

+ + {(fieldProps) => ( + + )} + + + {(fieldProps) => ( + + )} + + + ); +} diff --git a/src/components/FormSection/HeaderFooterSection.test.tsx b/src/components/FormSection/HeaderFooterSection.test.tsx new file mode 100644 index 00000000..8963e3d5 --- /dev/null +++ b/src/components/FormSection/HeaderFooterSection.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues, HeaderFooterTokenType } from '../../types'; +import HeaderFooterSection from './HeaderFooterSection'; + +test('Add button works as expected', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + , + ), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + test: [ + { type: HeaderFooterTokenType.NEWLINE }, + { type: HeaderFooterTokenType.NEWLINE }, + { type: HeaderFooterTokenType.NEWLINE }, + ], + }); +}); diff --git a/src/components/FormSection/HeaderFooterSection.tsx b/src/components/FormSection/HeaderFooterSection.tsx new file mode 100644 index 00000000..7026acaf --- /dev/null +++ b/src/components/FormSection/HeaderFooterSection.tsx @@ -0,0 +1,29 @@ +import { Button } from '@folio/stripes/components'; +import React from 'react'; +import { FieldArray } from 'react-final-form-arrays'; +import { FormattedMessage } from 'react-intl'; +import { HeaderFooterTokenType } from '../../types'; +import { HeaderFooterCard } from '../Token'; + +export default function HeaderFooterSection({ name }: Readonly<{ name: string }>) { + return ( + + {({ fields }) => ( + <> + {fields.map((innerName, index) => ( + + ))} + + + )} + + ); +} diff --git a/src/components/FormSection/SchedulingSection.test.tsx b/src/components/FormSection/SchedulingSection.test.tsx new file mode 100644 index 00000000..dbe31656 --- /dev/null +++ b/src/components/FormSection/SchedulingSection.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues, SchedulingFrequency } from '../../types'; +import SchedulingSection from './SchedulingSection'; + +describe('Scheduling section', () => { + it('Manual (never) option does not show extra fields', () => { + render( + withIntlConfiguration( + + onSubmit={jest.fn()} + initialValues={{ + scheduling: { frequency: SchedulingFrequency.Manual }, + }} + > + {() => } + , + ), + ); + + // no interval, weekdays, or time + expect(screen.queryAllByRole('textbox')).toHaveLength(0); + }); + + it('Hours option shows interval option only', () => { + render( + withIntlConfiguration( + + onSubmit={jest.fn()} + initialValues={{ + scheduling: { frequency: SchedulingFrequency.Hours }, + }} + > + {() => } + , + ), + ); + + expect(screen.queryAllByRole('textbox')).toHaveLength(1); + expect(screen.getByRole('textbox', { name: 'Hours between runs' })).toBeVisible(); + }); + + it('Days option shows interval and start time options only', () => { + render( + withIntlConfiguration( + + onSubmit={jest.fn()} + initialValues={{ + scheduling: { frequency: SchedulingFrequency.Days }, + }} + > + {() => } + , + ), + ); + + expect(screen.queryAllByRole('textbox')).toHaveLength(2); + expect(screen.getByRole('textbox', { name: 'Days between runs' })).toBeVisible(); + expect( + screen.getByRole('textbox', { + name: (name: string) => name.startsWith('Start time'), + }), + ).toBeVisible(); + }); + + it('Weeks option shows all options', () => { + render( + withIntlConfiguration( + + onSubmit={jest.fn()} + initialValues={{ + scheduling: { frequency: SchedulingFrequency.Weeks }, + }} + > + {() => } + , + ), + ); + + expect(screen.queryAllByRole('textbox')).toHaveLength(3); + expect(screen.getByRole('textbox', { name: 'Weeks between runs' })).toBeVisible(); + expect( + screen.getByRole('textbox', { + name: (name: string) => name.startsWith('Start time'), + }), + ).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Run on weekdays' })).toBeVisible(); + }); +}); diff --git a/src/components/FormSection/SchedulingSection.tsx b/src/components/FormSection/SchedulingSection.tsx new file mode 100644 index 00000000..8dd09bda --- /dev/null +++ b/src/components/FormSection/SchedulingSection.tsx @@ -0,0 +1,126 @@ +import { + Col, + MultiSelection, + MultiSelectionDefaultOptionType, + Row, + Select, + TextField, + Timepicker, +} from '@folio/stripes/components'; +import React from 'react'; +import { Field, useField } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { Weekday } from '../../utils/weekdayUtils'; +import { SchedulingFrequency } from '../../types'; +import useLocaleWeekdays from '../../hooks/useLocaleWeekdays'; + +export default function SchedulingSection() { + const frequencyValue = useField('scheduling.frequency', { + subscription: { value: true }, + }).input.value; + + const intl = useIntl(); + const localeWeekdays = useLocaleWeekdays(intl); + + const manualFrequencyComponent = ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + required + label={} + dataOptions={[ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.scheduling.frequency.manual', + }), + value: SchedulingFrequency.Manual, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.scheduling.frequency.hours', + }), + value: SchedulingFrequency.Hours, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.scheduling.frequency.days', + }), + value: SchedulingFrequency.Days, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.scheduling.frequency.weeks', + }), + value: SchedulingFrequency.Weeks, + }, + ]} + /> + )} + + + ); + + const hoursDaysWeeksFrequencyComponent = ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + required + label={ + + } + min={1} + /> + )} + + + ); + + const daysWeeksFrequencyComponent = ( + + + {(fieldProps) => ( + + )} + + + ); + + const weeksFrequencyComponent = ( + + + {(fieldProps) => ( + > + {...fieldProps} + required + label={} + dataOptions={localeWeekdays.map((weekday) => ({ + label: weekday.long, + value: weekday.weekday, + }))} + /> + )} + + + ); + + return ( + + {manualFrequencyComponent} + {[SchedulingFrequency.Hours, SchedulingFrequency.Days, SchedulingFrequency.Weeks].includes(frequencyValue) && hoursDaysWeeksFrequencyComponent} + {[SchedulingFrequency.Days, SchedulingFrequency.Weeks].includes(frequencyValue) && daysWeeksFrequencyComponent} + {frequencyValue === SchedulingFrequency.Weeks && weeksFrequencyComponent} + + ); +} diff --git a/src/components/FormSection/TransferInfoSection.module.css b/src/components/FormSection/TransferInfoSection.module.css new file mode 100644 index 00000000..99bbefe3 --- /dev/null +++ b/src/components/FormSection/TransferInfoSection.module.css @@ -0,0 +1,3 @@ +.evaluationDescription { + margin: 0; +} diff --git a/src/components/FormSection/TransferInfoSection.test.tsx b/src/components/FormSection/TransferInfoSection.test.tsx new file mode 100644 index 00000000..c735c031 --- /dev/null +++ b/src/components/FormSection/TransferInfoSection.test.tsx @@ -0,0 +1,208 @@ +import { act, render, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { ComparisonOperator, CriteriaTerminalType } from '../../types'; +import TransferInfoSection from './TransferInfoSection'; + +const getResponse = jest.fn((endpoint: string) => { + if (endpoint.startsWith('owners')) { + return { + owners: [ + { + id: 'owner1id', + owner: 'Owner 1', + }, + { + id: 'owner2id', + owner: 'Owner 2', + }, + ], + }; + } else if (endpoint.startsWith('transfers')) { + return { + transfers: [ + { + id: 'owner1account1id', + accountName: 'Owner 1 account 1', + ownerId: 'owner1id', + }, + { + id: 'owner1account2id', + accountName: 'Owner 1 account 2', + ownerId: 'owner1id', + }, + { + id: 'owner2accountId', + accountName: 'Owner 2 account', + ownerId: 'owner2id', + }, + ], + }; + } else if (endpoint.startsWith('groups')) { + return { + usergroups: [ + { id: '1', group: 'staff' }, + { id: '2', group: 'undergraduate' }, + ], + }; + } else { + fail(`Unexpected endpoint: ${endpoint}`); + return {}; + } +}); + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: () => ({ + get: (endpoint: string) => ({ + json: () => Promise.resolve(getResponse(endpoint)), + }), + }), +})); + +describe('Transfer criteria section', () => { + it('Displays labels appropriately depending on number of conditions', async () => { + render( + withIntlConfiguration( + +
+ {() => } + +
, + ), + ); + + expect(screen.getByText('Transfer to:')).toBeVisible(); + expect(screen.queryByText('Otherwise:')).toBeNull(); + expect(screen.queryByText(/Conditions will be evaluated in order/)).toBeNull(); + + await act(() => userEvent.click(screen.getByRole('button', { name: 'Add condition' }))); + await waitFor(() => { + expect(screen.queryByText('Transfer to:')).toBeNull(); + expect(screen.getByText('Otherwise:')).toBeVisible(); + screen.debug(); + expect(screen.queryByText(/Conditions will be evaluated in order/)).toBeVisible(); + }); + + await act(() => userEvent.click(screen.getAllByRole('button', { name: 'trash' })[0])); + + expect(screen.getByText('Transfer to:')).toBeVisible(); + expect(screen.queryByText('Otherwise:')).toBeNull(); + expect(screen.queryByText(/Conditions will be evaluated in order/)).toBeNull(); + }); + + describe('buttons work as expected', () => { + const submitter = jest.fn(); + + beforeEach(() => { + render( + withIntlConfiguration( + +
submitter(v)} + initialValues={{ + transferInfo: { + conditions: [ + { + condition: { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '10', + }, + owner: 'owner1id', + account: 'owner1account1id', + }, + { + condition: { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '20', + }, + owner: 'owner1id', + account: 'owner1account2id', + }, + ], + else: { + owner: 'owner2id', + account: 'owner2accountId', + }, + }, + }} + > + {({ handleSubmit }) => ( + + + + + )} + +
, + ), + ); + }); + + it('add works as expected', async () => { + await act(() => userEvent.click(screen.getByRole('button', { name: 'Add condition' }))); + await act(async () => { + await userEvent.selectOptions( + screen.getAllByRole('combobox', { name: 'Criteria' })[2], + 'Patron group', + ); + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Patron group' }), + 'staff', + ); + await userEvent.selectOptions( + screen.getAllByRole('combobox', { name: 'Fee/fine owner' })[2], + 'Owner 2', + ); + await userEvent.selectOptions( + screen.getAllByRole('combobox', { name: 'Transfer account' })[2], + 'Owner 2 account', + ); + }); + await act(() => userEvent.click(screen.getByRole('button', { name: 'Submit' }))); + + expect(submitter).toHaveBeenLastCalledWith({ + transferInfo: { + conditions: [ + { + condition: { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '10', + }, + owner: 'owner1id', + account: 'owner1account1id', + }, + { + condition: { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '20', + }, + owner: 'owner1id', + account: 'owner1account2id', + }, + { + condition: { + type: CriteriaTerminalType.PATRON_GROUP, + patronGroupId: '1', + }, + owner: 'owner2id', + account: 'owner2accountId', + }, + ], + else: { + owner: 'owner2id', + account: 'owner2accountId', + }, + }, + }); + }); + }); +}); diff --git a/src/components/FormSection/TransferInfoSection.tsx b/src/components/FormSection/TransferInfoSection.tsx new file mode 100644 index 00000000..87868975 --- /dev/null +++ b/src/components/FormSection/TransferInfoSection.tsx @@ -0,0 +1,57 @@ +import { Button, Card } from '@folio/stripes/components'; +import React from 'react'; +import { FieldArray } from 'react-final-form-arrays'; +import { FormattedMessage } from 'react-intl'; +import { CriteriaTerminalType } from '../../types'; +import ConditionalCard from '../ConditionalCard'; +import TransferAccountFields from '../TransferAccountFields'; +import css from './TransferInfoSection.module.css'; + +export default function TransferInfoSection() { + return ( + + {({ fields }) => ( + <> + {fields.map((name, index) => ( + + + + ))} + + + ) : ( + + ) + } + > + + + + + + {!!fields.length && ( +

+ + + +

+ )} + + )} +
+ ); +} diff --git a/src/components/FormSection/index.ts b/src/components/FormSection/index.ts new file mode 100644 index 00000000..2b06f304 --- /dev/null +++ b/src/components/FormSection/index.ts @@ -0,0 +1,7 @@ +export { default as AggregateSection } from './AggregateSection'; +export { default as CriteriaSection } from './CriteriaSection'; +export { default as DataTokenSection } from './DataTokenSection'; +export { default as ExportPreviewSection } from './ExportPreviewSection'; +export { default as HeaderFooterSection } from './HeaderFooterSection'; +export { default as SchedulingSection } from './SchedulingSection'; +export { default as TransferInfoSection } from './TransferInfoSection'; diff --git a/src/components/Token/Data/AccountDateToken.test.tsx b/src/components/Token/Data/AccountDateToken.test.tsx new file mode 100644 index 00000000..5cb10634 --- /dev/null +++ b/src/components/Token/Data/AccountDateToken.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { DataTokenType } from '../../../types'; +import DataTokenCardBody from './DataTokenCardBody'; + +describe('Account date token', () => { + it('displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} initialValues={{ test: { type: DataTokenType.ACCOUNT_DATE } }}> + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Format' }), 'Quarter'); + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Timezone' }), 'UTC'); + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Date' }), 'Last updated date'); + await userEvent.type(screen.getByRole('textbox', { name: 'Fallback value' }), 'placeholder'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: DataTokenType.ACCOUNT_DATE, + dateProperty: 'UPDATED', + format: 'QUARTER', + timezone: 'UTC', + placeholder: 'placeholder', + }, + }); + }); +}); diff --git a/src/components/Token/Data/AccountDateToken.tsx b/src/components/Token/Data/AccountDateToken.tsx new file mode 100644 index 00000000..30e3d217 --- /dev/null +++ b/src/components/Token/Data/AccountDateToken.tsx @@ -0,0 +1,76 @@ +import { Col, Select, TextField } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import DatePartPicker from '../Shared/DatePartPicker'; +import TimezonePicker from '../Shared/TimezonePicker'; +import css from '../TokenStyles.module.css'; + +export default function AccountDateToken({ prefix }: Readonly<{ prefix: string }>) { + const intl = useIntl(); + return ( + <> + + name={`${prefix}dateProperty`} defaultValue="CREATED"> + {(fieldProps) => ( + + {...fieldProps} + required + label={} + dataOptions={[ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.accountDate.dateType.created', + }), + value: 'CREATED', + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.accountDate.dateType.updated', + }), + value: 'UPDATED', + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.accountDate.dateType.dueItem', + }), + value: 'DUE', + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.accountDate.dateType.dueLoan', + }), + value: 'RETURNED', + }, + ]} + /> + )} + + + + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + label={} + /> + )} + + + + + + +

+ + + +

+ + + ); +} diff --git a/src/components/Token/Data/ConstantConditionalToken.test.tsx b/src/components/Token/Data/ConstantConditionalToken.test.tsx new file mode 100644 index 00000000..ee448d68 --- /dev/null +++ b/src/components/Token/Data/ConstantConditionalToken.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { ComparisonOperator, CriteriaGroupType, CriteriaTerminalType, DataTokenType } from '../../../types'; +import DataTokenCardBody from './DataTokenCardBody'; + +describe('Constant conditional token', () => { + it('add works as expected', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ + test: { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: [ + { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '10', + value: 'if value 1', + }, + { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '20', + value: 'if value 2', + }, + ], + else: 'fallback else', + }, + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Add condition' })); + await userEvent.type(screen.getAllByRole('textbox', { name: 'Then use:' })[2], 'if value 3'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: DataTokenType.CONSTANT_CONDITIONAL, + conditions: [ + { + type: CriteriaTerminalType.AGE, + operator: ComparisonOperator.GREATER_THAN, + numDays: '10', + value: 'if value 1', + }, + { + type: CriteriaTerminalType.AMOUNT, + operator: ComparisonOperator.GREATER_THAN, + amountCurrency: '20', + value: 'if value 2', + }, + { + type: CriteriaGroupType.ALL_OF, + value: 'if value 3', + }, + ], + else: 'fallback else', + }, + }); + }); + + describe('aggregate is respected', () => { + test.each([ + [undefined, true], + [true, false], + [false, true], + ])('if aggregate=%s then item/etc options should be present=%s', (aggregate, shouldHaveNonAggregateCriteria) => { + render( + withIntlConfiguration( +
+ {() => } + , + ), + ); + + if (shouldHaveNonAggregateCriteria) { + expect(screen.getByRole('option', { name: 'Item location' })).toBeInTheDocument(); + } else { + expect(screen.queryByRole('option', { name: 'Item location' })).toBeNull(); + } + }); + }); +}); diff --git a/src/components/Token/Data/ConstantConditionalToken.tsx b/src/components/Token/Data/ConstantConditionalToken.tsx new file mode 100644 index 00000000..df003a50 --- /dev/null +++ b/src/components/Token/Data/ConstantConditionalToken.tsx @@ -0,0 +1,75 @@ +import { Button, Card, Col, TextField } from '@folio/stripes/components'; +import React from 'react'; +import { Field, useField } from 'react-final-form'; +import { FieldArray } from 'react-final-form-arrays'; +import { FormattedMessage } from 'react-intl'; +import { CriteriaGroupType, CriteriaTerminalType, CriteriaGroup, CriteriaTerminal } from '../../../types'; +import ConditionalCard from '../../ConditionalCard'; +import css from '../TokenStyles.module.css'; + +export default function ConstantConditionalToken({ prefix }: Readonly<{ prefix: string }>) { + const aggregate = useField('aggregate', { + subscription: { value: true }, + format: (value) => value ?? false, + }).input.value; + + return ( + + name={`${prefix}conditions`} + defaultValue={[{ type: CriteriaTerminalType.PATRON_GROUP }]} + > + {({ fields }) => ( + <> + + {fields.map((name, index) => ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + required + label={ + + } + /> + )} + + + ))} + + + }> + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + required + label={} + /> + )} + + + + + +

+ + + +

+ + + )} + + ); +} diff --git a/src/components/Token/Data/DataTokenCard.test.tsx b/src/components/Token/Data/DataTokenCard.test.tsx new file mode 100644 index 00000000..fe59b85f --- /dev/null +++ b/src/components/Token/Data/DataTokenCard.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { DataTokenType } from '../../../types'; +import DataTokenCard from './DataTokenCard'; + +describe('Data token card', () => { + test('has good default value', () => { + render( + withIntlConfiguration( +
({})}> + {({ handleSubmit }) => ( + + + + )} + , + ), + ); + + expect(screen.getByRole('combobox')).toHaveDisplayValue('Newline (LF)'); + }); + + it('respects initial value', () => { + render( + withIntlConfiguration( +
({})} + initialValues={{ data: [{ type: DataTokenType.COMMA }] }} + > + {({ handleSubmit }) => ( + + + + )} + , + ), + ); + + expect(screen.getByRole('combobox')).toHaveDisplayValue('Comma'); + }); +}); diff --git a/src/components/Token/Data/DataTokenCard.tsx b/src/components/Token/Data/DataTokenCard.tsx new file mode 100644 index 00000000..91f3d607 --- /dev/null +++ b/src/components/Token/Data/DataTokenCard.tsx @@ -0,0 +1,35 @@ +import React, { useCallback } from 'react'; +import { useField } from 'react-final-form'; +import { DataTokenType } from '../../../types'; +import GenericTokenCard from '../GenericTokenCard'; +import { TOKEN_TYPES_WITH_LENGTH_CONTROL } from '../LengthControlDrawer'; +import DataTokenCardBody, { isDataBodyEmpty } from './DataTokenCardBody'; +import DataTypeSelect from './DataTypeSelect'; + +export interface DataTokenCardProps { + name: string; + index: number; + isLast: boolean; +} + +export default function DataTokenCard({ name, index, isLast }: Readonly) { + const type = useField(`${name}.type`, { + subscription: { value: true }, + format: (value) => value ?? DataTokenType.NEWLINE, + }).input.value; + + const shouldHaveLengthControl = useCallback(() => TOKEN_TYPES_WITH_LENGTH_CONTROL.includes(type), [type]); + + return ( + + fieldArrayName="data" + name={name} + index={index} + isLast={isLast} + SelectComponent={DataTypeSelect} + BodyComponent={DataTokenCardBody} + isBodyEmpty={isDataBodyEmpty} + shouldHaveLengthControl={shouldHaveLengthControl} + /> + ); +} diff --git a/src/components/Token/Data/DataTokenCardBody.empty.test.tsx b/src/components/Token/Data/DataTokenCardBody.empty.test.tsx new file mode 100644 index 00000000..3fcceb73 --- /dev/null +++ b/src/components/Token/Data/DataTokenCardBody.empty.test.tsx @@ -0,0 +1,49 @@ +import { render } from '@folio/jest-config-stripes/testing-library/react'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { DataTokenType } from '../../../types'; +import DataTokenCardBody, { isDataBodyEmpty } from './DataTokenCardBody'; + +test.each([ + [undefined, true], + + [DataTokenType.NEWLINE, true], + [DataTokenType.NEWLINE_MICROSOFT, true], + [DataTokenType.TAB, true], + [DataTokenType.COMMA, true], + [DataTokenType.AGGREGATE_COUNT, true], + + [DataTokenType.ARBITRARY_TEXT, false], + [DataTokenType.SPACE, false], + [DataTokenType.CURRENT_DATE, false], + [DataTokenType.ACCOUNT_AMOUNT, false], + [DataTokenType.ACCOUNT_DATE, false], + [DataTokenType.FEE_FINE_TYPE, false], + [DataTokenType.ITEM_INFO, false], + [DataTokenType.USER_DATA, false], + [DataTokenType.CONSTANT_CONDITIONAL, false], +])('Card bodies for type %s are empty = %s', (type, expected) => expect(isDataBodyEmpty(type)).toBe(expected)); + +test.each([ + undefined, + DataTokenType.NEWLINE, + DataTokenType.NEWLINE_MICROSOFT, + DataTokenType.TAB, + DataTokenType.COMMA, + DataTokenType.AGGREGATE_COUNT, +])('Card bodies for type %s result in empty div', (type) => { + const { container } = render( + withIntlConfiguration( +
({})} initialValues={{ test: { type } }}> + {({ handleSubmit }) => ( + + + + )} + , + ), + ); + + expect(container.textContent).toBe(''); +}); diff --git a/src/components/Token/Data/DataTokenCardBody.tsx b/src/components/Token/Data/DataTokenCardBody.tsx new file mode 100644 index 00000000..b9965066 --- /dev/null +++ b/src/components/Token/Data/DataTokenCardBody.tsx @@ -0,0 +1,99 @@ +import { Row } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { useField } from 'react-final-form'; +import { DataTokenType } from '../../../types'; +import AmountWithDecimalToken from '../Shared/AmountWithDecimalToken'; +import ArbitraryTextToken from '../Shared/ArbitraryTextToken'; +import CurrentDateToken from '../Shared/CurrentDateToken'; +import WhitespaceToken from '../Shared/WhitespaceToken'; +import AccountDateToken from './AccountDateToken'; +import FeeFineTypeToken from './FeeFineTypeToken'; +import ItemInfoToken from './ItemInfoToken'; +import UserInfoToken from './UserInfoToken'; +import ConstantConditionalToken from './ConstantConditionalToken'; + +export const EMPTY_BODY_TYPES = [ + DataTokenType.COMMA, + DataTokenType.NEWLINE, + DataTokenType.NEWLINE_MICROSOFT, + DataTokenType.TAB, + + DataTokenType.AGGREGATE_COUNT, +]; + +export function isDataBodyEmpty(type: DataTokenType | undefined) { + return EMPTY_BODY_TYPES.includes(type ?? DataTokenType.NEWLINE); +} + +export default function DataTokenCardBody({ name }: Readonly<{ name: string }>) { + const type = useField(`${name}.type`, { + subscription: { value: true }, + format: (value) => value ?? DataTokenType.NEWLINE, + }).input.value; + + const cardInterior = useMemo(() => { + switch (type) { + case DataTokenType.ARBITRARY_TEXT: + return ( + + + + ); + case DataTokenType.SPACE: + return ( + + + + ); + case DataTokenType.CURRENT_DATE: + return ( + + + + ); + + case DataTokenType.AGGREGATE_TOTAL: + case DataTokenType.ACCOUNT_AMOUNT: + return ( + + + + ); + case DataTokenType.ACCOUNT_DATE: + return ( + + + + ); + case DataTokenType.FEE_FINE_TYPE: + return ( + + + + ); + case DataTokenType.ITEM_INFO: + return ( + + + + ); + case DataTokenType.USER_DATA: + return ( + + + + ); + case DataTokenType.CONSTANT_CONDITIONAL: + return ( + + + + ); + + default: + return
; + } + }, [type, name]); + + return
{cardInterior}
; +} diff --git a/src/components/Token/Data/DataTypeSelect.test.tsx b/src/components/Token/Data/DataTypeSelect.test.tsx new file mode 100644 index 00000000..0e825287 --- /dev/null +++ b/src/components/Token/Data/DataTypeSelect.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { DataTokenType } from '../../../types'; +import DataTypeSelect from './DataTypeSelect'; + +describe('Data token type selection', () => { + it('has correct default', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)}> + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('combobox')).toHaveDisplayValue('Newline (LF)'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(submitter).toHaveBeenLastCalledWith({ + test: DataTokenType.NEWLINE, + }); + }); + + it('respects initial values', () => { + render( + withIntlConfiguration( +
({})} + initialValues={{ + test: DataTokenType.FEE_FINE_TYPE, + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('combobox')).toHaveDisplayValue('Fee/fine type'); + }); + + it.each([ + ['Newline (LF)', undefined], + ['Newline (LF)', false], + ['Newline (LF)', true], + + ['Item info', undefined], + ['Item info', false], + + ['Total amount', true], + ['Number of accounts', true], + ])('has %s when aggregate=%s', (optionName, aggregate) => { + render( + withIntlConfiguration( +
({})} initialValues={{ aggregate }}> + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('option', { name: optionName })).toBeInTheDocument(); + }); + + it.each([ + ['Item info', true], + + ['Total amount', undefined], + ['Total amount', false], + ['Number of accounts', undefined], + ['Number of accounts', false], + ])('does not have %s when aggregate=%s', (optionName, aggregate) => { + render( + withIntlConfiguration( +
({})} initialValues={{ aggregate }}> + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.queryByRole('option', { name: optionName })).toBeNull(); + }); +}); diff --git a/src/components/Token/Data/DataTypeSelect.tsx b/src/components/Token/Data/DataTypeSelect.tsx new file mode 100644 index 00000000..48bd82e5 --- /dev/null +++ b/src/components/Token/Data/DataTypeSelect.tsx @@ -0,0 +1,164 @@ +import { Select } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field, useField } from 'react-final-form'; +import { useIntl } from 'react-intl'; +import { DataTokenType } from '../../../types'; + +export default function DataTypeSelect({ name }: Readonly<{ name: string }>) { + const isAggregate = useField('aggregate', { + subscription: { value: true }, + format: (value) => value ?? false, + }).input.value; + + const intl = useIntl(); + + const alwaysAvailableOptions = useMemo(() => { + const topOptions = [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.newline', + }), + value: DataTokenType.NEWLINE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.newlineMicrosoft', + }), + value: DataTokenType.NEWLINE_MICROSOFT, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.tab', + }), + value: DataTokenType.TAB, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.comma', + }), + value: DataTokenType.COMMA, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.whitespace', + }), + value: DataTokenType.SPACE, + }, + ].sort((a, b) => a.label.localeCompare(b.label)); + + const bottomOptions = [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.arbitraryText', + }), + value: DataTokenType.ARBITRARY_TEXT, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate', + }), + value: DataTokenType.CURRENT_DATE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.constantConditional', + }), + value: DataTokenType.CONSTANT_CONDITIONAL, + }, + ].sort((a, b) => a.label.localeCompare(b.label)); + + return [ + ...topOptions, + { + label: '', + value: DataTokenType.NEWLINE, + disabled: true, + }, + ...bottomOptions, + { + label: '', + value: DataTokenType.NEWLINE, + disabled: true, + }, + ]; + }, [intl]); + + const noneAggregateOptions = useMemo( + () => [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.userData', + }), + value: DataTokenType.USER_DATA, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.accountAmount', + }), + value: DataTokenType.ACCOUNT_AMOUNT, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.accountDate', + }), + value: DataTokenType.ACCOUNT_DATE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.feeFineType', + }), + value: DataTokenType.FEE_FINE_TYPE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.itemInfo', + }), + value: DataTokenType.ITEM_INFO, + }, + ].sort((a, b) => a.label.localeCompare(b.label)), + [intl], + ); + + const aggregateOptions = useMemo( + () => [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.userData', + }), + value: DataTokenType.USER_DATA, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.totalAmount', + }), + value: DataTokenType.AGGREGATE_TOTAL, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.numAccounts', + }), + value: DataTokenType.AGGREGATE_COUNT, + }, + ].sort((a, b) => a.label.localeCompare(b.label)), + [intl], + ); + + return ( + + {(fieldProps) => ( + + {...fieldProps} + required + marginBottom0 + dataOptions={[...alwaysAvailableOptions, ...(isAggregate ? aggregateOptions : noneAggregateOptions)]} + /> + )} + + ); +} diff --git a/src/components/Token/Data/FeeFineTypeToken.test.tsx b/src/components/Token/Data/FeeFineTypeToken.test.tsx new file mode 100644 index 00000000..34b38272 --- /dev/null +++ b/src/components/Token/Data/FeeFineTypeToken.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { DataTokenType } from '../../../types'; +import DataTokenCardBody from './DataTokenCardBody'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; + +describe('Fee/fine type token', () => { + it('displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} initialValues={{ test: { type: DataTokenType.FEE_FINE_TYPE } }}> + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: DataTokenType.FEE_FINE_TYPE, + feeFineAttribute: 'FEE_FINE_TYPE_NAME', + }, + }); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Attribute' }), 'Type ID'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: DataTokenType.FEE_FINE_TYPE, + feeFineAttribute: 'FEE_FINE_TYPE_ID', + }, + }); + }); +}); diff --git a/src/components/Token/Data/FeeFineTypeToken.tsx b/src/components/Token/Data/FeeFineTypeToken.tsx new file mode 100644 index 00000000..ed6897a3 --- /dev/null +++ b/src/components/Token/Data/FeeFineTypeToken.tsx @@ -0,0 +1,39 @@ +import { Col, Select } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; + +export default function FeeFineTypeToken({ prefix }: Readonly<{ prefix: string }>) { + const intl = useIntl(); + return ( + + + name={`${prefix}feeFineAttribute`} + defaultValue="FEE_FINE_TYPE_NAME" + > + {(fieldProps) => ( + + {...fieldProps} + required + marginBottom0 + label={} + dataOptions={[ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.feeFineType.name', + }), + value: 'FEE_FINE_TYPE_NAME', + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.feeFineType.id', + }), + value: 'FEE_FINE_TYPE_ID', + }, + ]} + /> + )} + + + ); +} diff --git a/src/components/Token/Data/ItemInfoToken.test.tsx b/src/components/Token/Data/ItemInfoToken.test.tsx new file mode 100644 index 00000000..40019693 --- /dev/null +++ b/src/components/Token/Data/ItemInfoToken.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { DataTokenType } from '../../../types'; +import DataTokenCardBody from './DataTokenCardBody'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; + +describe('Item info type token', () => { + it('displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ test: { type: DataTokenType.ITEM_INFO } }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Value' }), 'Institution ID'); + await userEvent.type(screen.getByRole('textbox', { name: 'Fallback value' }), 'foo bar fallback'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: DataTokenType.ITEM_INFO, + itemAttribute: 'INSTITUTION_ID', + placeholder: 'foo bar fallback', + }, + }); + }); +}); diff --git a/src/components/Token/Data/ItemInfoToken.tsx b/src/components/Token/Data/ItemInfoToken.tsx new file mode 100644 index 00000000..d94b5840 --- /dev/null +++ b/src/components/Token/Data/ItemInfoToken.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import { ItemAttribute } from '../../../types'; +import UserItemInfoToken from './UserItemInfoToken'; + +export default function ItemInfoToken({ prefix }: Readonly<{ prefix: string }>) { + const intl = useIntl(); + const options = useMemo(() => { + const attributeOptions = [ + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.itemInfo.name', + value: 'NAME', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.itemInfo.barcode', + value: 'BARCODE', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.itemInfo.material', + value: 'MATERIAL_TYPE', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.itemInfo.instId', + value: 'INSTITUTION_ID', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.itemInfo.campId', + value: 'CAMPUS_ID', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.itemInfo.libId', + value: 'LIBRARY_ID', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.itemInfo.locId', + value: 'LOCATION_ID', + }, + ]; + return attributeOptions + .map((option) => ({ + label: intl.formatMessage({ id: option.labelId }), + value: option.value as ItemAttribute, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [intl]); + + return ( + + prefix={prefix} + defaultValue="NAME" + attributeName="itemAttribute" + options={options} + /> + ); +} diff --git a/src/components/Token/Data/UserInfoToken.test.tsx b/src/components/Token/Data/UserInfoToken.test.tsx new file mode 100644 index 00000000..e903b3f7 --- /dev/null +++ b/src/components/Token/Data/UserInfoToken.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { DataTokenType } from '../../../types'; +import DataTokenCardBody from './DataTokenCardBody'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; + +describe('Item info type token', () => { + it('displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ test: { type: DataTokenType.USER_DATA } }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Value' }), 'Patron group ID'); + await userEvent.type(screen.getByRole('textbox', { name: 'Fallback value' }), 'foo bar fallback'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: DataTokenType.USER_DATA, + userAttribute: 'PATRON_GROUP_ID', + placeholder: 'foo bar fallback', + }, + }); + }); +}); diff --git a/src/components/Token/Data/UserInfoToken.tsx b/src/components/Token/Data/UserInfoToken.tsx new file mode 100644 index 00000000..691be536 --- /dev/null +++ b/src/components/Token/Data/UserInfoToken.tsx @@ -0,0 +1,59 @@ +import React, { useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import UserItemInfoToken from './UserItemInfoToken'; +import { UserAttribute } from '../../../types'; + +export default function UserInfoToken({ prefix }: Readonly<{ prefix: string }>) { + const intl = useIntl(); + + const options = useMemo(() => { + const attributeOptions = [ + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.userInfo.folioId', + value: 'FOLIO_ID', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.userInfo.extId', + value: 'EXTERNAL_SYSTEM_ID', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.userInfo.groupId', + value: 'PATRON_GROUP_ID', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.userInfo.barcode', + value: 'BARCODE', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.userInfo.username', + value: 'USERNAME', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.userInfo.firstname', + value: 'FIRST_NAME', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.userInfo.middlename', + value: 'MIDDLE_NAME', + }, + { + labelId: 'ui-plugin-bursar-export.bursarExports.token.userInfo.lastname', + value: 'LAST_NAME', + }, + ]; + + return attributeOptions.map((option) => ({ + label: intl.formatMessage({ id: option.labelId }), + value: option.value as UserAttribute, + })); + }, [intl]); + + return ( + + prefix={prefix} + defaultValue="EXTERNAL_SYSTEM_ID" + attributeName="userAttribute" + options={options} + /> + ); +} diff --git a/src/components/Token/Data/UserItemInfoToken.tsx b/src/components/Token/Data/UserItemInfoToken.tsx new file mode 100644 index 00000000..4052d51c --- /dev/null +++ b/src/components/Token/Data/UserItemInfoToken.tsx @@ -0,0 +1,53 @@ +import { Col, Select, SelectOptionType, TextField } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import { ItemAttribute, UserAttribute } from '../../../types'; +import css from '../TokenStyles.module.css'; + +export default function UserItemInfoToken({ + defaultValue, + prefix, + attributeName, + options, +}: Readonly<{ + defaultValue: T; + prefix: string; + attributeName: string; + options: SelectOptionType[]; +}>) { + return ( + <> + + name={`${prefix}${attributeName}`} defaultValue={defaultValue}> + {(fieldProps) => ( + + {...fieldProps} + required + label={} + dataOptions={options} + /> + )} + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + label={} + /> + )} + + + +

+ + + +

+ + + ); +} diff --git a/src/components/Token/GenericTokenCard.test.tsx b/src/components/Token/GenericTokenCard.test.tsx new file mode 100644 index 00000000..8cd39bd3 --- /dev/null +++ b/src/components/Token/GenericTokenCard.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import GenericTokenCard from './GenericTokenCard'; + +describe('Generic token card', () => { + it('Shows length control when state is true and length control is available', () => { + render( + withIntlConfiguration( +
({})} + initialValues={{ test: [{ lengthControl: { drawerOpen: true } }] }} + > + {({ handleSubmit }) => ( + + + fieldArrayName="test" + name="test[0]" + index={0} + isLast + SelectComponent={() => null} + BodyComponent={() => null} + isBodyEmpty={() => true} + shouldHaveLengthControl={() => true} + /> + + )} + , + ), + ); + + expect(screen.getByRole('textbox')).toBeVisible(); + }); + + it.each([ + [undefined, true], + [undefined, false], + [true, false], + [false, true], + [false, false], + ])('Does not show length control when state is %s and length control is %s available', (state, available) => { + render( + withIntlConfiguration( +
({})} + initialValues={{ test: [{ lengthControl: { drawerOpen: state } }] }} + > + {({ handleSubmit }) => ( + + + fieldArrayName="test" + name="test[0]" + index={0} + isLast + SelectComponent={() => null} + BodyComponent={() => null} + isBodyEmpty={() => true} + shouldHaveLengthControl={() => available} + /> + + )} + , + ), + ); + + expect(screen.queryByRole('textbox')).toBeNull(); + }); +}); diff --git a/src/components/Token/GenericTokenCard.tsx b/src/components/Token/GenericTokenCard.tsx new file mode 100644 index 00000000..f91a584a --- /dev/null +++ b/src/components/Token/GenericTokenCard.tsx @@ -0,0 +1,70 @@ +import React, { useMemo } from 'react'; +import { Card } from '@folio/stripes/components'; +import classNames from 'classnames'; +import { useField } from 'react-final-form'; +import css from '../Card.module.css'; +import TokenCardToolbox from './TokenCardToolbox'; +import LengthControlDrawer from './LengthControlDrawer'; + +export interface GenericTokenCardProps { + fieldArrayName: string; + name: string; + index: number; + isLast: boolean; + SelectComponent: React.ComponentType<{ name: string }>; + BodyComponent: React.ComponentType<{ name: string }>; + isBodyEmpty: (type?: TypeEnum) => boolean; + shouldHaveLengthControl: (type?: TypeEnum) => boolean; +} + +export default function GenericTokenCard({ + fieldArrayName, + name, + index, + isLast, + SelectComponent, + BodyComponent, + isBodyEmpty, + shouldHaveLengthControl, +}: Readonly>) { + const type = useField(`${name}.type`, { + subscription: { value: true }, + // preserve undefined + format: (value) => value, + }).input.value; + + // cache this since we use it multiple times + const lengthControlAvailable = useMemo(() => shouldHaveLengthControl(type), [type, shouldHaveLengthControl]); + + const lengthControlOpen = useField(`${name}.lengthControl.drawerOpen`, { + subscription: { value: true }, + format: (value) => value ?? false, + }).input.value; + + return ( + <> + } + headerEnd={ + + } + bodyClass={classNames({ + [css.emptyBody]: isBodyEmpty(type), + })} + > + + + {lengthControlAvailable && lengthControlOpen && } + + ); +} diff --git a/src/components/Token/HeaderFooter/HeaderFooterCard.test.tsx b/src/components/Token/HeaderFooter/HeaderFooterCard.test.tsx new file mode 100644 index 00000000..42a91d5d --- /dev/null +++ b/src/components/Token/HeaderFooter/HeaderFooterCard.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import HeaderFooterCard from './HeaderFooterCard'; +import { HeaderFooterTokenType } from '../../../types'; + +describe('Header/footer card', () => { + test('has good default value', () => { + render( + withIntlConfiguration( +
({})}> + {({ handleSubmit }) => ( + + + + )} + , + ), + ); + + expect(screen.getByRole('combobox')).toHaveDisplayValue('Newline (LF)'); + }); + + it('respects initial value', () => { + render( + withIntlConfiguration( +
({})} + initialValues={{ test: [{ type: HeaderFooterTokenType.COMMA }] }} + > + {({ handleSubmit }) => ( + + + + )} + , + ), + ); + + expect(screen.getByRole('combobox')).toHaveDisplayValue('Comma'); + }); +}); diff --git a/src/components/Token/HeaderFooter/HeaderFooterCard.tsx b/src/components/Token/HeaderFooter/HeaderFooterCard.tsx new file mode 100644 index 00000000..67429f40 --- /dev/null +++ b/src/components/Token/HeaderFooter/HeaderFooterCard.tsx @@ -0,0 +1,44 @@ +import React, { useCallback } from 'react'; +import { useField } from 'react-final-form'; +import { HeaderFooterTokenType } from '../../../types'; +import GenericTokenCard from '../GenericTokenCard'; +import HeaderFooterTypeSelect from './HeaderFooterTypeSelect'; +import { TOKEN_TYPES_WITH_LENGTH_CONTROL } from '../LengthControlDrawer'; +import HeaderFooterCardBody, { isHeaderFooterBodyEmpty } from './HeaderFooterCardBody'; + +export interface HeaderFooterCardProps { + fieldArrayName: string; + name: string; + index: number; + isLast: boolean; +} + +export default function HeaderFooterCard({ + fieldArrayName, + name, + index, + isLast, +}: Readonly) { + const type = useField(`${name}.type`, { + subscription: { value: true }, + format: (value) => value ?? HeaderFooterTokenType.NEWLINE, + }).input.value; + + const shouldHaveLengthControl = useCallback( + () => TOKEN_TYPES_WITH_LENGTH_CONTROL.includes(type), + [type], + ); + + return ( + + fieldArrayName={fieldArrayName} + name={name} + index={index} + isLast={isLast} + SelectComponent={HeaderFooterTypeSelect} + BodyComponent={HeaderFooterCardBody} + isBodyEmpty={isHeaderFooterBodyEmpty} + shouldHaveLengthControl={shouldHaveLengthControl} + /> + ); +} diff --git a/src/components/Token/HeaderFooter/HeaderFooterCardBody.test.tsx b/src/components/Token/HeaderFooter/HeaderFooterCardBody.test.tsx new file mode 100644 index 00000000..fb271a4b --- /dev/null +++ b/src/components/Token/HeaderFooter/HeaderFooterCardBody.test.tsx @@ -0,0 +1,44 @@ +import { render } from '@folio/jest-config-stripes/testing-library/react'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { HeaderFooterTokenType } from '../../../types'; +import HeaderFooterCardBody, { isHeaderFooterBodyEmpty } from './HeaderFooterCardBody'; + +test.each([ + [undefined, true], + + [HeaderFooterTokenType.NEWLINE, true], + [HeaderFooterTokenType.NEWLINE_MICROSOFT, true], + [HeaderFooterTokenType.TAB, true], + [HeaderFooterTokenType.COMMA, true], + [HeaderFooterTokenType.AGGREGATE_COUNT, true], + + [HeaderFooterTokenType.ARBITRARY_TEXT, false], + [HeaderFooterTokenType.SPACE, false], + [HeaderFooterTokenType.CURRENT_DATE, false], + [HeaderFooterTokenType.AGGREGATE_TOTAL, false], +])('Card bodies for type %s are empty = %s', (type, expected) => expect(isHeaderFooterBodyEmpty(type)).toBe(expected)); + +test.each([ + undefined, + HeaderFooterTokenType.NEWLINE, + HeaderFooterTokenType.NEWLINE_MICROSOFT, + HeaderFooterTokenType.TAB, + HeaderFooterTokenType.COMMA, + HeaderFooterTokenType.AGGREGATE_COUNT, +])('Card bodies for type %s result in empty div', (type) => { + const { container } = render( + withIntlConfiguration( +
({})} initialValues={{ test: { type } }}> + {({ handleSubmit }) => ( + + + + )} + , + ), + ); + + expect(container.textContent).toBe(''); +}); diff --git a/src/components/Token/HeaderFooter/HeaderFooterCardBody.tsx b/src/components/Token/HeaderFooter/HeaderFooterCardBody.tsx new file mode 100644 index 00000000..da806ef4 --- /dev/null +++ b/src/components/Token/HeaderFooter/HeaderFooterCardBody.tsx @@ -0,0 +1,61 @@ +import { Row } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { useField } from 'react-final-form'; +import { HeaderFooterTokenType } from '../../../types'; +import ArbitraryTextToken from '../Shared/ArbitraryTextToken'; +import CurrentDateToken from '../Shared/CurrentDateToken'; +import WhitespaceToken from '../Shared/WhitespaceToken'; +import AmountWithDecimalToken from '../Shared/AmountWithDecimalToken'; + +export const EMPTY_BODY_TYPES = [ + HeaderFooterTokenType.AGGREGATE_COUNT, + HeaderFooterTokenType.COMMA, + HeaderFooterTokenType.NEWLINE, + HeaderFooterTokenType.NEWLINE_MICROSOFT, + HeaderFooterTokenType.TAB, +]; + +export function isHeaderFooterBodyEmpty(type: HeaderFooterTokenType | undefined) { + return EMPTY_BODY_TYPES.includes(type ?? HeaderFooterTokenType.NEWLINE); +} + +export default function HeaderFooterCardBody({ name }: Readonly<{ name: string }>) { + const type = useField(`${name}.type`, { + subscription: { value: true }, + format: (value) => value ?? HeaderFooterTokenType.NEWLINE, + }).input.value; + + const cardInterior = useMemo(() => { + switch (type) { + case HeaderFooterTokenType.ARBITRARY_TEXT: + return ( + + + + ); + case HeaderFooterTokenType.SPACE: + return ( + + + + ); + case HeaderFooterTokenType.CURRENT_DATE: + return ( + + + + ); + case HeaderFooterTokenType.AGGREGATE_TOTAL: + return ( + + + + ); + + default: + return
; + } + }, [type, name]); + + return
{cardInterior}
; +} diff --git a/src/components/Token/HeaderFooter/HeaderFooterTypeSelect.test.tsx b/src/components/Token/HeaderFooter/HeaderFooterTypeSelect.test.tsx new file mode 100644 index 00000000..c646e80c --- /dev/null +++ b/src/components/Token/HeaderFooter/HeaderFooterTypeSelect.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { HeaderFooterTokenType } from '../../../types'; +import HeaderFooterTypeSelect from './HeaderFooterTypeSelect'; + +describe('Header/footer type selection', () => { + it('has correct default', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)}> + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('combobox')).toHaveDisplayValue('Newline (LF)'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(submitter).toHaveBeenLastCalledWith({ + test: HeaderFooterTokenType.NEWLINE, + }); + }); + + it('respects initial values', () => { + render( + withIntlConfiguration( +
({})} + initialValues={{ + test: HeaderFooterTokenType.ARBITRARY_TEXT, + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('combobox')).toHaveDisplayValue('Text'); + }); +}); diff --git a/src/components/Token/HeaderFooter/HeaderFooterTypeSelect.tsx b/src/components/Token/HeaderFooter/HeaderFooterTypeSelect.tsx new file mode 100644 index 00000000..d04a6978 --- /dev/null +++ b/src/components/Token/HeaderFooter/HeaderFooterTypeSelect.tsx @@ -0,0 +1,95 @@ +import React, { useMemo } from 'react'; +import { Select } from '@folio/stripes/components'; +import { Field } from 'react-final-form'; +import { useIntl } from 'react-intl'; +import { HeaderFooterTokenType } from '../../../types'; + +export default function HeaderFooterTypeSelect({ name }: Readonly<{ name: string }>) { + const intl = useIntl(); + const options = useMemo(() => { + const topSection = [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.newline', + }), + value: HeaderFooterTokenType.NEWLINE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.newlineMicrosoft', + }), + value: HeaderFooterTokenType.NEWLINE_MICROSOFT, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.tab', + }), + value: HeaderFooterTokenType.TAB, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.comma', + }), + value: HeaderFooterTokenType.COMMA, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.whitespace', + }), + value: HeaderFooterTokenType.SPACE, + }, + ]; + + const bottomSection = [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.arbitraryText', + }), + value: HeaderFooterTokenType.ARBITRARY_TEXT, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate', + }), + value: HeaderFooterTokenType.CURRENT_DATE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.numAccounts', + }), + value: HeaderFooterTokenType.AGGREGATE_COUNT, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.totalAmount', + }), + value: HeaderFooterTokenType.AGGREGATE_TOTAL, + }, + ]; + + topSection.sort((a, b) => a.label.localeCompare(b.label)); + bottomSection.sort((a, b) => a.label.localeCompare(b.label)); + + return [ + ...topSection, + { + label: '', + value: HeaderFooterTokenType.NEWLINE, + disabled: true, + }, + ...bottomSection, + ]; + }, [intl]); + + return ( + + {(fieldProps) => {...fieldProps} required marginBottom0 dataOptions={options} />} + + ); +} diff --git a/src/components/Token/LengthControlDrawer.test.tsx b/src/components/Token/LengthControlDrawer.test.tsx new file mode 100644 index 00000000..52da0dae --- /dev/null +++ b/src/components/Token/LengthControlDrawer.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import { FormValues } from '../../types'; +import LengthControlDrawer from './LengthControlDrawer'; + +describe('Length control drawer', () => { + const submitter = jest.fn(); + + beforeEach(() => { + render( + withIntlConfiguration( + mutators={{ ...arrayMutators }} onSubmit={(v) => submitter(v)}> + {({ handleSubmit }) => ( +
+ + + + )} + , + ), + ); + }); + + it('displays all fields', () => { + expect(screen.getByLabelText('Desired length')).toBeVisible(); + expect(screen.getByLabelText('Fill extra space with')).toBeVisible(); + expect(screen.getByLabelText('Add characters to')).toBeVisible(); + expect(screen.getByLabelText('Truncate if too long')).toBeVisible(); + }); + + it('gives correct result if truncate/direction not touched', async () => { + await userEvent.type(screen.getByLabelText('Desired length'), '8'); + // should be truncated + await userEvent.type(screen.getByLabelText('Fill extra space with'), 'abc'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + test: { + length: '8', + character: 'a', + direction: 'FRONT', + truncate: false, + }, + }); + }); + + it('gives correct result if truncate and direction changed from default', async () => { + await userEvent.type(screen.getByLabelText('Desired length'), '12'); + await userEvent.type(screen.getByLabelText('Fill extra space with'), ' '); + await userEvent.selectOptions(screen.getByLabelText('Add characters to'), 'BACK'); + await userEvent.click(screen.getByLabelText('Truncate if too long')); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenCalledWith({ + test: { + length: '12', + character: ' ', + direction: 'BACK', + truncate: true, + }, + }); + }); + + it('updates front/back label appropriately', async () => { + expect(screen.getByLabelText('Add characters to')).toBeVisible(); + await userEvent.click(screen.getByLabelText('Truncate if too long')); + expect(screen.getByLabelText('Add/remove characters to/from')).toBeVisible(); + await userEvent.click(screen.getByLabelText('Truncate if too long')); + expect(screen.getByLabelText('Add characters to')).toBeVisible(); + }); +}); diff --git a/src/components/Token/LengthControlDrawer.tsx b/src/components/Token/LengthControlDrawer.tsx new file mode 100644 index 00000000..efebf745 --- /dev/null +++ b/src/components/Token/LengthControlDrawer.tsx @@ -0,0 +1,100 @@ +import { Card, Checkbox, Col, Row, Select, TextField } from '@folio/stripes/components'; +import React from 'react'; +import { Field, useField } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { DataTokenType, HeaderFooterTokenType } from '../../types'; + +export const TOKEN_TYPES_WITH_LENGTH_CONTROL = [ + HeaderFooterTokenType.CURRENT_DATE, + HeaderFooterTokenType.AGGREGATE_COUNT, + HeaderFooterTokenType.AGGREGATE_TOTAL, + + DataTokenType.CURRENT_DATE, + DataTokenType.ACCOUNT_AMOUNT, + DataTokenType.ACCOUNT_DATE, + DataTokenType.FEE_FINE_TYPE, + DataTokenType.ITEM_INFO, + DataTokenType.USER_DATA, + + DataTokenType.AGGREGATE_COUNT, + DataTokenType.AGGREGATE_TOTAL, +]; + +function FakeHeader() { + return
; +} + +export default function LengthControlDrawer({ prefix }: Readonly<{ prefix: string }>) { + const intl = useIntl(); + + const isTruncateEnabled = useField(`${prefix}truncate`, { + subscription: { value: true }, + format: (value) => value ?? false, + }).input.value; + + return ( + }> + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + type="number" + min={1} + label={} + /> + )} + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + maxLength={1} + label={} + /> + )} + + + + name={`${prefix}direction`} defaultValue="FRONT"> + {(fieldProps) => ( + + {...fieldProps} + marginBottom0 + label={ + isTruncateEnabled + ? + : + } + dataOptions={[ + { label: intl.formatMessage({ id: 'ui-plugin-bursar-export.bursarExports.lengthControl.direction.front' }), value: 'FRONT' }, + { label: intl.formatMessage({ id: 'ui-plugin-bursar-export.bursarExports.lengthControl.direction.back' }), value: 'BACK' }, + ]} + /> + )} + + + + + {(fieldProps) => ( + + } + /> + )} + + + + + ); +} diff --git a/src/components/Token/Shared/AmountWithDecimalToken.test.tsx b/src/components/Token/Shared/AmountWithDecimalToken.test.tsx new file mode 100644 index 00000000..5fd48b4c --- /dev/null +++ b/src/components/Token/Shared/AmountWithDecimalToken.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { DataTokenType, HeaderFooterTokenType } from '../../../types'; +import DataTokenCardBody from '../Data/DataTokenCardBody'; +import HeaderFooterCardBody from '../HeaderFooter/HeaderFooterCardBody'; + +describe('Aggregate total token', () => { + it.each([ + [HeaderFooterTokenType.AGGREGATE_TOTAL, HeaderFooterCardBody], + [DataTokenType.ACCOUNT_AMOUNT, DataTokenCardBody], + [DataTokenType.AGGREGATE_TOTAL, DataTokenCardBody], + ])('displays appropriate form', async (type, Component) => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ + test: { type }, + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('checkbox')).toBeVisible(); + + // check default + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type, + decimal: true, + }, + }); + + await userEvent.click(screen.getByRole('checkbox')); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type, + decimal: false, + }, + }); + + await userEvent.click(screen.getByRole('checkbox')); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type, + decimal: true, + }, + }); + }); +}); diff --git a/src/components/Token/Shared/AmountWithDecimalToken.tsx b/src/components/Token/Shared/AmountWithDecimalToken.tsx new file mode 100644 index 00000000..0610f310 --- /dev/null +++ b/src/components/Token/Shared/AmountWithDecimalToken.tsx @@ -0,0 +1,31 @@ +import { Checkbox, Col } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { useIntl } from 'react-intl'; +import css from '../TokenStyles.module.css'; + +export default function AmountWithDecimalToken({ prefix }: Readonly<{ prefix: string }>) { + const intl = useIntl(); + return ( + + + {(fieldProps) => ( + + )} + +

+ + {intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.accountAmount.enableDecimal.description', + })} + +

+ + ); +} diff --git a/src/components/Token/Shared/ArbitraryTextToken.test.tsx b/src/components/Token/Shared/ArbitraryTextToken.test.tsx new file mode 100644 index 00000000..53f49dc0 --- /dev/null +++ b/src/components/Token/Shared/ArbitraryTextToken.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { DataTokenType, HeaderFooterTokenType } from '../../../types'; +import HeaderFooterCardBody from '../HeaderFooter/HeaderFooterCardBody'; +import DataTokenCardBody from '../Data/DataTokenCardBody'; + +describe('Arbitrary text token', () => { + it.each([ + [HeaderFooterTokenType.ARBITRARY_TEXT, HeaderFooterCardBody], + [DataTokenType.ARBITRARY_TEXT, DataTokenCardBody], + ])('displays appropriate form', async (type, Component) => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ + test: { type }, + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('textbox')).toBeVisible(); + + await userEvent.type(screen.getByRole('textbox'), 'Sample constant!'); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: HeaderFooterTokenType.ARBITRARY_TEXT, + text: 'Sample constant!', + }, + }); + }); +}); diff --git a/src/components/Token/Shared/ArbitraryTextToken.tsx b/src/components/Token/Shared/ArbitraryTextToken.tsx new file mode 100644 index 00000000..cf868687 --- /dev/null +++ b/src/components/Token/Shared/ArbitraryTextToken.tsx @@ -0,0 +1,22 @@ +import { Col, TextField } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; + +export default function ArbitraryTextToken({ prefix }: Readonly<{ prefix: string }>) { + return ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + /> + )} + + + ); +} diff --git a/src/components/Token/Shared/CurrentDateToken.test.tsx b/src/components/Token/Shared/CurrentDateToken.test.tsx new file mode 100644 index 00000000..1dd70c23 --- /dev/null +++ b/src/components/Token/Shared/CurrentDateToken.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { DataTokenType, HeaderFooterTokenType } from '../../../types'; +import DataTokenCardBody from '../Data/DataTokenCardBody'; +import HeaderFooterCardBody from '../HeaderFooter/HeaderFooterCardBody'; + +describe('Current date token', () => { + it.each([ + [HeaderFooterTokenType.CURRENT_DATE, HeaderFooterCardBody], + [DataTokenType.CURRENT_DATE, DataTokenCardBody], + ])('displays appropriate form', async (type, Component) => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} initialValues={{ test: { type } }}> + {({ handleSubmit }) => ( + + + + + )} + , + 'en-US', + 'America/Chicago', + ), + ); + + expect(screen.getByRole('combobox', { name: 'Format' })).toHaveDisplayValue('Year (4-digit)'); + expect(screen.getByRole('option', { name: 'Quarter' })).toBeInTheDocument(); + + expect(screen.getByRole('combobox', { name: 'Timezone' })).toHaveDisplayValue( + 'America/Chicago', + ); + expect(screen.getByRole('option', { name: 'Europe/Lisbon' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'UTC' })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: HeaderFooterTokenType.CURRENT_DATE, + format: 'YEAR_LONG', + timezone: 'America/Chicago', + }, + }); + }); +}); diff --git a/src/components/Token/Shared/CurrentDateToken.tsx b/src/components/Token/Shared/CurrentDateToken.tsx new file mode 100644 index 00000000..1c859710 --- /dev/null +++ b/src/components/Token/Shared/CurrentDateToken.tsx @@ -0,0 +1,17 @@ +import { Col } from '@folio/stripes/components'; +import React from 'react'; +import TimezonePicker from './TimezonePicker'; +import DatePartPicker from './DatePartPicker'; + +export default function CurrentDateToken({ prefix }: Readonly<{ prefix: string }>) { + return ( + <> + + + + + + + + ); +} diff --git a/src/components/Token/Shared/DatePartPicker.test.tsx b/src/components/Token/Shared/DatePartPicker.test.tsx new file mode 100644 index 00000000..cc1595ff --- /dev/null +++ b/src/components/Token/Shared/DatePartPicker.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import DatePartPicker from './DatePartPicker'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; + +describe('Date part picker', () => { + it('displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)}> + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('combobox', { name: 'Format' })).toHaveDisplayValue('Year (4-digit)'); + expect(screen.getByRole('option', { name: 'Quarter' })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + format: 'YEAR_LONG', + }); + }); +}); diff --git a/src/components/Token/Shared/DatePartPicker.tsx b/src/components/Token/Shared/DatePartPicker.tsx new file mode 100644 index 00000000..17257b6e --- /dev/null +++ b/src/components/Token/Shared/DatePartPicker.tsx @@ -0,0 +1,114 @@ +import { Select } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { DateFormatType } from '../../../types'; + +export default function DatePartPicker({ prefix }: Readonly<{ prefix: string }>) { + const intl = useIntl(); + return ( + + {(fieldProps) => ( + + {...fieldProps} + required + marginBottom0 + label={} + dataOptions={[ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.yearLong', + }), + value: DateFormatType.YEAR_LONG, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.yearShort', + }), + value: DateFormatType.YEAR_SHORT, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.dayOfYear', + }), + value: DateFormatType.DAY_OF_YEAR, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.month', + }), + value: DateFormatType.MONTH, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.date', + }), + value: DateFormatType.DATE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.hour', + }), + value: DateFormatType.HOUR, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.minute', + }), + value: DateFormatType.MINUTE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.second', + }), + value: DateFormatType.SECOND, + }, + + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.quarter', + }), + value: DateFormatType.QUARTER, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.isoWeekNum', + }), + value: DateFormatType.WEEK_OF_YEAR_ISO, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.isoWeekYear', + }), + value: DateFormatType.WEEK_YEAR_ISO, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.YYYYMMDD', + }), + value: DateFormatType.YYYYMMDD, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.YYYY-MM-DD', + }), + value: DateFormatType.YYYY_MM_DD, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.MMDDYYYY', + }), + value: DateFormatType.MMDDYYYY, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.token.currentDate.format.DDMMYYYY', + }), + value: DateFormatType.DDMMYYYY, + }, + ]} + /> + )} + + ); +} diff --git a/src/components/Token/Shared/TimezonePicker.test.tsx b/src/components/Token/Shared/TimezonePicker.test.tsx new file mode 100644 index 00000000..01046de3 --- /dev/null +++ b/src/components/Token/Shared/TimezonePicker.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration, { withIntlConfigurationAnyTimezone } from '../../../../test/util/withIntlConfiguration'; +import TimezonePicker from './TimezonePicker'; + +describe('Timezone picker', () => { + it('displays appropriate form', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)}> + {({ handleSubmit }) => ( + + + + + )} + , + 'en-US', + 'America/Chicago', + ), + ); + + expect(screen.getByRole('combobox', { name: 'Timezone' })).toHaveDisplayValue('America/Chicago'); + expect(screen.getByRole('option', { name: 'Europe/Lisbon' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'UTC' })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + timezone: 'America/Chicago', + }); + }); + + it('Assumes UTC when no known TZ', async () => { + const submitter = jest.fn(); + + render( + withIntlConfigurationAnyTimezone( +
submitter(v)}> + {({ handleSubmit }) => ( + + + + + )} + , + 'en-US', + ), + ); + + expect(screen.getByRole('combobox', { name: 'Timezone' })).toHaveDisplayValue('UTC'); + + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + timezone: 'UTC', + }); + }); +}); diff --git a/src/components/Token/Shared/TimezonePicker.tsx b/src/components/Token/Shared/TimezonePicker.tsx new file mode 100644 index 00000000..344f6ce0 --- /dev/null +++ b/src/components/Token/Shared/TimezonePicker.tsx @@ -0,0 +1,24 @@ +import { Select, timezones } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage, useIntl } from 'react-intl'; + +export default function TimezonePicker({ prefix }: Readonly<{ prefix: string }>) { + const intl = useIntl(); + + const timeZonesForSelect = useMemo(() => timezones.map(({ value }) => ({ value, label: value })), []); + + return ( + + {(fieldProps) => ( + + {...fieldProps} + required + marginBottom0 + label={} + dataOptions={timeZonesForSelect} + /> + )} + + ); +} diff --git a/src/components/Token/Shared/WhitespaceToken.test.tsx b/src/components/Token/Shared/WhitespaceToken.test.tsx new file mode 100644 index 00000000..11527fe4 --- /dev/null +++ b/src/components/Token/Shared/WhitespaceToken.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../../test/util/withIntlConfiguration'; +import { DataTokenType, HeaderFooterTokenType } from '../../../types'; +import DataTokenCardBody from '../Data/DataTokenCardBody'; +import HeaderFooterCardBody from '../HeaderFooter/HeaderFooterCardBody'; + +describe('Whitespace token', () => { + it.each([ + [HeaderFooterTokenType.SPACE, HeaderFooterCardBody], + [DataTokenType.SPACE, DataTokenCardBody], + ])('displays appropriate form', async (type, Component) => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} initialValues={{ test: { type } }}> + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('spinbutton')).toBeVisible(); + + await userEvent.type(screen.getByRole('spinbutton'), '8'); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: { + type: HeaderFooterTokenType.SPACE, + repeat: '8', + }, + }); + }); +}); diff --git a/src/components/Token/Shared/WhitespaceToken.tsx b/src/components/Token/Shared/WhitespaceToken.tsx new file mode 100644 index 00000000..0c2b4693 --- /dev/null +++ b/src/components/Token/Shared/WhitespaceToken.tsx @@ -0,0 +1,24 @@ +import { Col, TextField } from '@folio/stripes/components'; +import React from 'react'; +import { Field } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; + +export default function WhitespaceToken({ prefix }: Readonly<{ prefix: string }>) { + return ( + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + type="number" + min={1} + label={} + /> + )} + + + ); +} diff --git a/src/components/Token/TokenCardToolbox.test.tsx b/src/components/Token/TokenCardToolbox.test.tsx new file mode 100644 index 00000000..bb153f63 --- /dev/null +++ b/src/components/Token/TokenCardToolbox.test.tsx @@ -0,0 +1,180 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import withIntlConfiguration from '../../../test/util/withIntlConfiguration'; +import TokenCardToolbox from './TokenCardToolbox'; + +describe('Token card toolbox', () => { + it('handles delete button', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ + test: ['a', 'b', 'c'], + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + await userEvent.click(screen.getByRole('button', { name: 'trash' })); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ test: ['a', 'c'] }); + }); + + it('handles up/down buttons', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ + test: ['a', 'b', 'c'], + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + await userEvent.click(screen.getByRole('button', { name: 'caret-up' })); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ test: ['b', 'a', 'c'] }); + + await userEvent.click(screen.getByRole('button', { name: 'caret-down' })); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ test: ['b', 'c', 'a'] }); + }); + + it('first has disabled up arrow', () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ + test: ['a', 'b', 'c'], + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('button', { name: 'caret-up' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'caret-down' })).not.toBeDisabled(); + expect(screen.queryByRole('button', { name: 'gear' })).toBeNull(); + }); + + it('last has disabled down arrow', () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ + test: ['a', 'b', 'c'], + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + expect(screen.getByRole('button', { name: 'caret-up' })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: 'caret-down' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'gear' })).toBeVisible(); + }); + + it('length control button works', async () => { + const submitter = jest.fn(); + + render( + withIntlConfiguration( +
submitter(v)} + initialValues={{ + test: [{}], + }} + > + {({ handleSubmit }) => ( + + + + + )} + , + ), + ); + + await userEvent.click(screen.getByRole('button', { name: 'gear' })); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: [{ lengthControl: { drawerOpen: true } }], + }); + + await userEvent.click(screen.getByRole('button', { name: 'gear' })); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: [{ lengthControl: { drawerOpen: false } }], + }); + + await userEvent.click(screen.getByRole('button', { name: 'gear' })); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + + expect(submitter).toHaveBeenLastCalledWith({ + test: [{ lengthControl: { drawerOpen: true } }], + }); + }); +}); diff --git a/src/components/Token/TokenCardToolbox.tsx b/src/components/Token/TokenCardToolbox.tsx new file mode 100644 index 00000000..d1468724 --- /dev/null +++ b/src/components/Token/TokenCardToolbox.tsx @@ -0,0 +1,46 @@ +import { IconButton } from '@folio/stripes/components'; +import React, { useCallback } from 'react'; +import { useField } from 'react-final-form'; +import { useFieldArray } from 'react-final-form-arrays'; + +export interface TokenCardToolboxProps { + fieldArrayName: string; + name: string; + index: number; + isLast: boolean; + showLengthControl: boolean; +} + +export default function TokenCardToolbox({ + fieldArrayName, + name, + index, + isLast, + showLengthControl, +}: Readonly) { + const fieldArray = useFieldArray(fieldArrayName); + const lengthControlOpen = useField(`${name}.lengthControl.drawerOpen`, { + subscription: { value: true }, + format: (value) => value ?? false, + }); + + const removeCallback = useCallback(() => fieldArray.fields.remove(index), [fieldArray.fields, index]); + + const lengthControlCallback = useCallback( + () => lengthControlOpen.input.onChange(!lengthControlOpen.input.value), + [lengthControlOpen], + ); + + const moveUpCallback = useCallback(() => fieldArray.fields.swap(index, index - 1), [fieldArray.fields, index]); + + const moveDownCallback = useCallback(() => fieldArray.fields.swap(index, index + 1), [fieldArray.fields, index]); + + return ( + <> + {showLengthControl && } + + + + + ); +} diff --git a/src/components/Token/TokenStyles.module.css b/src/components/Token/TokenStyles.module.css new file mode 100644 index 00000000..fdc1e681 --- /dev/null +++ b/src/components/Token/TokenStyles.module.css @@ -0,0 +1,3 @@ +.noMargin { + margin: 0; +} diff --git a/src/components/Token/index.ts b/src/components/Token/index.ts new file mode 100644 index 00000000..cb28244f --- /dev/null +++ b/src/components/Token/index.ts @@ -0,0 +1,2 @@ +export { default as DataTokenCard } from './Data/DataTokenCard'; +export { default as HeaderFooterCard } from './HeaderFooter/HeaderFooterCard'; diff --git a/src/components/TransferAccountFields.test.tsx b/src/components/TransferAccountFields.test.tsx new file mode 100644 index 00000000..ed67bc71 --- /dev/null +++ b/src/components/TransferAccountFields.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; +import arrayMutators from 'final-form-arrays'; +import React from 'react'; +import { Form } from 'react-final-form'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import withIntlConfiguration from '../../test/util/withIntlConfiguration'; +import TransferAccountFields from './TransferAccountFields'; + +const getResponse = jest.fn((endpoint: string) => { + if (endpoint.startsWith('owners')) { + return { + owners: [ + { + id: 'owner1id', + owner: 'Owner 1', + }, + { + id: 'owner2id', + owner: 'Owner 2', + }, + ], + }; + } else if (endpoint.startsWith('transfers')) { + return { + transfers: [ + { + id: 'owner1account1id', + accountName: 'Owner 1 account 1', + ownerId: 'owner1id', + }, + { + id: 'owner1account2id', + accountName: 'Owner 1 account 2', + ownerId: 'owner1id', + }, + { + id: 'owner2accountId', + accountName: 'Owner 2 account', + ownerId: 'owner2id', + }, + ], + }; + } else { + fail(`Unexpected endpoint: ${endpoint}`); + return {}; + } +}); + +jest.mock('@folio/stripes/core', () => ({ + useOkapiKy: () => ({ + get: (endpoint: string) => ({ + json: () => Promise.resolve(getResponse(endpoint)), + }), + }), +})); + +describe('Transfer account selection', () => { + it.each([ + ['', [], ['Owner 1 account 1', 'Owner 1 account 2', 'Owner 2 account']], + ['Owner 1', ['Owner 1 account 1', 'Owner 1 account 2'], ['Owner 2 account']], + ['Owner 2', ['Owner 2 account'], ['Owner 1 account 1', 'Owner 1 account 2']], + ])('For owner %s, has options %s and not %s', async (owner, includedAccounts, excludedAccounts) => { + render( + withIntlConfiguration( + +
+ {() => } + +
, + ), + ); + + expect(await screen.findByRole('option', { name: 'Owner 1' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Owner 2' })).toBeInTheDocument(); + + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Fee/fine owner' }), owner); + + includedAccounts.forEach((account) => expect(screen.getByRole('option', { name: account })).toBeInTheDocument()); + excludedAccounts.forEach((account) => expect(screen.queryByRole('option', { name: account })).not.toBeInTheDocument()); + }); +}); diff --git a/src/components/TransferAccountFields.tsx b/src/components/TransferAccountFields.tsx new file mode 100644 index 00000000..b6227b2f --- /dev/null +++ b/src/components/TransferAccountFields.tsx @@ -0,0 +1,82 @@ +import { Col, Row, Select } from '@folio/stripes/components'; +import React, { useMemo } from 'react'; +import { Field, useField } from 'react-final-form'; +import { FormattedMessage } from 'react-intl'; +import { useFeeFineOwners, useTransferAccounts } from '../api/queries'; + +export default function TransferAccountFields({ prefix }: Readonly<{ prefix: string }>) { + const feeFineOwners = useFeeFineOwners(); + const transferAccounts = useTransferAccounts(); + + const selectedOwner = useField(`${prefix}owner`, { + subscription: { value: true }, + // provide default value for when the field is not yet initialized + format: (value) => value, + }).input.value; + + const ownersSelectOptions = useMemo(() => { + if (!feeFineOwners.isSuccess) { + return [{ label: '', value: undefined, disabled: true }]; + } + + return [ + { label: '', value: undefined, disabled: true }, + ...feeFineOwners.data + .map((owner) => ({ + label: owner.owner, + value: owner.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [feeFineOwners]); + + const accountSelectOptions = useMemo(() => { + if (!transferAccounts.isSuccess || !selectedOwner) { + return [{ label: '', value: undefined }]; + } + + return [ + { label: '', value: undefined }, + ...transferAccounts.data + .filter((type) => type.ownerId === selectedOwner) + .map((type) => ({ + label: type.accountName, + value: type.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ]; + }, [selectedOwner, transferAccounts]); + + return ( + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={ownersSelectOptions} + /> + )} + + + + + {(fieldProps) => ( + + {...fieldProps} + fullWidth + marginBottom0 + required + label={} + dataOptions={accountSelectOptions} + /> + )} + + + + ); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..c144643f --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,3 @@ +export const FORM_ID = 'ui-plugin-bursar-export-form'; + +export default ''; diff --git a/src/hooks/useCriteriaCardOptions.ts b/src/hooks/useCriteriaCardOptions.ts new file mode 100644 index 00000000..4d6f43de --- /dev/null +++ b/src/hooks/useCriteriaCardOptions.ts @@ -0,0 +1,102 @@ +import { SelectOptionType } from '@folio/stripes/components'; +import { useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import { CriteriaGroupType, CriteriaTerminalType } from '../types'; + +export default function useCriteriaCardOptions(root: boolean, patronOnly: boolean) { + const intl = useIntl(); + + return useMemo(() => { + const options: SelectOptionType[] = [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.allOf', + }), + value: CriteriaGroupType.ALL_OF, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.anyOf', + }), + value: CriteriaGroupType.ANY_OF, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.noneOf', + }), + value: CriteriaGroupType.NONE_OF, + }, + + { + label: '', + value: CriteriaTerminalType.PASS, + disabled: true, + }, + + ...(patronOnly + ? [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.patronGroup', + }), + value: CriteriaTerminalType.PATRON_GROUP, + }, + ] : [ + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.age', + }), + value: CriteriaTerminalType.AGE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.amount', + }), + value: CriteriaTerminalType.AMOUNT, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.owner', + }), + value: CriteriaTerminalType.FEE_FINE_OWNER, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.type', + }), + value: CriteriaTerminalType.FEE_FINE_TYPE, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.location', + }), + value: CriteriaTerminalType.LOCATION, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.servicePoint', + }), + value: CriteriaTerminalType.SERVICE_POINT, + }, + { + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.patronGroup', + }), + value: CriteriaTerminalType.PATRON_GROUP, + }, + ] + ).sort((a, b) => a.label.localeCompare(b.label)), + ]; + + if (root) { + options.unshift({ + label: intl.formatMessage({ + id: 'ui-plugin-bursar-export.bursarExports.criteria.select.none', + }), + value: CriteriaTerminalType.PASS, + }); + } + + return options; + }, [intl, patronOnly, root]); +} diff --git a/src/hooks/useInitialValues.test.tsx b/src/hooks/useInitialValues.test.tsx new file mode 100644 index 00000000..293ccfdb --- /dev/null +++ b/src/hooks/useInitialValues.test.tsx @@ -0,0 +1,42 @@ +import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import React from 'react'; +import useInitialValues from './useInitialValues'; +import withIntlConfiguration from '../../test/util/withIntlConfiguration'; +import { useFeeFineTypes, useCurrentConfig, useLocations, useTransferAccounts } from '../api/queries'; + +jest.mock('../api/dto/from', () => () => 'values'); + +jest.mock('../api/queries'); +(useCurrentConfig as any).mockReturnValue({ isSuccess: false }); +(useFeeFineTypes as any).mockReturnValue({ isSuccess: false }); +(useLocations as any).mockReturnValue({ isSuccess: false }); +(useTransferAccounts as any).mockReturnValue({ isSuccess: false }); + +test('initial values hook', async () => { + const { result, rerender } = renderHook(() => useInitialValues(), { + wrapper: ({ children }: { children: React.ReactNode }) => withIntlConfiguration(
{children}
), + }); + + await waitFor(() => expect(result.current).toBeNull()); + + (useCurrentConfig as any).mockReturnValue({ isSuccess: true }); + rerender(); + expect(result.current).toBeNull(); + + (useFeeFineTypes as any).mockReturnValue({ isSuccess: true }); + rerender(); + expect(result.current).toBeNull(); + + (useLocations as any).mockReturnValue({ isSuccess: true }); + rerender(); + expect(result.current).toBeNull(); + + (useTransferAccounts as any).mockReturnValue({ isSuccess: true }); + rerender(); + await waitFor(() => expect(result.current).toEqual('values')); + + // should not change back + (useTransferAccounts as any).mockReturnValue({ isSuccess: false }); + rerender(); + await waitFor(() => expect(result.current).toEqual('values')); +}); diff --git a/src/hooks/useInitialValues.ts b/src/hooks/useInitialValues.ts new file mode 100644 index 00000000..57fb3299 --- /dev/null +++ b/src/hooks/useInitialValues.ts @@ -0,0 +1,52 @@ +import { useStripes } from '@folio/stripes/core'; +import { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import dtoToFormValues from '../api/dto/from'; +import { useCurrentConfig, useFeeFineTypes, useLocations, useTransferAccounts } from '../api/queries'; +import { FormValues } from '../types'; +import useLocaleWeekdays from './useLocaleWeekdays'; + +export default function useInitialValues() { + const currentConfig = useCurrentConfig(); + + const feeFineTypes = useFeeFineTypes(); + const locations = useLocations(); + const transferAccounts = useTransferAccounts(); + + const localeWeekdays = useLocaleWeekdays(useIntl()); + + const stripes = useStripes(); + const intl = useIntl(); + + const [initialValues, setInitialValues] = useState | null>(null); + + // this must go in an effect since, otherwise, the form will be reset on query fetch (which happens when selecting certain data/criteria) + useEffect(() => { + if (initialValues !== null) { + return; + } + + if (!currentConfig.isSuccess || !feeFineTypes.isSuccess || !locations.isSuccess || !transferAccounts.isSuccess) { + return; + } + + setInitialValues( + dtoToFormValues(currentConfig.data, localeWeekdays, feeFineTypes.data, locations.data, transferAccounts.data, stripes, intl) + ); + }, [ + currentConfig.isSuccess, + localeWeekdays, + feeFineTypes.isSuccess, + locations.isSuccess, + transferAccounts.isSuccess, + initialValues, + currentConfig.data, + feeFineTypes.data, + locations.data, + transferAccounts.data, + stripes, + intl, + ]); + + return initialValues; +} diff --git a/src/hooks/useLocaleWeekdays.test.tsx b/src/hooks/useLocaleWeekdays.test.tsx new file mode 100644 index 00000000..c0a3beed --- /dev/null +++ b/src/hooks/useLocaleWeekdays.test.tsx @@ -0,0 +1,91 @@ +import { renderHook } from '@folio/jest-config-stripes/testing-library/react'; +import { IntlShape } from 'react-intl'; +import * as Weekdays from '../../test/data/Weekdays'; +import getIntl from '../../test/util/getIntl'; +import useLocaleWeekdays from './useLocaleWeekdays'; + +// United States +let intlEn: IntlShape; +// France +let intlFr: IntlShape; +// Algeria +let intlAr: IntlShape; + +beforeAll(() => { + intlEn = getIntl('en-US', 'EST'); + intlFr = getIntl('fr-FR', 'CET'); + intlAr = getIntl('ar-DZ', 'CET'); +}); + +describe('useLocaleWeekdays.', () => { + test('useLocaleWeekdays hook works like getLocaleWeekdays', () => { + let intlToTest = intlEn; + const { result, rerender } = renderHook(() => useLocaleWeekdays(intlToTest)); + + intlToTest = intlEn; + rerender(); + expect(result.current).toStrictEqual([ + { weekday: Weekdays.Sunday, long: 'Sunday', short: 'Sun', narrow: 'S' }, + { weekday: Weekdays.Monday, long: 'Monday', short: 'Mon', narrow: 'M' }, + { weekday: Weekdays.Tuesday, long: 'Tuesday', short: 'Tue', narrow: 'T' }, + { + weekday: Weekdays.Wednesday, + long: 'Wednesday', + short: 'Wed', + narrow: 'W', + }, + { weekday: Weekdays.Thursday, long: 'Thursday', short: 'Thu', narrow: 'T' }, + { weekday: Weekdays.Friday, long: 'Friday', short: 'Fri', narrow: 'F' }, + { weekday: Weekdays.Saturday, long: 'Saturday', short: 'Sat', narrow: 'S' }, + ]); + + intlToTest = intlFr; + rerender(); + expect(result.current).toStrictEqual([ + { weekday: Weekdays.Monday, long: 'lundi', short: 'lun.', narrow: 'L' }, + { weekday: Weekdays.Tuesday, long: 'mardi', short: 'mar.', narrow: 'M' }, + { + weekday: Weekdays.Wednesday, + long: 'mercredi', + short: 'mer.', + narrow: 'M', + }, + { weekday: Weekdays.Thursday, long: 'jeudi', short: 'jeu.', narrow: 'J' }, + { weekday: Weekdays.Friday, long: 'vendredi', short: 'ven.', narrow: 'V' }, + { weekday: Weekdays.Saturday, long: 'samedi', short: 'sam.', narrow: 'S' }, + { weekday: Weekdays.Sunday, long: 'dimanche', short: 'dim.', narrow: 'D' }, + ]); + + intlToTest = intlAr; + rerender(); + expect(result.current).toStrictEqual([ + { weekday: Weekdays.Saturday, long: 'السبت', short: 'السبت', narrow: 'س' }, + { weekday: Weekdays.Sunday, long: 'الأحد', short: 'الأحد', narrow: 'ح' }, + { + weekday: Weekdays.Monday, + long: 'الاثنين', + short: 'الاثنين', + narrow: 'ن', + }, + { + weekday: Weekdays.Tuesday, + long: 'الثلاثاء', + short: 'الثلاثاء', + narrow: 'ث', + }, + { + weekday: Weekdays.Wednesday, + long: 'الأربعاء', + short: 'الأربعاء', + narrow: 'ر', + }, + { + weekday: Weekdays.Thursday, + long: 'الخميس', + short: 'الخميس', + narrow: 'خ', + }, + { weekday: Weekdays.Friday, long: 'الجمعة', short: 'الجمعة', narrow: 'ج' }, + ]); + }); +}); diff --git a/src/hooks/useLocaleWeekdays.ts b/src/hooks/useLocaleWeekdays.ts new file mode 100644 index 00000000..21aeb396 --- /dev/null +++ b/src/hooks/useLocaleWeekdays.ts @@ -0,0 +1,7 @@ +import { IntlShape } from 'react-intl'; +import { useMemo } from 'react'; +import { LocaleWeekdayInfo, getLocaleWeekdays } from '../utils/weekdayUtils'; + +export default function useLocaleWeekdays(intl: IntlShape): LocaleWeekdayInfo[] { + return useMemo(() => getLocaleWeekdays(intl), [intl]); +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index e26bb3ad..00000000 --- a/src/index.js +++ /dev/null @@ -1 +0,0 @@ -export { BursarExports as default } from './BursarExports'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..c7a22daf --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export { default } from './BursarExportPlugin'; diff --git a/src/types/ConvenientConstants.ts b/src/types/ConvenientConstants.ts new file mode 100644 index 00000000..0449767d --- /dev/null +++ b/src/types/ConvenientConstants.ts @@ -0,0 +1,20 @@ +import { DataTokenType, HeaderFooterTokenType } from './TokenTypes'; + +// there are overlap between the HeaderFooterTokenType and DataTokenType, but we want to +// be explicit that we are considering/including both here. +export default { + ...{ + [HeaderFooterTokenType.NEWLINE]: '\n', + [HeaderFooterTokenType.NEWLINE_MICROSOFT]: '\r\n', + [HeaderFooterTokenType.TAB]: '\t', + [HeaderFooterTokenType.COMMA]: ',', + [HeaderFooterTokenType.SPACE]: ' ', + }, + ...{ + [DataTokenType.NEWLINE]: '\n', + [DataTokenType.NEWLINE_MICROSOFT]: '\r\n', + [DataTokenType.TAB]: '\t', + [DataTokenType.COMMA]: ',', + [DataTokenType.SPACE]: ' ', + }, +}; diff --git a/src/types/CriteriaTypes.ts b/src/types/CriteriaTypes.ts new file mode 100644 index 00000000..044d2a13 --- /dev/null +++ b/src/types/CriteriaTypes.ts @@ -0,0 +1,97 @@ +export interface CriteriaGroup { + type: CriteriaGroupType; + criteria?: (CriteriaGroup | CriteriaTerminal)[]; +} + +export enum ComparisonOperator { + LESS_THAN_EQUAL = 'LESS_THAN_EQUAL', + LESS_THAN = 'LESS_THAN', + GREATER_THAN_EQUAL = 'GREATER_THAN_EQUAL', + GREATER_THAN = 'GREATER_THAN', +} + +export enum AndOrOperator { + AND = 'AND', + OR = 'OR', +} + +export type CriteriaTerminal = + | { type: CriteriaTerminalType.PASS } + | { + type: CriteriaTerminalType.AGE; + operator?: ComparisonOperator; + numDays?: string; + } + | { + type: CriteriaTerminalType.AMOUNT; + operator?: ComparisonOperator; + amountCurrency?: string; + } + | { + type: CriteriaTerminalType.FEE_FINE_OWNER; + feeFineOwnerId?: string; + } + | { + type: CriteriaTerminalType.FEE_FINE_TYPE; + feeFineOwnerId?: string; + feeFineTypeId?: string; + } + | { + type: CriteriaTerminalType.LOCATION; + institutionId?: string; + campusId?: string; + libraryId?: string; + locationId?: string; + } + | { + type: CriteriaTerminalType.SERVICE_POINT; + servicePointId?: string; + } + | { + type: CriteriaTerminalType.PATRON_GROUP; + patronGroupId?: string; + }; + +export type CriteriaAggregate = + | { + type: CriteriaAggregateType.PASS; + } + | { + type: CriteriaAggregateType.NUM_ROWS; + operator?: ComparisonOperator; + amount?: string; + } + | { + type: CriteriaAggregateType.TOTAL_AMOUNT; + operator?: ComparisonOperator; + amountCurrency?: string; + }; + +export enum CriteriaGroupType { + ALL_OF = 'Condition-AND', + ANY_OF = 'Condition-OR', + NONE_OF = 'Condition-NOR', +} + +export enum CriteriaTerminalType { + PASS = 'Pass', + + AGE = 'Age', + AMOUNT = 'Amount', + FEE_FINE_OWNER = 'FeeFineOwner', + FEE_FINE_TYPE = 'FeeType', + LOCATION = 'Location', + SERVICE_POINT = 'ServicePoint', + PATRON_GROUP = 'PatronGroup', +} + +export enum CriteriaAggregateType { + PASS = 'Pass', + NUM_ROWS = 'NumRows', + TOTAL_AMOUNT = 'TotalAmount', +} + +export enum CriteriaTokenType { + NUM_ROWS = 'NUM_ROWS', + TOTAL_AMOUNT = 'TOTAL_AMOUNT', +} diff --git a/src/types/FormValues.ts b/src/types/FormValues.ts new file mode 100644 index 00000000..eaae2c4c --- /dev/null +++ b/src/types/FormValues.ts @@ -0,0 +1,42 @@ +/* eslint-disable semi */ +import { SelectOptionType } from '@folio/stripes/components'; +import { Weekday } from '../utils/weekdayUtils'; +import { CriteriaAggregate, CriteriaGroup, CriteriaTerminal } from './CriteriaTypes'; +import SchedulingFrequency from './SchedulingFrequency'; +import { DataToken, HeaderFooterToken } from './TokenTypes'; + +// for coverage +export const TYPE_ONLY = true; + +export default interface FormValues { + scheduling: { + frequency: SchedulingFrequency; + interval?: string; + time?: string; + weekdays?: SelectOptionType[]; + }; + + criteria?: CriteriaGroup | CriteriaTerminal; + + aggregate: boolean; + aggregateFilter?: CriteriaAggregate; + + header?: HeaderFooterToken[]; + data?: DataToken[]; + dataAggregate?: DataToken[]; + footer?: HeaderFooterToken[]; + + transferInfo?: { + conditions?: { + condition: CriteriaGroup | CriteriaTerminal; + owner?: string; + account?: string; + }[]; + else?: { + owner?: string; + account?: string; + }; + }; + + buttonClicked?: 'save' | 'manual'; +} diff --git a/src/types/LengthControl.ts b/src/types/LengthControl.ts new file mode 100644 index 00000000..e3ebc480 --- /dev/null +++ b/src/types/LengthControl.ts @@ -0,0 +1,13 @@ +/* eslint-disable semi */ + +// for coverage +export const TYPE_ONLY = true; + +export default interface LengthControl { + drawerOpen?: boolean; + + character?: string; + length: string; + direction: 'FRONT' | 'BACK'; + truncate: boolean; +} diff --git a/src/types/SchedulingFrequency.ts b/src/types/SchedulingFrequency.ts new file mode 100644 index 00000000..a786eb92 --- /dev/null +++ b/src/types/SchedulingFrequency.ts @@ -0,0 +1,8 @@ +enum SchedulingFrequency { + Manual = 'NONE', + Hours = 'HOUR', + Days = 'DAY', + Weeks = 'WEEK', +} + +export default SchedulingFrequency; diff --git a/src/types/TokenTypes.ts b/src/types/TokenTypes.ts new file mode 100644 index 00000000..bab8853e --- /dev/null +++ b/src/types/TokenTypes.ts @@ -0,0 +1,184 @@ +import { CriteriaGroup, CriteriaTerminal } from './CriteriaTypes'; +import LengthControl from './LengthControl'; + +export enum HeaderFooterTokenType { + ARBITRARY_TEXT = 'Constant', + NEWLINE = 'Newline', + NEWLINE_MICROSOFT = 'NewlineMicrosoft', + TAB = 'Tab', + COMMA = 'Comma', + SPACE = 'Space', + + CURRENT_DATE = 'CurrentDate', + AGGREGATE_COUNT = 'AggregateCount', + AGGREGATE_TOTAL = 'AggregateTotal', +} + +export enum DateFormatType { + YEAR_LONG = 'YEAR_LONG', + YEAR_SHORT = 'YEAR_SHORT', + MONTH = 'MONTH', + DATE = 'DATE', + HOUR = 'HOUR', + MINUTE = 'MINUTE', + SECOND = 'SECOND', + QUARTER = 'QUARTER', + WEEK_OF_YEAR_ISO = 'WEEK_OF_YEAR_ISO', + WEEK_YEAR_ISO = 'WEEK_YEAR_ISO', + DAY_OF_YEAR = 'DAY_OF_YEAR', + YYYYMMDD = 'YYYYMMDD', + YYYY_MM_DD = 'YYYY-MM-DD', + MMDDYYYY = 'MMDDYYYY', + DDMMYYYY = 'DDMMYYYY', +} + +export type HeaderFooterToken = + | { + type: HeaderFooterTokenType.NEWLINE; + } + | { + type: HeaderFooterTokenType.NEWLINE_MICROSOFT; + } + | { + type: HeaderFooterTokenType.TAB; + } + | { + type: HeaderFooterTokenType.COMMA; + } + | { + type: HeaderFooterTokenType.SPACE; + repeat: string; + } + | { + type: HeaderFooterTokenType.ARBITRARY_TEXT; + text: string; + } + | { + type: HeaderFooterTokenType.CURRENT_DATE; + format: DateFormatType; + timezone: string; + lengthControl?: LengthControl; + } + | { + type: HeaderFooterTokenType.AGGREGATE_COUNT; + lengthControl?: LengthControl; + } + | { + type: HeaderFooterTokenType.AGGREGATE_TOTAL; + decimal: boolean; + lengthControl?: LengthControl; + }; + +export enum DataTokenType { + ARBITRARY_TEXT = 'Constant', + NEWLINE = 'Newline', + NEWLINE_MICROSOFT = 'NewlineMicrosoft', + TAB = 'Tab', + COMMA = 'Comma', + SPACE = 'Space', + + CURRENT_DATE = 'CurrentDate', + + ACCOUNT_AMOUNT = 'FeeAmount', + ACCOUNT_DATE = 'FeeDate', + FEE_FINE_TYPE = 'FeeFineMetadata', + ITEM_INFO = 'ItemData', + USER_DATA = 'UserData', + + CONSTANT_CONDITIONAL = 'ConstantConditional', + + AGGREGATE_COUNT = 'AggregateNumRows', + AGGREGATE_TOTAL = 'AggregateTotalAmount', +} + +export type ItemAttribute = + | 'BARCODE' + | 'NAME' + | 'MATERIAL_TYPE' + | 'INSTITUTION_ID' + | 'CAMPUS_ID' + | 'LIBRARY_ID' + | 'LOCATION_ID'; +export type UserAttribute = + | 'FOLIO_ID' + | 'PATRON_GROUP_ID' + | 'EXTERNAL_SYSTEM_ID' + | 'BARCODE' + | 'USERNAME' + | 'FIRST_NAME' + | 'MIDDLE_NAME' + | 'LAST_NAME'; + +export type DataToken = + | { + type: DataTokenType.NEWLINE; + } + | { + type: DataTokenType.NEWLINE_MICROSOFT; + } + | { + type: DataTokenType.TAB; + } + | { + type: DataTokenType.COMMA; + } + | { + type: DataTokenType.SPACE; + repeat: string; + } + | { + type: DataTokenType.ARBITRARY_TEXT; + text: string; + } + | { + type: DataTokenType.CURRENT_DATE; + format: DateFormatType; + timezone: string; + lengthControl?: LengthControl; + } + | { + type: DataTokenType.ACCOUNT_AMOUNT; + decimal: boolean; + lengthControl?: LengthControl; + } + | { + type: DataTokenType.ACCOUNT_DATE; + dateProperty: 'CREATED' | 'UPDATED' | 'DUE' | 'RETURNED'; + format: DateFormatType; + timezone: string; + placeholder?: string; + lengthControl?: LengthControl; + } + | { + type: DataTokenType.FEE_FINE_TYPE; + feeFineAttribute: 'FEE_FINE_TYPE_ID' | 'FEE_FINE_TYPE_NAME'; + lengthControl?: LengthControl; + } + | { + type: DataTokenType.ITEM_INFO; + itemAttribute: ItemAttribute; + placeholder?: string; + lengthControl?: LengthControl; + } + | { + type: DataTokenType.USER_DATA; + userAttribute: UserAttribute; + placeholder?: string; + lengthControl?: LengthControl; + } + | { + type: DataTokenType.CONSTANT_CONDITIONAL; + conditions?: ((CriteriaGroup | CriteriaTerminal) & { + value: string; + })[]; + else: string; + } + | { + type: DataTokenType.AGGREGATE_COUNT; + lengthControl?: LengthControl; + } + | { + type: DataTokenType.AGGREGATE_TOTAL; + decimal: boolean; + lengthControl?: LengthControl; + }; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000..83046f76 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,6 @@ +export { default as ConvenientConstants } from './ConvenientConstants'; +export * from './CriteriaTypes'; +export { default as FormValues } from './FormValues'; +export { default as LengthControl } from './LengthControl'; +export { default as SchedulingFrequency } from './SchedulingFrequency'; +export * from './TokenTypes'; diff --git a/src/types/types.test.ts b/src/types/types.test.ts new file mode 100644 index 00000000..78b47445 --- /dev/null +++ b/src/types/types.test.ts @@ -0,0 +1,9 @@ +import { TYPE_ONLY as TYPE_ONLY_FV } from './FormValues'; +import { TYPE_ONLY as TYPE_ONLY_LC } from './LengthControl'; + +// these are needed for coverage, for some reason Sonar won't consider these static files "tested" otherwise + +test('Types import correctly', () => { + expect(TYPE_ONLY_FV).toBe(true); + expect(TYPE_ONLY_LC).toBe(true); +}); diff --git a/src/typings.d.ts b/src/typings.d.ts new file mode 100644 index 00000000..1cae7c8e --- /dev/null +++ b/src/typings.d.ts @@ -0,0 +1,12 @@ +/* eslint-disable import/no-extraneous-dependencies */ +declare module '*.css'; + +// see STTYPES-9 to understand why this is necessary +declare module '@folio/jest-config-stripes/testing-library/react' { + export { default } from '@testing-library/react'; + export * from '@testing-library/react'; +} +declare module '@folio/jest-config-stripes/testing-library/user-event' { + export { default } from '@testing-library/user-event'; + export * from '@testing-library/user-event'; +} diff --git a/src/utils/exportPreviewUtils.test.ts b/src/utils/exportPreviewUtils.test.ts new file mode 100644 index 00000000..5a6c62c3 --- /dev/null +++ b/src/utils/exportPreviewUtils.test.ts @@ -0,0 +1,50 @@ +import { DateFormatType, LengthControl } from '../types'; +import { applyDecimalFormat, applyLengthControl, formatDate } from './exportPreviewUtils'; + +describe('Export preview utility functions', () => { + describe('date formatter', () => { + // Jan 2, 2021 @ 03:04:05 + const TEST_DATE = new Date(2021, 0, 2, 3, 4, 5); + + test.each([ + [DateFormatType.YEAR_LONG, 2021], + [DateFormatType.YEAR_SHORT, 21], + [DateFormatType.MONTH, 1], + [DateFormatType.DATE, 2], + [DateFormatType.HOUR, 3], + [DateFormatType.MINUTE, 4], + [DateFormatType.SECOND, 5], + [DateFormatType.QUARTER, 1], + [DateFormatType.WEEK_OF_YEAR_ISO, 1], + [DateFormatType.WEEK_YEAR_ISO, 2021], + + // garbage in = garbage out, so we don't care what the output is + ['' as DateFormatType, 1], + ])('Format %s gives %s', (format, expected) => expect(formatDate(format, TEST_DATE)).toBe(expected)); + }); + + describe('length control', () => { + const TEST_VALUE = '0123456789'; + + test.each([ + [undefined, '0123456789'], + [{}, '0123456789'], + [{ character: '.' }, '0123456789'], + [{ length: 12, character: '' }, '0123456789'], + [{ length: 12, character: '.', direction: 'FRONT' }, '..0123456789'], + [{ length: 12, character: '.', direction: 'BACK' }, '0123456789..'], + [{ length: 8, character: '.' }, '0123456789'], + [{ length: 8, character: '.', truncate: true }, '01234567'], + [{ length: 8, character: '.', truncate: true, direction: 'FRONT' }, '23456789'], + ] as [LengthControl, string][])('length control %s gives %s', (lengthControl, expected) => expect(applyLengthControl(TEST_VALUE, lengthControl)).toBe(expected)); + }); + + describe('decimal format', () => { + const TEST_VALUE = 1234.5678; + + test.each([ + [true, '1234.57'], + [false, '123457'], + ])('decimal=%s gives %s', (decimal, expected) => expect(applyDecimalFormat(TEST_VALUE, decimal)).toBe(expected)); + }); +}); diff --git a/src/utils/exportPreviewUtils.ts b/src/utils/exportPreviewUtils.ts new file mode 100644 index 00000000..9fcd44d7 --- /dev/null +++ b/src/utils/exportPreviewUtils.ts @@ -0,0 +1,73 @@ +import { DateFormatType, LengthControl } from '../types'; + +export function formatDate(format: DateFormatType, date: Date): number { + switch (format) { + case DateFormatType.WEEK_YEAR_ISO: + case DateFormatType.YEAR_LONG: + return date.getFullYear(); + + case DateFormatType.YEAR_SHORT: + return date.getFullYear() % 100; + + case DateFormatType.MONTH: + return date.getMonth() + 1; + + case DateFormatType.DATE: + return date.getDate(); + + case DateFormatType.HOUR: + return date.getHours(); + + case DateFormatType.MINUTE: + return date.getMinutes(); + + case DateFormatType.SECOND: + return date.getSeconds(); + + case DateFormatType.QUARTER: + return Math.floor(date.getMonth() / 3 + 1); + + // garbage in = garbage out, so we don't care what the output is in default + case DateFormatType.WEEK_OF_YEAR_ISO: + default: { + const janFirst = new Date(date.getFullYear(), 0, 1); + const dayOfYear = (date.getTime() - janFirst.getTime()) / 86400000 + 1; + return Math.ceil(dayOfYear / 7); + } + } +} + +export function applyLengthControl(value: string, lengthControl?: LengthControl): string { + if (lengthControl === undefined || lengthControl.character?.length !== 1) { + return value; + } + + const desiredLength = parseInt(lengthControl.length, 10); + if (Number.isNaN(desiredLength)) { + return value; + } + + if (value.length < desiredLength) { + if (lengthControl.direction === 'FRONT') { + return lengthControl.character.repeat(desiredLength - value.length) + value; + } else { + return value + lengthControl.character.repeat(desiredLength - value.length); + } + } else if (lengthControl.truncate) { + if (lengthControl.direction === 'FRONT') { + return value.substring(value.length - desiredLength); + } else { + return value.substring(0, desiredLength); + } + } else { + return value; + } +} + +export function applyDecimalFormat(value: number, decimal: boolean): string { + if (!decimal) { + return (value * 100).toFixed(0); + } else { + return value.toFixed(2); + } +} diff --git a/src/utils/guardNumber.test.ts b/src/utils/guardNumber.test.ts new file mode 100644 index 00000000..477b2511 --- /dev/null +++ b/src/utils/guardNumber.test.ts @@ -0,0 +1,33 @@ +import guardNumber, { guardNumberPositive } from './guardNumber'; + +describe('guardNumber function', () => { + const TEST_FALLBACK = 1234; + + test.each([ + ['1', 1], + ['12', 12], + [' 12.5', 13], + [' 12.52', 13], + ['abc', TEST_FALLBACK], + ['', TEST_FALLBACK], + [undefined, TEST_FALLBACK], + ['a123', TEST_FALLBACK], + ])('guardNumber(%s) = %s', (input, expected) => expect(guardNumber(input, TEST_FALLBACK)).toBe(expected)); + + test('guardNumber custom beforeRound', () => { + const formatter = jest.fn((v) => v + 1); + + expect(guardNumber('1234.1', TEST_FALLBACK, formatter)).toBe(1235); + expect(formatter).toHaveBeenLastCalledWith(1234.1); + }); + + test.each([ + ['1', 1], + ['0', 0], + ['0.9', 1], + ['12.7', 13], + ['-1', 0], + ['-1000.5', 0], + ['', 0], + ])('guardNumberPositive(%s) = %s', (input, expected) => expect(guardNumberPositive(input)).toBe(expected)); +}); diff --git a/src/utils/guardNumber.ts b/src/utils/guardNumber.ts new file mode 100644 index 00000000..3d4acf70 --- /dev/null +++ b/src/utils/guardNumber.ts @@ -0,0 +1,18 @@ +/** Guarantees an integer */ +export default function guardNumber( + value: string | undefined, + fallback: number, + preRound: (value: number) => number = (v) => v, +): number { + const parsed = parseFloat(value ?? ''); + + if (Number.isNaN(parsed)) { + return fallback; + } + + return Math.round(preRound(parsed)); +} + +export function guardNumberPositive(value: string | undefined): number { + return guardNumber(value, 0, (v) => Math.max(0, v)); +} diff --git a/src/utils/weekdayUtils.test.ts b/src/utils/weekdayUtils.test.ts new file mode 100644 index 00000000..3df72f4d --- /dev/null +++ b/src/utils/weekdayUtils.test.ts @@ -0,0 +1,99 @@ +import { IntlShape } from 'react-intl'; +import * as Weekdays from '../../test/data/Weekdays'; +import getIntl from '../../test/util/getIntl'; +import { WEEKDAYS, getFirstDayOfWeek, getLocaleWeekdays } from './weekdayUtils'; + +// United States +let intlEn: IntlShape; +// France +let intlFr: IntlShape; +// Algeria +let intlAr: IntlShape; + +beforeAll(() => { + intlEn = getIntl('en-US', 'EST'); + intlFr = getIntl('fr-FR', 'CET'); + intlAr = getIntl('ar-DZ', 'CET'); +}); + +describe('getFirstDayOfWeek', () => { + test('First day of week is properly retrieved', () => { + // united states + expect(getFirstDayOfWeek('en-US')).toBe(WEEKDAYS[Weekdays.Sunday]); + expect(getFirstDayOfWeek('en-us')).toBe(WEEKDAYS[Weekdays.Sunday]); + + // france + expect(getFirstDayOfWeek('fr-FR')).toBe(WEEKDAYS[Weekdays.Monday]); + expect(getFirstDayOfWeek('fr-fr')).toBe(WEEKDAYS[Weekdays.Monday]); + + // algeria + expect(getFirstDayOfWeek('ar-DZ')).toBe(WEEKDAYS[Weekdays.Saturday]); + expect(getFirstDayOfWeek('ar-dz')).toBe(WEEKDAYS[Weekdays.Saturday]); + + // invalid, fallback to Sunday + expect(getFirstDayOfWeek('zz')).toBe(WEEKDAYS[Weekdays.Sunday]); + expect(getFirstDayOfWeek('zz-zz')).toBe(WEEKDAYS[Weekdays.Sunday]); + }); +}); + +describe('getLocaleWeekdays.', () => { + test('Locale weekdays are properly retrieved', () => { + expect(getLocaleWeekdays(intlEn)).toStrictEqual([ + { weekday: Weekdays.Sunday, long: 'Sunday', short: 'Sun', narrow: 'S' }, + { weekday: Weekdays.Monday, long: 'Monday', short: 'Mon', narrow: 'M' }, + { weekday: Weekdays.Tuesday, long: 'Tuesday', short: 'Tue', narrow: 'T' }, + { + weekday: Weekdays.Wednesday, + long: 'Wednesday', + short: 'Wed', + narrow: 'W', + }, + { weekday: Weekdays.Thursday, long: 'Thursday', short: 'Thu', narrow: 'T' }, + { weekday: Weekdays.Friday, long: 'Friday', short: 'Fri', narrow: 'F' }, + { weekday: Weekdays.Saturday, long: 'Saturday', short: 'Sat', narrow: 'S' }, + ]); + expect(getLocaleWeekdays(intlFr)).toStrictEqual([ + { weekday: Weekdays.Monday, long: 'lundi', short: 'lun.', narrow: 'L' }, + { weekday: Weekdays.Tuesday, long: 'mardi', short: 'mar.', narrow: 'M' }, + { + weekday: Weekdays.Wednesday, + long: 'mercredi', + short: 'mer.', + narrow: 'M', + }, + { weekday: Weekdays.Thursday, long: 'jeudi', short: 'jeu.', narrow: 'J' }, + { weekday: Weekdays.Friday, long: 'vendredi', short: 'ven.', narrow: 'V' }, + { weekday: Weekdays.Saturday, long: 'samedi', short: 'sam.', narrow: 'S' }, + { weekday: Weekdays.Sunday, long: 'dimanche', short: 'dim.', narrow: 'D' }, + ]); + expect(getLocaleWeekdays(intlAr)).toStrictEqual([ + { weekday: Weekdays.Saturday, long: 'السبت', short: 'السبت', narrow: 'س' }, + { weekday: Weekdays.Sunday, long: 'الأحد', short: 'الأحد', narrow: 'ح' }, + { + weekday: Weekdays.Monday, + long: 'الاثنين', + short: 'الاثنين', + narrow: 'ن', + }, + { + weekday: Weekdays.Tuesday, + long: 'الثلاثاء', + short: 'الثلاثاء', + narrow: 'ث', + }, + { + weekday: Weekdays.Wednesday, + long: 'الأربعاء', + short: 'الأربعاء', + narrow: 'ر', + }, + { + weekday: Weekdays.Thursday, + long: 'الخميس', + short: 'الخميس', + narrow: 'خ', + }, + { weekday: Weekdays.Friday, long: 'الجمعة', short: 'الجمعة', narrow: 'ج' }, + ]); + }); +}); diff --git a/src/utils/weekdayUtils.ts b/src/utils/weekdayUtils.ts new file mode 100644 index 00000000..c40b71c3 --- /dev/null +++ b/src/utils/weekdayUtils.ts @@ -0,0 +1,77 @@ +import { staticFirstWeekDay } from '@folio/stripes/components'; +import { IntlShape } from 'react-intl'; + +export type Weekday = 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY'; + +/** + * Used for algorithmically relating weekdays to each other. This MUST not be + * used for any type of user display, only for relating weekdays to each other. + * The only guarantees are that weekdays (when wrapped around) will be in the + * typical order, such as Friday -> Saturday, and that .getDay() on Date will + * correspond here as expected. + * Additionally, the values here will correspond to {@link WEEKDAY_INDEX} + */ +export const WEEKDAYS: Record = { + SUNDAY: 0, + MONDAY: 1, + TUESDAY: 2, + WEDNESDAY: 3, + THURSDAY: 4, + FRIDAY: 5, + SATURDAY: 6, +}; +/** + * Used for algorithmically relating weekdays to each other. This MUST not be + * used for any type of user display, only for relating weekdays to each other. + * The only guarantees are that weekdays (when wrapped around) will be in the + * typical order, such as Friday -> Saturday, and that .getDay() on Date will + * correspond here as expected. + * Additionally, the values here will correspond to {@link WEEKDAY_INDEX} + */ +export const WEEKDAY_INDEX: Weekday[] = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; + +export interface LocaleWeekdayInfo { + weekday: Weekday; + narrow: string; + short: string; + long: string; +} + +export function getFirstDayOfWeek(locale: string) { + const region = locale.split('-')[1]?.toUpperCase() ?? 'US'; + const weekdayLookup = { + sun: 0, + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6, + }; + for (const [weekday, regions] of Object.entries(staticFirstWeekDay)) { + if (regions.includes(region)) { + return weekdayLookup[weekday as keyof typeof staticFirstWeekDay]; + } + } + return 0; // safe default +} + +/** Get information for the days of the week, for the current locale */ +export function getLocaleWeekdays(intl: IntlShape): LocaleWeekdayInfo[] { + const firstDay = getFirstDayOfWeek(intl.locale); + + const weekdays: LocaleWeekdayInfo[] = []; + for (let i = 0; i < 7; i++) { + // need to be careful to use UTC here, and force react-intl to use UTC + // since this is the one date-formatted thing that will be visible to users + const day = new Date(Date.UTC(2000, 1, 1)); + day.setUTCDate(day.getUTCDate() - day.getUTCDay() + firstDay + i); + weekdays.push({ + weekday: WEEKDAY_INDEX[(firstDay + i) % 7], + narrow: intl.formatDate(day, { weekday: 'narrow', timeZone: 'UTC' }), + short: intl.formatDate(day, { weekday: 'short', timeZone: 'UTC' }), + long: intl.formatDate(day, { weekday: 'long', timeZone: 'UTC' }), + }); + } + return weekdays; +} diff --git a/test/__mocks__/index.ts b/test/__mocks__/index.ts new file mode 100644 index 00000000..3449ec5d --- /dev/null +++ b/test/__mocks__/index.ts @@ -0,0 +1,4 @@ +import './matchMedia.mock'; +import './stripes-components.mock'; +import './stripes-config.mock'; +import './stripes-core.mock'; diff --git a/test/__mocks__/matchMedia.mock.ts b/test/__mocks__/matchMedia.mock.ts new file mode 100644 index 00000000..099fa517 --- /dev/null +++ b/test/__mocks__/matchMedia.mock.ts @@ -0,0 +1,13 @@ +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); diff --git a/test/__mocks__/stripes-components.mock.tsx b/test/__mocks__/stripes-components.mock.tsx new file mode 100644 index 00000000..bb8bcf36 --- /dev/null +++ b/test/__mocks__/stripes-components.mock.tsx @@ -0,0 +1,18 @@ +import React, { ReactNode } from 'react'; + +// needed for Jest to properly require UMD-global React +global.React = React; + +// relies on Webpack's require.context +jest.mock('@folio/stripes-components/lib/Icon', () => { + return (props: Record) => ( + {props.children as ReactNode} + ); +}); + +jest.mock('@folio/stripes-components/util/currencies', () => { + return {}; +}); + +// re-export our mocks +jest.mock('@folio/stripes/components', () => jest.requireActual('@folio/stripes-components')); diff --git a/test/__mocks__/stripes-config.mock.ts b/test/__mocks__/stripes-config.mock.ts new file mode 100644 index 00000000..38dd34f2 --- /dev/null +++ b/test/__mocks__/stripes-config.mock.ts @@ -0,0 +1,3 @@ +jest.mock('stripes-config', () => ({ modules: [], metadata: {} }), { + virtual: true, +}); diff --git a/test/__mocks__/stripes-core.mock.ts b/test/__mocks__/stripes-core.mock.ts new file mode 100644 index 00000000..3ea74abc --- /dev/null +++ b/test/__mocks__/stripes-core.mock.ts @@ -0,0 +1,25 @@ +jest.mock('@folio/stripes/core', () => { + const STRIPES = { + hasPerm: () => true, + }; + + return { + IfInterface: jest.fn(({ name, children }) => { + return name === 'interface' || name === 'service-points-users' ? children : null; + }), + IfPermission: jest.fn(({ perm, children }) => { + if (perm === 'permission') { + return children; + } else if (perm.startsWith('ui-calendar')) { + return children; + } else if (perm.startsWith('perms')) { + return children; + } else { + return null; + } + }), + Pluggable: jest.fn(({ children }) => [children]), + useOkapiKy: jest.fn(), + useStripes: jest.fn(() => STRIPES), + }; +}); diff --git a/test/data/Weekdays.ts b/test/data/Weekdays.ts new file mode 100644 index 00000000..c2ee9cf1 --- /dev/null +++ b/test/data/Weekdays.ts @@ -0,0 +1,9 @@ +import { WEEKDAY_INDEX } from '../../src/utils/weekdayUtils'; + +export const Sunday = WEEKDAY_INDEX[0]; +export const Monday = WEEKDAY_INDEX[1]; +export const Tuesday = WEEKDAY_INDEX[2]; +export const Wednesday = WEEKDAY_INDEX[3]; +export const Thursday = WEEKDAY_INDEX[4]; +export const Friday = WEEKDAY_INDEX[5]; +export const Saturday = WEEKDAY_INDEX[6]; diff --git a/test/jest.setup.ts b/test/jest.setup.ts new file mode 100644 index 00000000..12527a74 --- /dev/null +++ b/test/jest.setup.ts @@ -0,0 +1 @@ +import '@folio/jest-config-stripes/testing-library/jest-dom'; diff --git a/test/jest/__mock__/createRange.mock.js b/test/jest/__mock__/createRange.mock.js deleted file mode 100644 index d22918ff..00000000 --- a/test/jest/__mock__/createRange.mock.js +++ /dev/null @@ -1 +0,0 @@ -global.document.createRange = jest.fn(() => new Range()); diff --git a/test/jest/__mock__/index.js b/test/jest/__mock__/index.js deleted file mode 100644 index 2b2d24ef..00000000 --- a/test/jest/__mock__/index.js +++ /dev/null @@ -1 +0,0 @@ -import './createRange.mock'; diff --git a/test/jest/setupFiles.js b/test/jest/setupFiles.js deleted file mode 100644 index 3cb4a538..00000000 --- a/test/jest/setupFiles.js +++ /dev/null @@ -1 +0,0 @@ -import './__mock__'; diff --git a/test/setupTests.ts b/test/setupTests.ts new file mode 100644 index 00000000..1603e4ca --- /dev/null +++ b/test/setupTests.ts @@ -0,0 +1 @@ +import './__mocks__'; diff --git a/test/util/expectRender.ts b/test/util/expectRender.ts new file mode 100644 index 00000000..de3df2e1 --- /dev/null +++ b/test/util/expectRender.ts @@ -0,0 +1,7 @@ +import { render } from '@folio/jest-config-stripes/testing-library/react'; +import { ReactNode } from 'react'; +import withIntlConfiguration from './withIntlConfiguration'; + +export default function expectRender(error: ReactNode): jest.JestMatchers { + return expect(render(withIntlConfiguration(error)).container.textContent); +} diff --git a/test/util/getIntl.tsx b/test/util/getIntl.tsx new file mode 100644 index 00000000..5a81aa06 --- /dev/null +++ b/test/util/getIntl.tsx @@ -0,0 +1,20 @@ +import { cleanup, render } from '@folio/jest-config-stripes/testing-library/react'; +import React, { FunctionComponent } from 'react'; +import { IntlContext, IntlShape } from 'react-intl'; +import withIntlConfiguration from './withIntlConfiguration'; + +export default function getIntl(locale = 'en-US', timeZone = 'UTC'): IntlShape { + const intlCapturer = jest.fn(); + + const TestComponent: FunctionComponent> = () => ( + {intlCapturer} + ); + render(withIntlConfiguration(, locale, timeZone)); + + expect(intlCapturer).toHaveBeenCalled(); + const intl = intlCapturer.mock.calls[0][0] as IntlShape; + + cleanup(); + + return intl; +} diff --git a/test/util/translationTypings.d.ts b/test/util/translationTypings.d.ts new file mode 100644 index 00000000..8350347e --- /dev/null +++ b/test/util/translationTypings.d.ts @@ -0,0 +1,2 @@ +declare module '@folio/stripes-components/translations/stripes-components/en'; +declare module '@folio/stripes-core/translations/stripes-core/en'; diff --git a/test/util/withIntlConfiguration.tsx b/test/util/withIntlConfiguration.tsx new file mode 100644 index 00000000..d9825d9c --- /dev/null +++ b/test/util/withIntlConfiguration.tsx @@ -0,0 +1,52 @@ +/* eslint-disable import/no-extraneous-dependencies */ // needed for translations below +import stripesComponentsTranslations from '@folio/stripes-components/translations/stripes-components/en'; +import stripesCoreTranslations from '@folio/stripes-core/translations/stripes-core/en'; +import React, { ReactNode } from 'react'; +import { IntlProvider } from 'react-intl'; +// cannot make TS happy without the .json +// eslint-disable-next-line import/extensions +import localTranslations from '../../translations/ui-plugin-bursar-export/en.json'; + +export const translationSets = [ + { + prefix: 'ui-plugin-bursar-export', + translations: localTranslations, + }, + { + prefix: 'stripes-components', + translations: stripesComponentsTranslations, + }, + { + prefix: 'stripes-core', + translations: stripesCoreTranslations, + }, +]; + +export function withIntlConfigurationAnyTimezone( + children: ReactNode, + locale = 'en-US', + timeZone?: string, +): React.JSX.Element { + const allTranslations: Record = {}; + + translationSets.forEach((set) => { + const { prefix, translations } = set; + Object.keys(translations).forEach((key) => { + allTranslations[`${prefix}.${key}`] = translations[key]; + }); + }); + + return ( + + {children} + + ); +} + +export default function withIntlConfiguration( + children: ReactNode, + locale = 'en-US', + timeZone = 'UTC', +): React.JSX.Element { + return withIntlConfigurationAnyTimezone(children, locale, timeZone); +} diff --git a/translations/ui-plugin-bursar-export/en.json b/translations/ui-plugin-bursar-export/en.json index a201280c..c0a6f86c 100644 --- a/translations/ui-plugin-bursar-export/en.json +++ b/translations/ui-plugin-bursar-export/en.json @@ -1,40 +1,176 @@ { - "meta.title": "Transfer criteria", - - "bursarExports": "Bursar exports", - "bursarExports.schedulePeriod": "Schedule period", - "bursarExports.schedulePeriod.none": "None", - "bursarExports.schedulePeriod.hours": "Hours", - "bursarExports.schedulePeriod.days": "Days", - "bursarExports.schedulePeriod.weeks": "Weeks", - "bursarExports.scheduleFrequency": "Schedule frequency", - "bursarExports.scheduleWeekdays": "Weekdays", - "bursarExports.scheduleWeekdays.friday": "F", - "bursarExports.scheduleWeekdays.monday": "M", - "bursarExports.scheduleWeekdays.saturday": "S", - "bursarExports.scheduleWeekdays.sunday": "S", - "bursarExports.scheduleWeekdays.thursday": "T", - "bursarExports.scheduleWeekdays.tuesday": "T", - "bursarExports.scheduleWeekdays.wednesday": "W", - "bursarExports.scheduleTime": "Schedule time", - "bursarExports.daysOutstanding": "Fees/Fines older than (days)", - "bursarExports.patronGroups": "Patron groups", - "bursarExports.transferOwner": "Transfer owner", - "bursarExports.transferAccount": "Transfer account", - "bursarExports.owner": "Fee/fine owner", - "bursarExports.itemTypes": "Transfer types", - "bursarExports.feeFineType": "Fee/fine type", - "bursarExports.itemType": "Transfer type", - "bursarExports.itemDescription": "Transfer description", - "bursarExports.itemCode": "Transfer code", - "bursarExports.itemCode.CHARGE": "Charge", - "bursarExports.itemCode.PAYMENT": "Payment", - "bursarExports.save": "Save", - "bursarExports.save.success": "Bursar exports configuration has been successfully saved", - "bursarExports.save.error": "Bursar exports configuration was not saved", - "bursarExports.runManually": "Run manually", - "bursarExports.runManually.success": "Bursar exports has been successfully scheduled", - "bursarExports.runManually.error": "Bursar exports was not scheduled", - - "permission.bursar-exports.all": "Transfer exports: Transfer admin" + "meta.title": "Transfer configuration", + + "bursarExports.paneTitle": "Transfer configuration", + + "bursarExports.button.addCondition": "Add condition", + "bursarExports.button.add": "Add", + "bursarExports.button.save": "Save", + "bursarExports.button.runManually": "Run manually", + "bursarExports.otherwise": "Otherwise:", + + "bursarExports.scheduling.accordion": "Scheduling", + "bursarExports.scheduling.frequency": "Frequency", + "bursarExports.scheduling.frequency.manual": "Never (run manually)", + "bursarExports.scheduling.frequency.hours": "Hours", + "bursarExports.scheduling.frequency.days": "Days", + "bursarExports.scheduling.frequency.weeks": "Weeks", + "bursarExports.scheduling.interval.HOUR": "Hours between runs", + "bursarExports.scheduling.interval.DAY": "Days between runs", + "bursarExports.scheduling.interval.WEEK": "Weeks between runs", + "bursarExports.scheduling.time": "Start time", + "bursarExports.scheduling.weekdays": "Run on weekdays", + + "bursarExports.scheduler.mutation.automatic.success": "Configuration saved", + "bursarExports.scheduler.mutation.automatic.error": "Failed to save job", + "bursarExports.scheduler.mutation.manual.success": "Job has been scheduled", + "bursarExports.scheduler.mutation.manual.error": "Failed to start job", + + "bursarExports.criteria.accordion": "Criteria", + "bursarExports.criteria.select.allOf": "All of:", + "bursarExports.criteria.select.anyOf": "Any of:", + "bursarExports.criteria.select.noneOf": "None of:", + "bursarExports.criteria.select.age": "Age", + "bursarExports.criteria.select.amount": "Amount", + "bursarExports.criteria.select.owner": "Fee/fine owner", + "bursarExports.criteria.select.type": "Fee/fine type", + "bursarExports.criteria.select.location": "Item location", + "bursarExports.criteria.select.servicePoint": "Item service point", + "bursarExports.criteria.select.patronGroup": "Patron group", + "bursarExports.criteria.select.none": "No criteria (always run)", + "bursarExports.criteria.select.label": "Criteria", + + "bursarExports.criteria.age.value": "Number of days old", + "bursarExports.criteria.type.automatic": "Automatic", + "bursarExports.criteria.location.inst": "Institution", + "bursarExports.criteria.location.camp": "Campus", + "bursarExports.criteria.location.lib": "Library", + "bursarExports.criteria.location.loc": "Location", + "bursarExports.criteria.servicePoint.value": "Service point", + + "bursarExports.conditional.card.header": "If:", + + "bursarExports.aggregate.accordion": "Aggregate by patron", + "bursarExports.aggregate.enabler": "Group data by patron", + "bursarExports.aggregate.description": "If enabled, each output row will correspond to a single patron with all of their accounts, rather than just a single account.", + "bursarExports.aggregate.filter": "Filter type", + "bursarExports.aggregate.filter.header": "Only include patrons with:", + "bursarExports.aggregate.filter.none": "None (include all patrons)", + "bursarExports.aggregate.filter.numAccounts": "Number of accounts", + "bursarExports.aggregate.filter.numAccounts.amount": "Number of accounts", + "bursarExports.aggregate.filter.totalAmount": "Total amount", + "bursarExports.aggregate.filter.totalAmount.amount": "Amount", + "bursarExports.aggregate.filter.operator": "Comparison operator", + "bursarExports.aggregate.filter.operator.less": "Less than but not equal to", + "bursarExports.aggregate.filter.operator.lessEqual": "Less than or equal to", + "bursarExports.aggregate.filter.operator.greater": "Greater than but not equal to", + "bursarExports.aggregate.filter.operator.greaterEqual": "Greater than or equal to", + "bursarExports.aggregate.filter.description": "This will be applied after accounts are evaluated per the \u201cCriteria\u201d specified above.", + + "bursarExports.header.accordion": "Header format", + "bursarExports.footer.accordion": "Footer format", + + "bursarExports.token.newline": "Newline (LF)", + "bursarExports.token.newlineMicrosoft": "Newline (Microsoft, CRLF)", + "bursarExports.token.tab": "Tab", + "bursarExports.token.comma": "Comma", + "bursarExports.token.whitespace": "Whitespace", + "bursarExports.token.arbitraryText": "Text", + "bursarExports.token.currentDate": "Current date", + "bursarExports.token.constantConditional": "Conditional text", + "bursarExports.token.numAccounts": "Number of accounts", + "bursarExports.token.totalAmount": "Total amount", + "bursarExports.token.userData": "User info", + "bursarExports.token.accountAmount": "Account amount", + "bursarExports.token.accountDate": "Account date", + "bursarExports.token.feeFineType": "Fee/fine type", + "bursarExports.token.itemInfo": "Item info", + "bursarExports.token.fallback": "Fallback value", + "bursarExports.token.fallback.description": "If the chosen value is not available/applicable, the fallback value will be used instead.", + "bursarExports.token.value": "Value", + + "bursarExports.token.headerFooter.typeSelect": "Header/footer type select", + + "bursarExports.token.dataType.typeSelect": "Data type select", + + "bursarExports.token.whitespace.numSpaces": "Number of spaces", + + "bursarExports.token.currentDate.format": "Format", + "bursarExports.token.currentDate.format.yearLong": "Year (4-digit)", + "bursarExports.token.currentDate.format.yearShort": "Year (2-digit)", + "bursarExports.token.currentDate.format.month": "Month", + "bursarExports.token.currentDate.format.date": "Day of month", + "bursarExports.token.currentDate.format.hour": "Hour", + "bursarExports.token.currentDate.format.minute": "Minute", + "bursarExports.token.currentDate.format.second": "Second", + "bursarExports.token.currentDate.format.quarter": "Quarter", + "bursarExports.token.currentDate.format.isoWeekNum": "ISO week number", + "bursarExports.token.currentDate.format.isoWeekYear": "ISO week year", + "bursarExports.token.currentDate.format.dayOfYear": "Day of year", + "bursarExports.token.currentDate.format.YYYYMMDD": "YYYYMMDD", + "bursarExports.token.currentDate.format.YYYY-MM-DD": "YYYY-MM-DD", + "bursarExports.token.currentDate.format.MMDDYYYY": "MMDDYYYY", + "bursarExports.token.currentDate.format.DDMMYYYY": "DDMMYYYY", + "bursarExports.token.currentDate.timezone": "Timezone", + + "bursarExports.token.accountAmount.enableDecimal": "Include the decimal point", + "bursarExports.token.accountAmount.enableDecimal.description": "If selected, amounts will be exported like \u201c12.50\u201d if left unselected, they will be exported like \u201c1250\u201d.", + + "bursarExports.token.accountDate.dateType": "Date", + "bursarExports.token.accountDate.dateType.created": "Creation date", + "bursarExports.token.accountDate.dateType.updated": "Last updated date", + "bursarExports.token.accountDate.dateType.dueItem": "Item due date", + "bursarExports.token.accountDate.dateType.dueLoan": "Loan end date", + "bursarExports.token.accountDate.fallback.description": "If the chosen date is not available/applicable, the fallback value will be used instead.", + + "bursarExports.token.feeFineType.attribute": "Attribute", + "bursarExports.token.feeFineType.name": "Type name", + "bursarExports.token.feeFineType.id": "Type ID", + + "bursarExports.token.itemInfo.name": "Name", + "bursarExports.token.itemInfo.barcode": "Barcode", + "bursarExports.token.itemInfo.material": "Material type", + "bursarExports.token.itemInfo.instId": "Institution ID", + "bursarExports.token.itemInfo.campId": "Campus ID", + "bursarExports.token.itemInfo.libId": "Library ID", + "bursarExports.token.itemInfo.locId": "Location ID", + + "bursarExports.token.userInfo.folioId": "Folio ID", + "bursarExports.token.userInfo.extId": "External ID", + "bursarExports.token.userInfo.groupId": "Patron group ID", + "bursarExports.token.userInfo.barcode": "Barcode", + "bursarExports.token.userInfo.username": "Username", + "bursarExports.token.userInfo.firstname": "First name", + "bursarExports.token.userInfo.middlename": "Middle name", + "bursarExports.token.userInfo.lastname": "Last name", + + "bursarExports.token.constantConditional.value": "Then use:", + "bursarExports.token.constantConditional.description": "Conditions will be evaluated in order, with the first matched value being used. If no conditions are matched, the fallback value will be used.", + + "bursarExports.data.accordion.patron": "Patron data format", + "bursarExports.data.accordion.account": "Account data format", + + "bursarExports.lengthControl.length": "Desired length", + "bursarExports.lengthControl.filler": "Fill extra space with", + "bursarExports.lengthControl.direction.addOnly": "Add characters to", + "bursarExports.lengthControl.direction.addOrTruncate": "Add/remove characters to/from", + "bursarExports.lengthControl.direction.front": "Start", + "bursarExports.lengthControl.direction.back": "End", + "bursarExports.lengthControl.truncate": "Truncate if too long", + + "bursarExports.preview.accordion": "Preview", + "bursarExports.preview.header": "Export preview", + "bursarExports.preview.wrap": "Wrap long lines", + "bursarExports.preview.description": "This preview is only a sample and does not represent real data, nor does it consider any specified criteria.", + "bursarExports.preview.enableInvisibleChar": "Display invisible characters (newlines, tabs, and spaces)", + + "bursarExports.transfer.accordion": "Transfer accounts to", + "bursarExports.transfer.description": "Conditions will be evaluated in order, with the first matched transfer account being used. If no conditions are matched, the account listed under \u201cotherwise\u201d will be used.", + "bursarExports.transfer.owner": "Fee/fine owner", + "bursarExports.transfer.account": "Transfer account", + "bursarExports.transfer.transferTo": "Transfer to:", + + "permission.bursar-exports.all": "Transfer exports: Modify configuration and start jobs", + "permission.bursar-exports.manual": "Transfer exports: Start manual jobs", + "permission.bursar-exports.view": "Transfer exports: View configuration" } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..23ca022a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": true, + "noImplicitAny": true, + "esModuleInterop": true, + "jsx": "react", + "lib": ["esnext", "dom"], + "module": "es2020", + "moduleResolution": "node", + "strict": true, + "resolveJsonModule": true + } +}