diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b7c0255..9b77184 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,14 +9,13 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '16.x' registry-url: 'https://npm.pkg.github.com' scope: '@ecmwf-projects' - run: yarn install --frozen-lockfile - - run: yarn test - run: yarn build - run: yarn publish env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a2f4cb..2636b73 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ jobs: unit-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '16.x' @@ -17,8 +17,8 @@ jobs: component-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Cypress run - uses: cypress-io/github-action@v4 + uses: cypress-io/github-action@v5 with: command: yarn test:components:ci diff --git a/.gitignore b/.gitignore index 6704566..7d58996 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ dist # TernJS port file .tern-port + +# Cypress +cypress/videos diff --git a/.nycrc.json b/.nycrc.json new file mode 100644 index 0000000..2b5c8ca --- /dev/null +++ b/.nycrc.json @@ -0,0 +1,7 @@ +{ + "all": true, + "extends": "@istanbuljs/nyc-config-typescript", + "check-coverage": true, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["cypress/**/*.*", "**/*.d.ts", "**/*.cy.tsx", "**/*.cy.ts"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3848a91..d88713c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.1.0] 2023-04-21 + +## Added + +- ability to bypass the `required` attribute for `StringListArrayWidget`, `StringListWidget`, `StringChoiceWidget`, and `ExclusiveGroupWidget` children. + ## [5.0.2] - 2023-04-18 ## Fixed diff --git a/__tests__/factories.ts b/__tests__/factories.ts index 9ea4378..a20d827 100644 --- a/__tests__/factories.ts +++ b/__tests__/factories.ts @@ -55,7 +55,7 @@ export const getStringListArrayWidgetConfiguration = () => { ], id: 1 }, - help: 'Please, consult the product user guide in the documentation section for more information on these variables.', + help: null, label: 'Variable', name: 'variable', required: true, @@ -119,7 +119,7 @@ export const getStringListWidgetConfiguration = () => { 'monthly_averaged_reanalysis_by_hour_of_day' ] }, - help: 'Monthly averaged reanalysis data are produced by averaging all daily data. Monthly averages by hour of day constitute the average over all data within the calendar month for every hour (UTC) of the day.', + help: null, label: 'Product type', name: 'product_type', required: true, diff --git a/cypress.config.ts b/cypress.config.ts index 089a80c..e5bbb0a 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,10 +1,20 @@ import { defineConfig } from 'cypress' export default defineConfig({ + env: { + codeCoverage: { exclude: 'cypress/**/*.*' } + }, + video: false, component: { devServer: { framework: 'react', bundler: 'vite' + }, + screenshotOnRunFailure: true, + setupNodeEvents(on, config) { + require('@cypress/code-coverage/task')(on, config) + + return config } } }) diff --git a/cypress/component/ExclusiveGroupWidget.cy.tsx b/cypress/component/ExclusiveGroupWidget.cy.tsx index 092bbc4..106b834 100644 --- a/cypress/component/ExclusiveGroupWidget.cy.tsx +++ b/cypress/component/ExclusiveGroupWidget.cy.tsx @@ -37,7 +37,7 @@ describe('', () => { const configuration = { type: 'ExclusiveGroupWidget' as const, label: 'Generic selections', - help: 'Select one choice from the widgets below', + help: null, name: 'checkbox_groups', children: ['variable', 'surface_help'], details: { @@ -55,17 +55,15 @@ describe('', () => { cy.viewport(800, 600) cy.mount( - -
- - -
+
+ + ).then(({ rerender }) => { cy.findByLabelText('Lake shape factor').click() cy.findByLabelText('Soil temperature level 3').click() @@ -108,7 +106,7 @@ describe('', () => { const configuration = { type: 'ExclusiveGroupWidget' as const, label: 'Generic selections', - help: 'Select one choice from the widgets below', + help: null, name: 'checkbox_groups', children: ['product_type', 'surface_help'], details: { @@ -136,17 +134,15 @@ describe('', () => { cy.viewport(984, 597) cy.mount( - -
- - -
+
+ + ) cy.findByLabelText('Monthly averaged reanalysis').click() @@ -163,7 +159,7 @@ describe('', () => { const configuration = { type: 'ExclusiveGroupWidget' as const, label: 'Generic selections', - help: 'Select one choice from the widgets below', + help: null, name: 'checkbox_groups', children: ['product_type', 'variable'], details: { @@ -178,15 +174,13 @@ describe('', () => { ] cy.mount( - - - + ) cy.findByLabelText('Skin temperature') @@ -196,7 +190,7 @@ describe('', () => { const configuration = { type: 'ExclusiveGroupWidget' as const, label: 'Geographical area', - help: 'Select one choice from the widgets below', + help: null, name: 'area_group', children: ['global', 'area'], details: { @@ -217,6 +211,7 @@ describe('', () => { }, { ...getGeographicExtentWidgetConfiguration(), + help: null, label: 'Sub-region extraction' } ] @@ -225,17 +220,15 @@ describe('', () => { cy.viewport(1200, 900) cy.mount( - -
- - -
+
+ + ) cy.findByLabelText('Sub-region extraction').should( @@ -267,7 +260,7 @@ describe('', () => { const configuration = { type: 'ExclusiveGroupWidget' as const, label: 'Generic selections', - help: 'Select one choice from the widgets below', + help: null, name: 'checkbox_groups', children: ['format', 'surface_help'], details: { @@ -285,17 +278,15 @@ describe('', () => { cy.viewport(800, 600) cy.mount( - -
- - -
+
+ + ) cy.findByLabelText('NetCDF (experimental)').click() @@ -311,7 +302,7 @@ describe('', () => { const thisExclusive = { type: 'ExclusiveGroupWidget' as const, label: 'This exclusive', - help: 'This exclusive', + help: null, name: 'this_exclusive', children: ['format', 'surface_help'], details: { @@ -322,7 +313,7 @@ describe('', () => { const otherExclusive = { type: 'ExclusiveGroupWidget' as const, label: 'Other exclusive', - help: 'Other exclusive', + help: null, name: 'other_exclusive', children: ['product_type'], details: { @@ -368,25 +359,23 @@ describe('', () => { cy.viewport(800, 600) cy.mount( - -
- - - - -
+
+ + + + ) cy.findByLabelText(/netcdf/i) @@ -400,4 +389,48 @@ describe('', () => { ['product_type', 'monthly_averaged_reanalysis'] ]) }) + + it('bypasses the required attribute if all options are made unavailable by constraints', () => { + const configuration = { + type: 'ExclusiveGroupWidget' as const, + label: 'Generic selections', + help: null, + name: 'checkbox_groups', + children: ['product_type', 'variable', 'format'], + details: { + default: 'product_type' + } + } + + const formConfiguration = [ + configuration, + getStringListWidgetConfiguration(), + getStringListArrayWidgetConfiguration(), + getStringChoiceWidgetConfiguration() + ] + + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.mount( +
+ + + ) + + cy.findByText('submit').click() + + cy.get('@stubbedHandleSubmit').should('have.been.calledOnceWith', [ + ['bypassRequired', 'product_type'], + ['bypassRequired', 'variable'], + ['bypassRequired', 'format'] + ]) + }) }) diff --git a/cypress/component/StringChoiceWidget.cy.tsx b/cypress/component/StringChoiceWidget.cy.tsx new file mode 100644 index 0000000..2cafff5 --- /dev/null +++ b/cypress/component/StringChoiceWidget.cy.tsx @@ -0,0 +1,67 @@ +import React from 'react' + +import { StringChoiceWidget } from '../../src' + +import { getStringChoiceWidgetConfiguration } from '../../__tests__/factories' + +const Form = ({ + children, + handleSubmit +}: { + children: React.ReactNode + handleSubmit: (...args: any) => void +}) => { + return ( +
{ + ev.preventDefault() + const formData = new FormData(ev.currentTarget) + handleSubmit([...formData.entries()]) + }} + > + {children} + +
+ ) +} + +describe('', () => { + it('handles selection', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.mount( +
+ + + ) + + cy.findByLabelText('NetCDF (experimental)').click() + cy.findByText('submit').click() + + cy.get('@stubbedHandleSubmit').should('have.been.calledOnceWith', [ + ['format', 'netcdf'] + ]) + }) + + it('bypasses the required attribute if all options are made unavailable by constraints', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.mount( +
+ + + ) + + cy.findByText('submit').click() + + cy.get('@stubbedHandleSubmit').should('have.been.calledOnceWith', [ + ['bypassRequired', 'format'] + ]) + }) +}) diff --git a/cypress/component/StringListArrayWidget.cy.tsx b/cypress/component/StringListArrayWidget.cy.tsx new file mode 100644 index 0000000..4f74088 --- /dev/null +++ b/cypress/component/StringListArrayWidget.cy.tsx @@ -0,0 +1,92 @@ +import React from 'react' + +import { StringListArrayWidget } from '../../src' + +import { getStringListArrayWidgetConfiguration } from '../../__tests__/factories' + +const Form = ({ + children, + handleSubmit +}: { + children: React.ReactNode + handleSubmit: (...args: any) => void +}) => { + return ( +
{ + ev.preventDefault() + const formData = new FormData(ev.currentTarget) + handleSubmit([...formData.entries()]) + }} + > + {children} + +
+ ) +} + +describe('', () => { + it('appends current selection for closed accordions', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.mount( +
+ + + ) + + cy.findByText(/select all/i).click() + + cy.findByText('submit').click() + + cy.get('@stubbedHandleSubmit').should('have.been.calledOnceWith', [ + ['variable', 'lake_bottom_temperature'], + ['variable', 'lake_ice_depth'], + ['variable', 'lake_ice_temperature'], + ['variable', 'lake_mix_layer_depth'], + ['variable', 'lake_mix_layer_temperature'], + ['variable', 'lake_shape_factor'], + ['variable', 'lake_total_layer_temperature'], + ['variable', '2m_dewpoint_temperature'], + ['variable', '2m_temperature'], + ['variable', 'skin_temperature'], + ['variable', 'soil_temperature_level_1'], + ['variable', 'soil_temperature_level_2'], + ['variable', 'soil_temperature_level_3'], + ['variable', 'soil_temperature_level_4'] + ]) + }) + + it('bypasses the required attribute if all options are made unavailable by constraints', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.mount( +
+ + + ) + + cy.findByText('submit').click() + + cy.get('@stubbedHandleSubmit').should('have.been.calledOnceWith', [ + ['bypassRequired', 'variable'] + ]) + + cy.findByText(/at least one selection must be made/i).should('not.exist') + }) +}) diff --git a/cypress/component/StringListWidget.cy.tsx b/cypress/component/StringListWidget.cy.tsx new file mode 100644 index 0000000..e60ee27 --- /dev/null +++ b/cypress/component/StringListWidget.cy.tsx @@ -0,0 +1,68 @@ +import React from 'react' + +import { StringListWidget } from '../../src' + +import { getStringListWidgetConfiguration } from '../../__tests__/factories' + +const Form = ({ + children, + handleSubmit +}: { + children: React.ReactNode + handleSubmit: (...args: any) => void +}) => { + return ( +
{ + ev.preventDefault() + const formData = new FormData(ev.currentTarget) + handleSubmit([...formData.entries()]) + }} + > + {children} + +
+ ) +} + +describe('', () => { + it('handles selection', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.mount( +
+ + + ) + + cy.findByLabelText('Monthly averaged reanalysis').click() + + cy.findByText('submit').click() + + cy.get('@stubbedHandleSubmit').should('have.been.calledOnceWith', [ + ['product_type', 'monthly_averaged_reanalysis'] + ]) + }) + + it('bypasses the required attribute if all options are made unavailable by constraints', () => { + const stubbedHandleSubmit = cy.stub().as('stubbedHandleSubmit') + + cy.mount( +
+ + + ) + + cy.findByText('submit').click() + + cy.get('@stubbedHandleSubmit').should('have.been.calledOnceWith', [ + ['bypassRequired', 'product_type'] + ]) + + cy.findByText(/at least one selection must be made/i).should('not.exist') + }) +}) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index bc98b46..9586027 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -36,3 +36,4 @@ // } // } import '@testing-library/cypress/add-commands' +import '@cypress/code-coverage/support' diff --git a/package.json b/package.json index a53e923..ad4dec0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ecmwf-projects/cads-ui-library", - "version": "5.0.2", + "version": "5.0.3-0", "description": "Common UI kit library", "repository": { "type": "git", @@ -24,10 +24,11 @@ "clean": "rimraf dist", "prebuild": "npm run clean", "build": "tsc", - "test": "jest --coverage", + "test": "jest", "test:watch": "jest --watch", "test:components": "cypress open", "test:components:ci": "cypress run --component", + "test:components:coverage": "nyc report", "format": "prettier --write .", "lint:format": "prettier --check .", "lint": "eslint src/**", @@ -37,38 +38,41 @@ "postversion": "git push --follow-tags" }, "devDependencies": { + "@cypress/code-coverage": "^3.10.4", + "@istanbuljs/nyc-config-typescript": "^1.0.2", "@testing-library/cypress": "^9.0.0", "@testing-library/dom": "^9.2.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", - "@types/node": "^18.15.11", - "@types/react": "^18.0.33", + "@types/node": "^18.15.12", + "@types/react": "^18.0.37", "@types/react-dom": "^18.0.11", "@types/styled-components": "^5.1.26", "@types/styled-system": "^5.1.16", "@types/testing-library__jest-dom": "^5.14.5", - "@typescript-eslint/eslint-plugin": "^5.57.1", - "@typescript-eslint/parser": "^5.57.1", + "@typescript-eslint/eslint-plugin": "^5.59.0", + "@typescript-eslint/parser": "^5.59.0", "@vitejs/plugin-react": "^3.1.0", "cypress": "^12.10.0", - "eslint": "^8.37.0", + "eslint": "^8.38.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-jest": "^27.2.1", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.32.2", - "husky": "^7.0.0", + "husky": "^8.0.3", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", - "lint-staged": "^13.0.3", - "prettier": "^2.7.1", - "rimraf": "^3.0.2", + "lint-staged": "^13.2.1", + "nyc": "^15.1.0", + "prettier": "^2.8.7", + "rimraf": "^5.0.0", "styled-components": "^5.3.9", "ts-jest": "^29.1.0", - "ts-node": "^10.9.1", - "type-fest": "^3.7.2", - "typescript": "^5.0.3", - "vite": "^4.2.1" + "type-fest": "^3.8.0", + "typescript": "^5.0.4", + "vite": "^4.3.0", + "vite-plugin-istanbul": "^4.0.1" }, "dependencies": { "@radix-ui/react-accordion": "^1.1.1", @@ -90,5 +94,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "styled-components": "^5.3.5" + }, + "resolutions": { + "json5": "2.2.3" } } diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index 68aeb91..7a09943 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -1,5 +1,5 @@ -import { useState, useRef, useEffect } from 'react' -import { useReadLocalStorage } from 'usehooks-ts' +import { useState, useRef, useEffect, RefObject } from 'react' +import { useReadLocalStorage, useEventListener } from 'usehooks-ts' const useWidgetSelection = (fieldset: string) => { const [selection, setSelection] = useState>({ @@ -38,4 +38,37 @@ const useWidgetSelection = (fieldset: string) => { return { selection, setSelection } } -export { useWidgetSelection } +type UseBypassRequired = ( + elementRef: RefObject, + bypass?: boolean, + constraints?: string[] +) => boolean +/** + * Bypass the required attribute if all options are made unavailable by constraints. + */ +const useBypassRequired: UseBypassRequired = ( + elementRef, + bypass = false, + constraints +) => { + const form = useRef(elementRef.current?.form || null) + + const injectBypassRequired = (ev: FormDataEvent) => { + if (!bypass) return + if (!elementRef.current) return + if (!elementRef.current.name) return + if (!constraints || constraints?.length) return + + const { formData } = ev + + formData.append('bypassRequired', elementRef.current.name) + } + + useEventListener('formdata', injectBypassRequired, form) + + if (!constraints) return false + + return !constraints.length && bypass +} + +export { useWidgetSelection, useBypassRequired } diff --git a/src/utils/index.ts b/src/utils/index.ts index 77482fa..8f58a90 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,6 +4,6 @@ export { isAllSelected } from './constraints' -export { useWidgetSelection } from './hooks' +export { useWidgetSelection, useBypassRequired } from './hooks' export { SelectAll, ClearAll } from './bulkSelector' diff --git a/src/utils/widgetFactory.tsx b/src/utils/widgetFactory.tsx index cb32b3e..896c23d 100644 --- a/src/utils/widgetFactory.tsx +++ b/src/utils/widgetFactory.tsx @@ -14,9 +14,15 @@ import type { FormConfiguration } from '../types/Form' */ type CreateWidget = ( configuration: FormConfiguration, - constraints?: string[] + constraints?: string[], + opts?: { + /** + * When true, bypass the required attribute if all options are made unavailable by constraints. + */ + bypassRequiredForConstraints?: boolean + } ) => (...props: any) => JSX.Element | null -const createWidget: CreateWidget = (configuration, constraints) => { +const createWidget: CreateWidget = (configuration, constraints, opts) => { switch (configuration.type) { case 'GeographicExtentWidget': // eslint-disable-next-line react/display-name @@ -28,6 +34,7 @@ const createWidget: CreateWidget = (configuration, constraints) => { return props => ( @@ -37,6 +44,7 @@ const createWidget: CreateWidget = (configuration, constraints) => { return props => ( @@ -46,6 +54,7 @@ const createWidget: CreateWidget = (configuration, constraints) => { return props => ( diff --git a/src/widgets/ExclusiveGroupWidget.tsx b/src/widgets/ExclusiveGroupWidget.tsx index 91b1d8d..dfaa7b7 100644 --- a/src/widgets/ExclusiveGroupWidget.tsx +++ b/src/widgets/ExclusiveGroupWidget.tsx @@ -98,12 +98,19 @@ const ExclusiveGroupWidget = ({ type GetExclusiveGroupChildren = ( formConfiguration: FormConfiguration[], name: string, - constraints?: Record + constraints?: Record, + opts?: { + /** + * When true, bypass the required attribute if all options are made unavailable by constraints. + */ + bypassRequiredForConstraints?: boolean + } ) => ChildrenGetter const getExclusiveGroupChildren: GetExclusiveGroupChildren = ( formConfiguration, name, - constraints + constraints, + opts ) => { const thisExclusiveGroup = formConfiguration.find( configuration => @@ -125,7 +132,11 @@ const getExclusiveGroupChildren: GetExclusiveGroupChildren = ( if (!childConfiguration) return childMap const childConstraints = constraints && constraints[childName] - const childWidget = createWidget(childConfiguration, childConstraints) + const childWidget = createWidget( + childConfiguration, + childConstraints, + opts + ) childMap[childName] = props => childWidget(props) diff --git a/src/widgets/GeographicExtentWidget.tsx b/src/widgets/GeographicExtentWidget.tsx index ae11cc0..c7dcc4a 100644 --- a/src/widgets/GeographicExtentWidget.tsx +++ b/src/widgets/GeographicExtentWidget.tsx @@ -60,8 +60,6 @@ const GeographicExtentWidget = ({ fieldsetDisabled, labelAriaHidden = true }: GeographicExtentWidgetProps) => { - const fieldSetRef = useRef(null) - const injectWidgetPayload = (ev: FormDataEvent) => { const { formData } = ev /** @@ -78,7 +76,7 @@ const GeographicExtentWidget = ({ } } - useEventListener('formdata', injectWidgetPayload, fieldSetRef) + useEventListener('formdata', injectWidgetPayload) if (!configuration) return null diff --git a/src/widgets/StringChoiceWidget.tsx b/src/widgets/StringChoiceWidget.tsx index 17edc27..bac873d 100644 --- a/src/widgets/StringChoiceWidget.tsx +++ b/src/widgets/StringChoiceWidget.tsx @@ -21,7 +21,7 @@ import { WidgetTitle } from './Widget' -import { isDisabled } from '../utils' +import { isDisabled, useBypassRequired } from '../utils' export interface StringChoiceWidgetDetails { columns: number @@ -56,14 +56,20 @@ interface StringChoiceWidgetProps { * Whether to hide the widget label from ARIA. */ labelAriaHidden?: boolean + /** + * When true, bypass the required attribute if all options are made unavailable by constraints. + */ + bypassRequiredForConstraints?: boolean } const StringChoiceWidget = ({ configuration, constraints, fieldsetDisabled, - labelAriaHidden = true + labelAriaHidden = true, + bypassRequiredForConstraints }: StringChoiceWidgetProps) => { + const fieldSetRef = useRef(null) const [selection, setSelection] = useState([]) const persistedSelection = useReadLocalStorage<{ dataset: { id: string } @@ -74,6 +80,8 @@ const StringChoiceWidget = ({ */ const persistedSelectionRef = useRef(persistedSelection) + useBypassRequired(fieldSetRef, bypassRequiredForConstraints, constraints) + const { details, label, help, name } = configuration const { details: { columns } @@ -122,7 +130,7 @@ const StringChoiceWidget = ({ triggerAriaLabel={`Get help for ${label}`} /> -
+
{label} { const { details: { groups, accordionOptions }, @@ -129,31 +136,26 @@ const StringListArrayWidget = ({ const bulkSelectionTriggerRef = useRef(null) const fieldSetRef = useRef(null) - // TODO: test with a functional test - /* istanbul ignore next */ - useEffect(() => { - if (!fieldSetRef?.current?.form) return - const form = fieldSetRef.current.form + const bypassed = useBypassRequired( + fieldSetRef, + bypassRequiredForConstraints, + constraints + ) - const formDataListener = (ev: FormDataEvent) => { - const { formData } = ev + const formDataListener = (ev: FormDataEvent) => { + const { formData } = ev - appendToFormData( - formData, - { - currentSelection: selection, - name - }, - constraints - ) - } - - form.addEventListener('formdata', formDataListener) + appendToFormData( + formData, + { + currentSelection: selection, + name + }, + constraints + ) + } - return () => { - form.removeEventListener('formdata', formDataListener) - } - }, [selection, name, constraints]) + useEventListener('formdata', formDataListener) if (!configuration) return null @@ -222,7 +224,7 @@ const StringListArrayWidget = ({ /> - {required && !selection[name]?.length ? ( + {!bypassed && required && !selection[name]?.length ? ( At least one selection must be made ) : null} diff --git a/src/widgets/StringListWidget.tsx b/src/widgets/StringListWidget.tsx index 050568c..6d4abbc 100644 --- a/src/widgets/StringListWidget.tsx +++ b/src/widgets/StringListWidget.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useRef } from 'react' import { Checkbox, Label, WidgetTooltip } from '../index' @@ -20,6 +20,7 @@ import { isDisabled, getPermittedBulkSelection, isAllSelected, + useBypassRequired, useWidgetSelection, ClearAll, SelectAll @@ -57,6 +58,10 @@ type StringListWidgetProps = { * Whether to hide the widget label from ARIA. */ labelAriaHidden?: boolean + /** + * When true, bypass the required attribute if all options are made unavailable by constraints. + */ + bypassRequiredForConstraints?: boolean } const getAllValues = (labels: StringListWidgetDetails['labels']) => { @@ -67,8 +72,10 @@ const StringListWidget = ({ configuration, constraints, fieldsetDisabled, - labelAriaHidden = true + labelAriaHidden = true, + bypassRequiredForConstraints }: StringListWidgetProps) => { + const fieldSetRef = useRef(null) const { details, label, help, name, required } = configuration const { details: { columns, labels } @@ -76,6 +83,12 @@ const StringListWidget = ({ const { selection, setSelection } = useWidgetSelection(name) + const bypassed = useBypassRequired( + fieldSetRef, + bypassRequiredForConstraints, + constraints + ) + if (!configuration) return null const allValues = getAllValues(labels) @@ -116,11 +129,11 @@ const StringListWidget = ({ /> - {required && !selection[name]?.length ? ( + {!bypassed && required && !selection[name]?.length ? ( At least one selection must be made ) : null} -
+
{label}