diff --git a/apps/modernization-ui/package-lock.json b/apps/modernization-ui/package-lock.json index 350d45b056..88ab237ff6 100644 --- a/apps/modernization-ui/package-lock.json +++ b/apps/modernization-ui/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@apollo/client": "^3.11.8", - "@dnd-kit/core": "^6.3.1", + "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", "@hello-pangea/dnd": "^18.0.1", "@react-querybuilder/dnd": "^8.16.0", "@trussworks/react-uswds": "^11", @@ -234,6 +234,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@atlaskit/pragmatic-drag-and-drop": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.8.1.tgz", + "integrity": "sha512-uXWNPpL8n4OmTVbduH7nq8pk8htqGo/prR5cYEE8sVCPJGAUMWn6lzvWTfI+4VCeQvHiDRODVz4YzH06OVAxhw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^3.0.0", + "raf-schd": "^4.0.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -813,45 +824,6 @@ "node": ">=20.19.0" } }, - "node_modules/@dnd-kit/accessibility": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", - "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/core": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", - "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", - "license": "MIT", - "dependencies": { - "@dnd-kit/accessibility": "^3.1.1", - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", - "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -4009,9 +3981,9 @@ "license": "BSD-3-Clause" }, "node_modules/@react-querybuilder/dnd": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/@react-querybuilder/dnd/-/dnd-8.16.0.tgz", - "integrity": "sha512-LLdmWw5bhHuC6RwpTdcK4DERpJc/RR2QQY0kMVYVFf9igZuLL3G0qOiObS86h2rqU/KCm6ITU3c+2sFsUZCRmg==", + "version": "8.16.1", + "resolved": "https://registry.npmjs.org/@react-querybuilder/dnd/-/dnd-8.16.1.tgz", + "integrity": "sha512-fc45T2osrSDsLhUY7gm2GPc1djF2HDVLrgswvid+/yvumgcuw+bqiHCUeuE4LkNCAsJqaIulEFP+4IbaEEr02g==", "license": "MIT", "peerDependencies": { "@atlaskit/pragmatic-drag-and-drop": ">=1.0.0", @@ -4020,7 +3992,7 @@ "react-dnd": ">=14.0.0", "react-dnd-html5-backend": ">=14.0.0", "react-dnd-touch-backend": ">=14.0.0", - "react-querybuilder": "8.16.0" + "react-querybuilder": "8.16.1" }, "peerDependenciesMeta": { "@atlaskit/pragmatic-drag-and-drop": { @@ -6343,6 +6315,12 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bind-event-listener": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -12628,12 +12606,12 @@ "license": "MIT" }, "node_modules/react-querybuilder": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/react-querybuilder/-/react-querybuilder-8.16.0.tgz", - "integrity": "sha512-fERnSBAkXpMJTp4QqTKzunalZfHEeftRwLVr52DHkDsO+/nirREvLmWp3w4fNtN4FJrGgspIFbf3Vi5aOR4h4Q==", + "version": "8.16.1", + "resolved": "https://registry.npmjs.org/react-querybuilder/-/react-querybuilder-8.16.1.tgz", + "integrity": "sha512-tkhjTijkGfQDNHyWTLlceKtJf0p9IkeLrBunrgBLWpqncBsSlQsKB7/4PKhtG/l08M8kOMFbfeIoqTNBKYYw5Q==", "license": "MIT", "dependencies": { - "@react-querybuilder/core": "^8.16.0", + "@react-querybuilder/core": "^8.16.1", "@reduxjs/toolkit": "^2.11.2", "react-redux": "^9.2.0" }, @@ -12642,14 +12620,14 @@ } }, "node_modules/react-querybuilder/node_modules/@react-querybuilder/core": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/@react-querybuilder/core/-/core-8.16.0.tgz", - "integrity": "sha512-fEKrPIW60p+OYJYYahTdWjT0F+CXnubXUCYJq9rsRkzkrZn62ZncH4Q12EW3MxS0hbB4Xkrjbg48law2QscedQ==", + "version": "8.16.1", + "resolved": "https://registry.npmjs.org/@react-querybuilder/core/-/core-8.16.1.tgz", + "integrity": "sha512-G1cvdEaG9+EgjYC/Cx9Bd2edlaTJmF1P9EgtMAmLOecRfHLEjh0Wnm6v2sJP+zP2bXz8c4YAm5rOuupd4oHXjg==", "license": "MIT", "dependencies": { "@ts-jison/lexer": "0.4.1-alpha.1", "@ts-jison/parser": "0.4.1-alpha.1", - "immer": "^11.1.3", + "immer": "^11.1.8", "numeric-quantity": "^3.2.1" }, "peerDependencies": { diff --git a/apps/modernization-ui/package.json b/apps/modernization-ui/package.json index 05f7cf1408..135368ed6a 100644 --- a/apps/modernization-ui/package.json +++ b/apps/modernization-ui/package.json @@ -7,7 +7,7 @@ }, "dependencies": { "@apollo/client": "^3.11.8", - "@dnd-kit/core": "^6.3.1", + "@atlaskit/pragmatic-drag-and-drop": "^1.8.1", "@hello-pangea/dnd": "^18.0.1", "@react-querybuilder/dnd": "^8.16.0", "@trussworks/react-uswds": "^11", diff --git a/apps/modernization-ui/src/apps/report/run/ReportConfigurationPage.tsx b/apps/modernization-ui/src/apps/report/run/ReportConfigurationPage.tsx index 7604e41b46..0646a8fd4e 100644 --- a/apps/modernization-ui/src/apps/report/run/ReportConfigurationPage.tsx +++ b/apps/modernization-ui/src/apps/report/run/ReportConfigurationPage.tsx @@ -3,11 +3,11 @@ import { Button } from 'design-system/button'; import { permissions, Permitted } from 'libs/permission'; import { ReportRunLayout } from './layout/ReportRunLayout'; import { ReportConfiguration } from 'generated'; -import { BasicFilter } from './filters/BasicFilter'; +import { BasicFilter } from './filters/basic/BasicFilter'; import { Card } from 'design-system/card'; -import { STATE_FILTER_CODE } from './filters/OptionSelectFilter'; -import { CurrentStateProvider } from './filters/useCurrentState'; -import { AdvancedFilter } from './filters/AdvancedFilter'; +import { STATE_FILTER_CODE } from './filters/basic/OptionSelectFilter'; +import { CurrentStateProvider } from './filters/basic/useCurrentState'; +import { AdvancedFilter } from './filters/advanced/AdvancedFilter'; const ReportConfigurationPage = ({ config, diff --git a/apps/modernization-ui/src/apps/report/run/ReportRunPage.spec.tsx b/apps/modernization-ui/src/apps/report/run/ReportRunPage.spec.tsx index 5cff771d6b..e891e3c941 100644 --- a/apps/modernization-ui/src/apps/report/run/ReportRunPage.spec.tsx +++ b/apps/modernization-ui/src/apps/report/run/ReportRunPage.spec.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import { ReportRunPage } from './ReportRunPage'; import * as generated from 'generated'; import userEvent from '@testing-library/user-event'; @@ -2247,4 +2247,501 @@ describe('report run page', () => { }); }); }); + + describe('advanced filter', () => { + const MOCK_FILTER: generated.AdvancedFilterConfiguration = { + reportFilterUid: 1001, + defaultValue: undefined, + }; + + it('renders the empty filter builder when no default value', async () => { + const mockApi = vi + .mocked(generated.ReportControllerService.getReportConfiguration) + .mockResolvedValue({ ...MOCK_CONFIG, advancedFilter: MOCK_FILTER }); + const mockResultApi = vi + .mocked(generated.ReportControllerService.exportReport) + .mockResolvedValue(MOCK_RESULT); + const { getByRole, findByText, queryByText, findByRole } = renderWithRouter(); + + expect(getByRole('status')).toHaveTextContent('Loading'); + + expect(mockApi).toHaveBeenCalled(); + + expect(await findByText('Advanced Filter')).toBeVisible(); + expect(queryByText('Basic Filters')).toBeNull(); + + const fieldSelect = await findByRole('combobox', { name: 'Field' }); + expect(fieldSelect).toHaveValue('~'); + const user = userEvent.setup(); + await user.selectOptions(fieldSelect, 'Full Name'); + const opSelect = await findByRole('combobox', { name: 'Operator' }); + expect(opSelect).toHaveValue('~'); + await user.selectOptions(opSelect, 'contains'); + const valueBox = await findByRole('textbox', { name: 'Value' }); + expect(valueBox).toHaveValue(''); + await user.type(valueBox, 'hi'); + + // currently not working, but should once we put in our own components + // expect(await axe(container)).toHaveNoViolations(); + + const exportButton = await findByRole('button', { name: 'Export' }); + await user.click(exportButton); + + expect(mockResultApi).toHaveBeenCalledWith({ + requestBody: expect.objectContaining({ + isExport: true, + advancedFilter: { + reportFilterUid: 1001, + value: { + id: expect.stringMatching(/[0-9-]+/), + combinator: 'and', + rules: [ + { + id: expect.stringMatching(/[0-9-]+/), + columnId: 2001, + operator: 'CO', + value: 'hi', + }, + ], + }, + }, + basicFilters: [], + }), + }); + }); + + it('allows submit when empty', async () => { + const mockApi = vi + .mocked(generated.ReportControllerService.getReportConfiguration) + .mockResolvedValue({ ...MOCK_CONFIG, advancedFilter: MOCK_FILTER }); + const mockResultApi = vi + .mocked(generated.ReportControllerService.exportReport) + .mockResolvedValue(MOCK_RESULT); + const { getByRole, findByText, findByRole } = renderWithRouter(); + + expect(getByRole('status')).toHaveTextContent('Loading'); + + expect(mockApi).toHaveBeenCalled(); + + expect(await findByText('Advanced Filter')).toBeVisible(); + + const exportButton = await findByRole('button', { name: 'Export' }); + const user = userEvent.setup(); + await user.click(exportButton); + + expect(mockResultApi).toHaveBeenCalledWith({ + requestBody: expect.objectContaining({ + isExport: true, + advancedFilter: undefined, + basicFilters: [], + }), + }); + }); + + it('validates rule states', async () => { + const mockApi = vi + .mocked(generated.ReportControllerService.getReportConfiguration) + .mockResolvedValue({ ...MOCK_CONFIG, advancedFilter: MOCK_FILTER }); + const mockResultApi = vi + .mocked(generated.ReportControllerService.exportReport) + .mockResolvedValue(MOCK_RESULT); + const { getByRole, queryByText, findByText, findByRole, findAllByRole, findByTestId } = renderWithRouter(); + + expect(getByRole('status')).toHaveTextContent('Loading'); + + expect(mockApi).toHaveBeenCalled(); + + expect(await findByText('Advanced Filter')).toBeVisible(); + + const fieldSelect = await findByRole('combobox', { name: 'Field' }); + expect(fieldSelect).toHaveValue('~'); + const user = userEvent.setup(); + await user.selectOptions(fieldSelect, 'Full Name'); + + // trigger validation + const exportButton = await findByRole('button', { name: 'Export' }); + await user.click(exportButton); + + expect(await findByText('Must select an operator and value')).toBeVisible(); + + // generally filled in + const opSelect = await findByRole('combobox', { name: 'Operator' }); + expect(opSelect).toHaveValue('~'); + await user.selectOptions(opSelect, 'contains'); + + expect(await findByText('Value cannot be empty')).toBeVisible(); + + const valueBox = await findByRole('textbox', { name: 'Value' }); + expect(valueBox).toHaveValue(''); + await user.type(valueBox, 'hi'); + + expect(queryByText('Value cannot be empty')).toBeNull(); + + // dates between + await user.selectOptions(fieldSelect, 'DATE_OF_BIRTH'); + expect(opSelect).toHaveValue('~'); + await user.selectOptions(opSelect, 'between'); + + expect(await findByText('Both low and high values required')).toBeVisible(); + + // The date entry will likely need to change once we switch to NBS components + const dtInputs = (await findByTestId('value-editor')).children; + await user.type(dtInputs[0], '2022-10-18'); + + expect(await findByText('Both low and high values required')).toBeVisible(); + + await user.type(dtInputs[1], '2022-10-17'); + + expect(await findByText('High value must be greater than or equal to low value')).toBeVisible(); + + await user.type(dtInputs[1], '{backspace}9'); + + expect(queryByText('High value must be greater than or equal to low value')).toBeNull(); + + // numbers between + await user.selectOptions(fieldSelect, 'DAYS_OLD'); + expect(opSelect).toHaveValue('~'); + await user.selectOptions(opSelect, 'between'); + + expect(await findByText('Both low and high values required')).toBeVisible(); + + const numInputs = await findAllByRole('spinbutton'); + await user.type(numInputs[0], '10'); + + expect(await findByText('Both low and high values required')).toBeVisible(); + + await user.type(numInputs[1], '2'); + + expect(await findByText('High value must be greater than or equal to low value')).toBeVisible(); + + await user.type(numInputs[1], '0'); + + await user.click(exportButton); + + expect(mockResultApi).toHaveBeenCalledWith({ + requestBody: expect.objectContaining({ + isExport: true, + advancedFilter: { + reportFilterUid: 1001, + value: { + id: expect.stringMatching(/[0-9-]+/), + combinator: 'and', + rules: [ + { + id: expect.stringMatching(/[0-9-]+/), + columnId: 2003, + operator: 'BW', + value: '10,20', + }, + ], + }, + }, + basicFilters: [], + }), + }); + }); + + it('starts from default value', async () => { + const mockApi = vi.mocked(generated.ReportControllerService.getReportConfiguration).mockResolvedValue({ + ...MOCK_CONFIG, + advancedFilter: { + ...MOCK_FILTER, + defaultValue: { + id: '123-123-123', + combinator: generated.RuleGroup.combinator.OR, + rules: [ + { + id: '124-124-124', + columnId: 2001, + operator: 'SW', + value: 'prefix', + }, + { + id: '125-125-125', + combinator: generated.RuleGroup.combinator.AND, + rules: [ + { + id: '126-126-126', + columnId: 2002, + operator: 'GT', + value: '2020-01-01', // format should be mm/dd/yyyy when we switch components + }, + { + id: '127-127-127', + columnId: 2003, + operator: 'BW', + value: '10,20', + }, + ], + }, + ], + }, + }, + }); + const mockResultApi = vi + .mocked(generated.ReportControllerService.exportReport) + .mockResolvedValue(MOCK_RESULT); + const { getByRole, findByText, findByRole, findAllByRole, findAllByTitle } = renderWithRouter(); + + expect(getByRole('status')).toHaveTextContent('Loading'); + + expect(mockApi).toHaveBeenCalled(); + + expect(await findByText('Advanced Filter')).toBeVisible(); + + const combinators = await findAllByRole('combobox', { name: 'Combinator' }); + expect(combinators).toHaveLength(2); + expect(combinators[0]).toHaveValue('or'); + expect(combinators[1]).toHaveValue('and'); + + const fields = await findAllByRole('combobox', { name: 'Field' }); + expect(fields).toHaveLength(3); + expect(fields[0]).toHaveValue('FULL_NAME'); + expect(fields[1]).toHaveValue('DATE_OF_BIRTH'); + expect(fields[2]).toHaveValue('DAYS_OLD'); + + const operators = await findAllByRole('combobox', { name: 'Operator' }); + expect(operators).toHaveLength(3); + expect(operators[0]).toHaveValue('beginswith'); + expect(operators[1]).toHaveValue('>'); + expect(operators[2]).toHaveValue('between'); + + const values = await findAllByTitle('Value'); + expect(values).toHaveLength(3); + expect(values[0]).toHaveValue('prefix'); + expect(values[1]).toHaveValue('2020-01-01'); + const [low, high] = values[2].children; + expect(low).toHaveValue(10); + expect(high).toHaveValue(20); + + const user = userEvent.setup(); + await user.type(high, '1'); + expect(high).toHaveValue(201); + + const exportButton = await findByRole('button', { name: 'Export' }); + await user.click(exportButton); + + expect(mockResultApi).toHaveBeenCalledWith({ + requestBody: expect.objectContaining({ + isExport: true, + advancedFilter: { + reportFilterUid: 1001, + value: { + id: '123-123-123', + combinator: generated.RuleGroup.combinator.OR, + rules: [ + { + id: '124-124-124', + columnId: 2001, + operator: 'SW', + value: 'prefix', + }, + { + id: '125-125-125', + combinator: generated.RuleGroup.combinator.AND, + rules: [ + { + id: '126-126-126', + columnId: 2002, + operator: 'GT', + // format should be mm/dd/yyyy when we switch components + value: '2020-01-01', + }, + { + id: '127-127-127', + columnId: 2003, + operator: 'BW', + value: '10,201', + }, + ], + }, + ], + }, + }, + basicFilters: [], + }), + }); + }); + + it('has keyboard drag and drop', async () => { + const mockApi = vi.mocked(generated.ReportControllerService.getReportConfiguration).mockResolvedValue({ + ...MOCK_CONFIG, + advancedFilter: { + ...MOCK_FILTER, + defaultValue: { + id: '123-123-123', + combinator: generated.RuleGroup.combinator.OR, + rules: [ + { + id: '124-124-124', + columnId: 2001, + operator: 'SW', + value: 'prefix', + }, + { + id: '125-125-125', + combinator: generated.RuleGroup.combinator.AND, + rules: [ + { + id: '127-127-127', + columnId: 2003, + operator: 'BW', + value: '10,20', + }, + ], + }, + { + id: '128-128-128', + combinator: generated.RuleGroup.combinator.OR, + rules: [ + { + id: '129-129-129', + columnId: 2002, + operator: 'GT', + value: '2020-01-01', // format should be mm/dd/yyyy when we switch components + }, + { + id: '130-130-130', + columnId: 2003, + operator: 'BW', + value: '10,20', + }, + ], + }, + ], + }, + }, + }); + const mockResultApi = vi + .mocked(generated.ReportControllerService.exportReport) + .mockResolvedValue(MOCK_RESULT); + const { getByRole, findByText, findByRole, findByTestId, findAllByTestId } = renderWithRouter(); + + expect(getByRole('status')).toHaveTextContent('Loading'); + + expect(mockApi).toHaveBeenCalled(); + + expect(await findByText('Advanced Filter')).toBeVisible(); + + const user = userEvent.setup(); + const ruleGroupHandle = async () => await findByTestId('drag-handle-128-128-128'); + const announcementEl = await findByTestId('announcement'); + + let ruleGroups = await findAllByTestId('rule-group'); + expect(ruleGroups[0]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[1]).not.toContainElement(await ruleGroupHandle()); + expect(ruleGroups[2]).toContainElement(await ruleGroupHandle()); + expect(announcementEl).toHaveTextContent(''); + + // activate handle and move up into above group + await user.type(await ruleGroupHandle(), ' '); + await waitFor(() => expect(announcementEl).toHaveTextContent('You have lifted a group at path 3')); + await user.type(await ruleGroupHandle(), '{ArrowUp}'); + await waitFor(() => expect(announcementEl).toHaveTextContent('You have moved the group up to path 2-2')); + + ruleGroups = await findAllByTestId('rule-group'); + expect(await ruleGroupHandle()).toHaveFocus(); + expect(ruleGroups[0]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[1]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[2]).toContainElement(await ruleGroupHandle()); + + // move back down and make sure restores + await user.type(await ruleGroupHandle(), '{ArrowDown}'); + await waitFor(() => expect(announcementEl).toHaveTextContent('You have moved the group down to path 3')); + + ruleGroups = await findAllByTestId('rule-group'); + expect(await ruleGroupHandle()).toHaveFocus(); + expect(ruleGroups[0]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[1]).not.toContainElement(await ruleGroupHandle()); + expect(ruleGroups[2]).toContainElement(await ruleGroupHandle()); + + // move back up and above rule and deactivate + await user.type(await ruleGroupHandle(), '{ArrowUp}{ArrowUp} '); + await waitFor(() => expect(announcementEl).toHaveTextContent('You have dropped the group at path 2-1')); + + ruleGroups = await findAllByTestId('rule-group'); + expect(await ruleGroupHandle()).toHaveFocus(); + expect(ruleGroups[0]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[1]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[2]).toContainElement(await ruleGroupHandle()); + + // nothing should change, since not active + await user.type(await ruleGroupHandle(), '{ArrowUp}'); + expect(announcementEl).toHaveTextContent('You have dropped the group at path 2-1'); + + ruleGroups = await findAllByTestId('rule-group'); + expect(await ruleGroupHandle()).toHaveFocus(); + expect(ruleGroups[0]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[1]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[2]).toContainElement(await ruleGroupHandle()); + + // check escape handling works, should not change from previous + await user.type(await ruleGroupHandle(), ' {ArrowUp}{Escape}{ArrowDown}'); + await waitFor(() => + expect(announcementEl).toHaveTextContent('The group has returned to its starting position') + ); + + ruleGroups = await findAllByTestId('rule-group'); + await waitFor(async () => expect(await ruleGroupHandle()).toHaveFocus()); + expect(ruleGroups[0]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[1]).toContainElement(await ruleGroupHandle()); + expect(ruleGroups[2]).toContainElement(await ruleGroupHandle()); + + const exportButton = await findByRole('button', { name: 'Export' }); + await user.click(exportButton); + + expect(mockResultApi).toHaveBeenCalledWith({ + requestBody: expect.objectContaining({ + isExport: true, + advancedFilter: { + reportFilterUid: 1001, + value: { + id: '123-123-123', + combinator: generated.RuleGroup.combinator.OR, + rules: [ + { + id: '124-124-124', + columnId: 2001, + operator: 'SW', + value: 'prefix', + }, + { + id: '125-125-125', + combinator: generated.RuleGroup.combinator.AND, + rules: [ + { + id: '128-128-128', + combinator: generated.RuleGroup.combinator.OR, + rules: [ + { + id: '129-129-129', + columnId: 2002, + operator: 'GT', + // format should be mm/dd/yyyy when we switch components + value: '2020-01-01', + }, + { + id: '130-130-130', + columnId: 2003, + operator: 'BW', + value: '10,20', + }, + ], + }, + { + id: '127-127-127', + columnId: 2003, + operator: 'BW', + value: '10,20', + }, + ], + }, + ], + }, + }, + basicFilters: [], + }), + }); + }); + }); }); diff --git a/apps/modernization-ui/src/apps/report/run/ReportRunPage.tsx b/apps/modernization-ui/src/apps/report/run/ReportRunPage.tsx index 798d61ac0b..1aa1ce2fc6 100644 --- a/apps/modernization-ui/src/apps/report/run/ReportRunPage.tsx +++ b/apps/modernization-ui/src/apps/report/run/ReportRunPage.tsx @@ -11,7 +11,7 @@ import { ReportResultPage } from './ReportResultPage'; import { LoadingIndicator } from 'libs/loading/indicator'; import { FormProvider, useForm } from 'react-hook-form'; import { AlertBanner } from 'apps/page-builder/components/AlertBanner/AlertBanner'; -import { QbRuleGroup, queryToAdvancedFilterRequest } from './filters/AdvancedFilter'; +import { QbRuleGroup, queryToAdvancedFilterRequest } from './filters/advanced/AdvancedFilter'; export type ReportExecuteForm = { // key is the report's ID diff --git a/apps/modernization-ui/src/apps/report/run/filters/AdvancedFilter.tsx b/apps/modernization-ui/src/apps/report/run/filters/advanced/AdvancedFilter.tsx similarity index 88% rename from apps/modernization-ui/src/apps/report/run/filters/AdvancedFilter.tsx rename to apps/modernization-ui/src/apps/report/run/filters/advanced/AdvancedFilter.tsx index fb6db2fde5..7b80dead59 100644 --- a/apps/modernization-ui/src/apps/report/run/filters/AdvancedFilter.tsx +++ b/apps/modernization-ui/src/apps/report/run/filters/advanced/AdvancedFilter.tsx @@ -13,11 +13,18 @@ import QueryBuilder, { ValidationResult, } from 'react-querybuilder'; import 'react-querybuilder/dist/query-builder.css'; -import { ReportExecuteForm } from '../ReportRunPage'; +import { ReportExecuteForm } from '../../ReportRunPage'; import { AlertBanner } from 'apps/page-builder/components/AlertBanner/AlertBanner'; import { QueryBuilderDnD } from '@react-querybuilder/dnd'; -import { createDndKitAdapter } from '@react-querybuilder/dnd/dnd-kit'; -import * as DndKit from '@dnd-kit/core'; +import { createPragmaticDndAdapter } from '@react-querybuilder/dnd/pragmatic-dnd'; +import { + draggable, + dropTargetForElements, + monitorForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { KeyboardDnDProvider } from './useKeyboardDnd'; +import { ShiftableDragHandle } from './ShiftableDragHandle'; // ============= Constants ============= / @@ -265,7 +272,12 @@ const validateRule = (rule: RuleGroupTypeAny | RuleType | string, result: Valida // ============= Drag And Drop ============= / -const dndKitAdapter = createDndKitAdapter(DndKit); +const pragmaticDndAdapter = createPragmaticDndAdapter({ + draggable, + dropTargetForElements, + monitorForElements, + combine, +}); // ============= Componentry ============= / @@ -290,18 +302,21 @@ const AdvancedFilter = ({ filter, columns }: { filter: AdvancedFilterConfigurati return (
{error?.message && {error.message}} - - - + + + + +
); diff --git a/apps/modernization-ui/src/apps/report/run/filters/advanced/ShiftableDragHandle.tsx b/apps/modernization-ui/src/apps/report/run/filters/advanced/ShiftableDragHandle.tsx new file mode 100644 index 0000000000..37fe759b71 --- /dev/null +++ b/apps/modernization-ui/src/apps/report/run/filters/advanced/ShiftableDragHandle.tsx @@ -0,0 +1,85 @@ +import { forwardRef, ForwardRefExoticComponent, KeyboardEventHandler, RefAttributes, useEffect, useState } from 'react'; +import { DragHandleProps } from 'react-querybuilder'; +import { useKeyboardDnd } from './useKeyboardDnd'; +import { Icon } from 'design-system/icon'; + +// custom drag handle to add shifting action on keyboard up down when space enabled +const ShiftableDragHandle: ForwardRefExoticComponent> = forwardRef< + HTMLSpanElement, + DragHandleProps +>((props, dragRef) => { + const { activeId, activate, reset, drag, drop } = useKeyboardDnd(); + const id = props.ruleOrGroup.id!; + const [isActive, setIsActive] = useState(activeId === id); + const { getQuery, dispatchQuery } = props.schema; + + // When a rule group changes level, the component re-mounts and we need to move focus + // back to the drag handle + useEffect(() => { + if (isActive) { + const thisEl = document.querySelector(`#drag-handle-${id}`); + thisEl?.focus(); + } + }, []); + + const handleKeyDown: KeyboardEventHandler = (event) => { + // space toggles, escape will turn off activity if active + if (event.code === 'Space') { + if (!isActive) { + setIsActive(true); + activate(props.ruleOrGroup, props.path); + } else { + setIsActive(false); + drop(props.path); + } + event.preventDefault(); + return; + } else if (isActive && event.code === 'Escape') { + setIsActive(false); + reset(getQuery(), dispatchQuery); + // restore move focus to the drag handle that was moved back + setTimeout(() => { + const thisEl = document.querySelector(`#drag-handle-${id}`); + thisEl?.focus(); + }, 50); + event.preventDefault(); + return; + } + + if (!isActive) return; + + let dir: 'up' | 'down' | undefined = undefined; + if (event.code === 'ArrowUp') { + dir = 'up'; + } else if (event.code === 'ArrowDown') { + dir = 'down'; + } else { + // only allow stated actions while activated + event.preventDefault(); + event.stopPropagation(); + } + + if (!dir) return; + + // move the rule and update the query + drag(getQuery(), dispatchQuery, dir); + event.preventDefault(); + }; + + return ( + + + + ); +}); + +export { ShiftableDragHandle }; diff --git a/apps/modernization-ui/src/apps/report/run/filters/advanced/useKeyboardDnd.tsx b/apps/modernization-ui/src/apps/report/run/filters/advanced/useKeyboardDnd.tsx new file mode 100644 index 0000000000..03f99ac06c --- /dev/null +++ b/apps/modernization-ui/src/apps/report/run/filters/advanced/useKeyboardDnd.tsx @@ -0,0 +1,98 @@ +import VisuallyHidden from 'components/VisuallyHidden/VisuallyHidden'; +import { createContext, ReactNode, useContext, useState } from 'react'; +import { getPathOfID, isRuleType, move, Path, RuleGroupTypeAny, RuleType } from 'react-querybuilder'; + +type RuleOrGroupType = RuleType | RuleGroupTypeAny; + +type KeyboardDndContextType = { + activeId?: string; + activate: (ruleOrGroup: RuleOrGroupType, path: Path) => void; + reset: (query: RuleGroupTypeAny, dispatchQuery: (q: RuleGroupTypeAny) => void) => void; + drop: (curPath: Path) => void; + drag: (query: RuleGroupTypeAny, dispatchQuery: (q: RuleGroupTypeAny) => void, dir: 'up' | 'down') => void; +}; + +const KeyboardDndContext = createContext({ + activeId: undefined, + activate: () => {}, + reset: () => {}, + drag: () => {}, + drop: () => {}, +}); + +type Props = { + children: ReactNode; +}; +const KeyboardDnDProvider = ({ children }: Props) => { + const [activeRuleOrGroup, setActiveRuleOrGroup] = useState(null); + const [startPath, setStartPath] = useState(null); + const [announcedMessage, setAnnouncedMessage] = useState(''); + const activeId = activeRuleOrGroup?.id; + + const activate = (ruleOrGroup: RuleOrGroupType, path: Path) => { + setActiveRuleOrGroup(ruleOrGroup); + // make a copy to be safe + setStartPath([...path]); + announce(`You have lifted a ${isRuleType(ruleOrGroup) ? 'rule' : 'group'} at path ${describeLocation(path)}`); + }; + + const reset = (query: RuleGroupTypeAny, dispatchQuery: (q: RuleGroupTypeAny) => void) => { + if (!activeId) return; + setActiveRuleOrGroup(null); + setStartPath(null); + const nextQuery = move(query, activeId, startPath ?? []); + dispatchQuery(nextQuery); + announce(`The ${isRuleType(activeRuleOrGroup) ? 'rule' : 'group'} has returned to its starting position`); + }; + + const drag = (query: RuleGroupTypeAny, dispatchQuery: (q: RuleGroupTypeAny) => void, dir: 'up' | 'down') => { + if (!activeId) return; + const nextQuery = move(query, activeId, dir); + const nextPath = getPathOfID(activeId, nextQuery); + dispatchQuery(nextQuery); + announce( + `You have moved the ${isRuleType(activeRuleOrGroup) ? 'rule' : 'group'} ${dir} + to path ${describeLocation(nextPath)}` + ); + }; + + const drop = (curPath: Path) => { + setActiveRuleOrGroup(null); + setStartPath(null); + announce( + `You have dropped the ${isRuleType(activeRuleOrGroup) ? 'rule' : 'group'} + at path ${describeLocation(curPath)}` + ); + }; + + const announce = (message: string): void => { + // slight timeout improves screen reader flakiness + setTimeout(() => setAnnouncedMessage(message), 100); + }; + + return ( + + + In the advanced filter builder below, you can drag and drop rules and groups to change the logic of how + statements are combined. When focused on a drag handle for a rule or group, press space bar to start and + stop a drag. When dragging you can use the arrow keys to move the item around and escape to cancel. Some + screen readers may require you to be in focus mode or to use your pass through key + + + {announcedMessage} + + {children} + + ); +}; + +const useKeyboardDnd = () => { + return useContext(KeyboardDndContext); +}; + +const describeLocation = (path: Path | null) => { + if (!path) return 'unknown'; + return path.map((n) => n + 1).join('-'); +}; + +export { KeyboardDnDProvider, useKeyboardDnd }; diff --git a/apps/modernization-ui/src/apps/report/run/filters/BasicFilter.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/BasicFilter.tsx similarity index 100% rename from apps/modernization-ui/src/apps/report/run/filters/BasicFilter.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/BasicFilter.tsx diff --git a/apps/modernization-ui/src/apps/report/run/filters/DateRangeFilter.spec.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/DateRangeFilter.spec.tsx similarity index 100% rename from apps/modernization-ui/src/apps/report/run/filters/DateRangeFilter.spec.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/DateRangeFilter.spec.tsx diff --git a/apps/modernization-ui/src/apps/report/run/filters/DateRangeFilter.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/DateRangeFilter.tsx similarity index 100% rename from apps/modernization-ui/src/apps/report/run/filters/DateRangeFilter.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/DateRangeFilter.tsx diff --git a/apps/modernization-ui/src/apps/report/run/filters/MonthYearRangeFilter.spec.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/MonthYearRangeFilter.spec.tsx similarity index 100% rename from apps/modernization-ui/src/apps/report/run/filters/MonthYearRangeFilter.spec.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/MonthYearRangeFilter.spec.tsx diff --git a/apps/modernization-ui/src/apps/report/run/filters/MonthYearRangeFilter.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/MonthYearRangeFilter.tsx similarity index 100% rename from apps/modernization-ui/src/apps/report/run/filters/MonthYearRangeFilter.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/MonthYearRangeFilter.tsx diff --git a/apps/modernization-ui/src/apps/report/run/filters/OptionSelectFilter.spec.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/OptionSelectFilter.spec.tsx similarity index 100% rename from apps/modernization-ui/src/apps/report/run/filters/OptionSelectFilter.spec.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/OptionSelectFilter.spec.tsx diff --git a/apps/modernization-ui/src/apps/report/run/filters/OptionSelectFilter.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/OptionSelectFilter.tsx similarity index 96% rename from apps/modernization-ui/src/apps/report/run/filters/OptionSelectFilter.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/OptionSelectFilter.tsx index cc63c590e0..ce30d52ab6 100644 --- a/apps/modernization-ui/src/apps/report/run/filters/OptionSelectFilter.tsx +++ b/apps/modernization-ui/src/apps/report/run/filters/basic/OptionSelectFilter.tsx @@ -2,13 +2,13 @@ import { BasicFilterComponent, BasicFilterProps } from './BasicFilter'; import { BasicFilterConfiguration } from 'generated'; import { MultiSelect } from 'design-system/select'; import { useCountyOptions, useStateOptions } from 'options/location'; +import { useConditionOptions } from 'options/condition'; +import { useConceptOptions } from 'options/concepts'; import { SelectInput } from 'components/FormInputs/SelectInput'; import { useEffect } from 'react'; import { useCurrentState } from './useCurrentState'; import { Selectable } from 'options'; import { validateRequiredRule } from 'validation/entry'; -import { useConditionOptions } from '../../../../options/condition'; -import { useConceptOptions } from '../../../../options/concepts'; export const STATE_FILTER_CODE = 'J_S01'; export const COUNTY_FILTER_CODE = 'J_C01'; diff --git a/apps/modernization-ui/src/apps/report/run/filters/TextFilter.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/TextFilter.tsx similarity index 100% rename from apps/modernization-ui/src/apps/report/run/filters/TextFilter.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/TextFilter.tsx diff --git a/apps/modernization-ui/src/apps/report/run/filters/YearRangeFilter.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/YearRangeFilter.tsx similarity index 100% rename from apps/modernization-ui/src/apps/report/run/filters/YearRangeFilter.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/YearRangeFilter.tsx diff --git a/apps/modernization-ui/src/apps/report/run/filters/useCurrentState.tsx b/apps/modernization-ui/src/apps/report/run/filters/basic/useCurrentState.tsx similarity index 100% rename from apps/modernization-ui/src/apps/report/run/filters/useCurrentState.tsx rename to apps/modernization-ui/src/apps/report/run/filters/basic/useCurrentState.tsx diff --git a/apps/modernization-ui/src/components/VisuallyHidden/VisuallyHidden.module.scss b/apps/modernization-ui/src/components/VisuallyHidden/VisuallyHidden.module.scss new file mode 100644 index 0000000000..41a3721906 --- /dev/null +++ b/apps/modernization-ui/src/components/VisuallyHidden/VisuallyHidden.module.scss @@ -0,0 +1,9 @@ +.visually-hidden:not(:focus):not(:active) { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); /* Legacy property for Internet Explorer */ + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/apps/modernization-ui/src/components/VisuallyHidden/VisuallyHidden.tsx b/apps/modernization-ui/src/components/VisuallyHidden/VisuallyHidden.tsx new file mode 100644 index 0000000000..e83afdee10 --- /dev/null +++ b/apps/modernization-ui/src/components/VisuallyHidden/VisuallyHidden.tsx @@ -0,0 +1,44 @@ +import React, { ReactNode } from 'react'; +import styles from './VisuallyHidden.module.scss'; + +// From https://www.joshwcomeau.com/snippets/react-components/visually-hidden/ +// Display text for screen readers only, but in dev mode can hold `Alt` to display the value +const VisuallyHidden = ({ children, ...delegated }: { children: ReactNode } & JSX.IntrinsicElements['span']) => { + const [forceShow, setForceShow] = React.useState(false); + + React.useEffect(() => { + if (import.meta.env.DEV) { + const handleKeyDown = (ev: KeyboardEvent) => { + if (ev.key === 'Alt') { + setForceShow(true); + } + }; + + const handleKeyUp = (ev: KeyboardEvent) => { + if (ev.key === 'Alt') { + setForceShow(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + } + }, []); + + if (forceShow) { + return children; + } + + return ( + + {children} + + ); +}; + +export default VisuallyHidden; diff --git a/apps/modernization-ui/src/components/VisuallyHidden/index.tsx b/apps/modernization-ui/src/components/VisuallyHidden/index.tsx new file mode 100644 index 0000000000..f11f3f421b --- /dev/null +++ b/apps/modernization-ui/src/components/VisuallyHidden/index.tsx @@ -0,0 +1 @@ +export * from './VisuallyHidden'; diff --git a/apps/modernization-ui/src/generated/models/Rule.ts b/apps/modernization-ui/src/generated/models/Rule.ts index 87b9458f5c..7b2e2c301e 100644 --- a/apps/modernization-ui/src/generated/models/Rule.ts +++ b/apps/modernization-ui/src/generated/models/Rule.ts @@ -14,4 +14,3 @@ export type Rule = (AdvancedQuery & { operator: string; value: string; }); -