diff --git a/.github/workflows/_validate.yml b/.github/workflows/_validate.yml index e0284a679..b5e82f9a8 100644 --- a/.github/workflows/_validate.yml +++ b/.github/workflows/_validate.yml @@ -6,7 +6,7 @@ on: playwright_version: description: Installed playwright version required: false - default: "1.48.1" + default: '1.50.0' type: string jobs: @@ -69,7 +69,6 @@ jobs: - name: Make directory for build artifacts run: mkdir -p output/build-artifacts - - name: Spotlight app performance budget run: pnpm --filter @atj/spotlight size:ci > output/build-artifacts/spotlight-size-output.txt @@ -95,7 +94,7 @@ jobs: - name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: - terraform_version: "1.10.4" + terraform_version: '1.10.4' - name: Initialize Terraform CDK configuration shell: bash diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a2fefb00b..e6b269ab1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,15 +15,8 @@ jobs: app-name: server-doj tag-name: ${{ github.ref_name }} - build-image-kansas: - uses: ./.github/workflows/_docker-build-image.yml - secrets: inherit - with: - app-name: server-kansas - tag-name: ${{ github.ref_name }} - deploy: - needs: [build-image-doj, build-image-kansas] + needs: [build-image-doj] uses: ./.github/workflows/_terraform-apply.yml secrets: inherit with: diff --git a/.gitignore b/.gitignore index 0f41fb70b..41dc02f95 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ _site .turbo/ .vscode/ +.idea/ coverage/ html/ node_modules/ diff --git a/.husky/pre-commit b/.husky/pre-commit index c77607772..8db696726 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh pnpm lint pnpm format -pnpm test +pnpm test:ci diff --git a/apps/cli/package.json b/apps/cli/package.json index 613a33274..b308c9dc8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -13,7 +13,6 @@ "test": "vitest run --coverage" }, "dependencies": { - "@atj/dependency-graph": "workspace:*", "@atj/infra-core": "workspace:*", "commander": "^11.1.0" } diff --git a/apps/cli/src/cli-controller/index.ts b/apps/cli/src/cli-controller/index.ts index b78b7424e..456d757df 100644 --- a/apps/cli/src/cli-controller/index.ts +++ b/apps/cli/src/cli-controller/index.ts @@ -1,6 +1,5 @@ import { Command } from 'commander'; -import { createDependencyGraph } from '@atj/dependency-graph'; import type { Context } from './types.js'; import { addSecretCommands } from './secrets.js'; @@ -16,14 +15,6 @@ export const CliController = (ctx: Context) => { ctx.console.log('Hello!'); }); - cli - .command('create-workspace-graph') - .description('create a dependency graph of projects in the workspace') - .action(async () => { - await createDependencyGraph(ctx.workspaceRoot); - ctx.console.log('wrote workspace dependency graph'); - }); - addSecretCommands(ctx, cli); return cli; diff --git a/apps/server-doj/src/server.ts b/apps/server-doj/src/server.ts index e1bd2117a..84de2fcfc 100644 --- a/apps/server-doj/src/server.ts +++ b/apps/server-doj/src/server.ts @@ -20,6 +20,7 @@ export const createCustomServer = async (db: DatabaseContext): Promise => { 'natasha.pierre-louis@gsa.gov', 'emily.lordahl@gsa.gov', 'khayal.alasgarov@gsa.gov', + 'jenny.richards@gsa.gov', // DOJ test users 'deserene.h.worsley@usdoj.gov', 'jordan.pendergrass@usdoj.gov', diff --git a/apps/server-kansas/.gitignore b/apps/server-kansas/.gitignore deleted file mode 100644 index 00e644fbd..000000000 --- a/apps/server-kansas/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist/ -*.db diff --git a/apps/server-kansas/README.md b/apps/server-kansas/README.md deleted file mode 100644 index c2e6db8c7..000000000 --- a/apps/server-kansas/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @atj/server-kansas - -Web server to demonstrate forms for Kansas State Courts. diff --git a/apps/server-kansas/package.json b/apps/server-kansas/package.json deleted file mode 100644 index de3674c77..000000000 --- a/apps/server-kansas/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@atj/server-kansas", - "version": "1.0.0", - "description": "Form server instance for Kansas State Courts", - "type": "module", - "license": "CC0", - "main": "src/index.ts", - "scripts": { - "build": "tsup src/* --format esm", - "clean": "rimraf dist tsconfig.tsbuildinfo coverage", - "dev": "tsup src/* --watch --format esm", - "start": "VCAP_SERVICES='{\"aws-rds\": [{ \"credentials\": { \"uri\": \"\" }}]}' node dist/index.js", - "test": "vitest run --coverage" - }, - "dependencies": { - "@atj/database": "workspace:*", - "@atj/server": "workspace:*" - }, - "devDependencies": { - "@types/supertest": "^6.0.2", - "supertest": "^7.0.0" - } -} diff --git a/apps/server-kansas/src/index.ts b/apps/server-kansas/src/index.ts deleted file mode 100644 index 96981459d..000000000 --- a/apps/server-kansas/src/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createPostgresDatabaseContext } from '@atj/database/context'; -import { createCustomServer } from './server.js'; - -const port = process.env.PORT || 4321; - -const getCloudGovServerSecrets = () => { - if (process.env.VCAP_SERVICES === undefined) { - throw new Error('VCAP_SERVICES not found'); - } - const services = JSON.parse(process.env.VCAP_SERVICES || '{}'); - return { - //loginGovClientSecret: services['user-provided']?.credentials?.SECRET_LOGIN_GOV_PRIVATE_KEY, - dbUri: services['aws-rds'][0].credentials.uri as string, - }; -}; - -const secrets = getCloudGovServerSecrets(); -const db = await createPostgresDatabaseContext(secrets.dbUri, true); -const server = await createCustomServer(db); -server.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); diff --git a/apps/server-kansas/src/server.ts b/apps/server-kansas/src/server.ts deleted file mode 100644 index 20a7d470f..000000000 --- a/apps/server-kansas/src/server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type DatabaseContext } from '@atj/database'; -import { createServer } from '@atj/server'; - -export const createCustomServer = async (db: DatabaseContext): Promise => { - return createServer({ - title: 'DOJ Form Service', - db, - loginGovOptions: { - loginGovUrl: 'https://idp.int.identitysandbox.gov', - clientId: - 'urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:tts-10x-atj-dev-server-doj', - //clientSecret: '', // secrets.loginGovClientSecret, - }, - isUserAuthorized: async (email: string) => { - return [ - // 10x team members - 'daniel.naab@gsa.gov', - 'jim.moffet@gsa.gov', - 'ethan.gardner@gsa.gov', - 'natasha.pierre-louis@gsa.gov', - 'emily.lordahl@gsa.gov', - 'khayal.alasgarov@gsa.gov', - ].includes(email.toLowerCase()); - }, - }); -}; diff --git a/apps/server-kansas/tests/integration.test.ts b/apps/server-kansas/tests/integration.test.ts deleted file mode 100644 index e73821132..000000000 --- a/apps/server-kansas/tests/integration.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import request from 'supertest'; -import { describe, expect, test } from 'vitest'; - -import { createInMemoryDatabaseContext } from '@atj/database/context'; -import { createCustomServer } from '../src/server'; - -describe('Kansas State Courts Form Service', () => { - test('renders the home page', async () => { - const db = await createInMemoryDatabaseContext(); - const app = await createCustomServer(db); - const response = await request(app).get('/'); - expect(response.ok).toBe(true); - expect(response.text).toMatch(/DOJ Form Service/); - }); -}); diff --git a/apps/server-kansas/tsconfig.json b/apps/server-kansas/tsconfig.json deleted file mode 100644 index 6f1380c82..000000000 --- a/apps/server-kansas/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", - "emitDeclarationOnly": true, - "outDir": "./dist" - }, - "include": ["./src/**/*"], - "exclude": ["./dist"], - "references": [] -} diff --git a/e2e/Dockerfile b/e2e/Dockerfile index c054654ad..b9813a2c4 100644 --- a/e2e/Dockerfile +++ b/e2e/Dockerfile @@ -1,9 +1,9 @@ # base image with Node.js and playwright preinstalled -FROM mcr.microsoft.com/playwright:v1.48.1-noble as base +FROM mcr.microsoft.com/playwright:v1.50.0-noble as base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" ENV NODE_ENV=test -RUN apt-get -y update && apt-get install -y netcat-openbsd +RUN apt-get -y update && apt-get install -y netcat-openbsd make g++ WORKDIR /srv/apps/atj-platform COPY ./pnpm-lock.yaml ./pnpm-lock.yaml COPY ./package.json ./package.json diff --git a/e2e/package.json b/e2e/package.json index d9bbaa09c..1ca4be5b6 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,7 +7,6 @@ "test": "export E2E_ENDPOINT=http://localhost:4321; pnpm playwright test --ui-port=8080 --ui-host=0.0.0.0" }, "devDependencies": { - "@playwright/test": "1.48.1", "@storybook/test-runner": "^0.19.1", "path-to-regexp": "^8.2.0" }, diff --git a/package.json b/package.json index e2f40dbd8..3456cb62d 100644 --- a/package.json +++ b/package.json @@ -16,16 +16,16 @@ "lint": "turbo run lint", "pages": "rm -rf node_modules && npm i -g pnpm turbo && pnpm i && pnpm build && ln -sf ./apps/spotlight/dist _site", "test": "vitest run", - "test:ci": "vitest run # --coverage.enabled --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json --coverage.reportOnFailure", + "test:ci": "CI=true vitest run # --coverage.enabled --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json --coverage.reportOnFailure", "test:infra": "turbo run --filter=infra-cdktf test", - "typecheck": "tsc --build", + "typecheck": "tsc --build --noEmit", "prepare": "husky" }, "hooks": { "pre-commit": "pnpm format" }, "devDependencies": { - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.50.0", "@rollup/plugin-commonjs": "^28.0.2", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", diff --git a/packages-python/docassemble-server/Dockerfile b/packages-python/docassemble-server/Dockerfile deleted file mode 100644 index ae38d8b1f..000000000 --- a/packages-python/docassemble-server/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -# Relevant base images: -# docassemble-os: https://github.com/jhpyle/docassemble-os/blob/master/Dockerfile -# docassemble: https://github.com/jhpyle/docassemble/blob/master/Dockerfile -FROM jhpyle/docassemble:latest - -#ADD ./startup.sh /usr/local/bin/startup.sh -#CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"] diff --git a/packages-python/docassemble-server/README.md b/packages-python/docassemble-server/README.md deleted file mode 100644 index 70b014d66..000000000 --- a/packages-python/docassemble-server/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# atj-platform docassemble server - -This is a docassemble server for the ATJ Platform that may be run locally for development purposes or deployed to cloud.gov. - -The configuration does not mount volumes for persistent data, because it is intended to be used as a disposable test environment. We may want to change this in the future, but for the moment the intention is to automate pushing data into the instance after startup. - -## Local development - -```bash -docker-compose up -``` diff --git a/packages-python/docassemble-server/docker-compose.yml b/packages-python/docassemble-server/docker-compose.yml deleted file mode 100644 index 7c97b633e..000000000 --- a/packages-python/docassemble-server/docker-compose.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: '3' - -services: - web: - image: jhpyle/docassemble:latest - ports: - - '8011:80' - # The available environment variables have defaults defined in the base image: - # https://github.com/jhpyle/docassemble/blob/master/Dockerfile - # Configuration documentation is available here: - # https://docassemble.org/docs/docker.html#configuration%20options - environment: - CONTAINERROLE: 'all' diff --git a/packages-python/docassemble-server/manifest.yml b/packages-python/docassemble-server/manifest.yml deleted file mode 100644 index 5872e5f26..000000000 --- a/packages-python/docassemble-server/manifest.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -applications: - - name: docassemble-server - docker: - image: jhpyle/docassemble - memory: 2GB - disk_quota: 7168MB - instances: 1 diff --git a/packages/common/src/locales/en/app.ts b/packages/common/src/locales/en/app.ts index bb5f7c689..1e4bb864f 100644 --- a/packages/common/src/locales/en/app.ts +++ b/packages/common/src/locales/en/app.ts @@ -30,6 +30,13 @@ export const en = { displayName: 'Short answer', maxLength: 'Maximum length', }, + textarea: { + ...defaults, + displayName: 'Long answer', + maxLength: 'Maximum length', + hintLabel: 'Hint Text (optional)', + hint: 'The more specific you can be, the better. Use the space below and/or attach additional pages.', + }, packageDownload: { ...defaults, displayName: 'Package download', @@ -99,5 +106,10 @@ export const en = { preferNotToAnswerTextLabel: 'Prefer not to share my gender identity checkbox label', }, + repeater: { + ...defaults, + displayName: 'Repeatable Group', + errorTextMustContainChar: 'String must contain at least 1 character(s)', + }, }, }; diff --git a/packages/dependency-graph/.gitignore b/packages/dependency-graph/.gitignore deleted file mode 100644 index 849ddff3b..000000000 --- a/packages/dependency-graph/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist/ diff --git a/packages/dependency-graph/README.md b/packages/dependency-graph/README.md deleted file mode 100644 index 98be65476..000000000 --- a/packages/dependency-graph/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# @atj/dependency-graph - -This package will create an SVG in the workspace root that graphs the pnpm projects in this repository. It is intended to be checked into the repository as part of the project documentation. - -## Usage - -See the [command-line interface](../../apps/cli) for usage information. - -## Development - -Run tests with coverage: - -```bash -pnpm test -``` diff --git a/packages/dependency-graph/package.json b/packages/dependency-graph/package.json deleted file mode 100644 index 11fa3bd52..000000000 --- a/packages/dependency-graph/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@atj/dependency-graph", - "version": "1.0.0", - "description": "generates a dependency graph of projects in a pnpm workspace", - "type": "module", - "license": "CC0", - "main": "dist/index.js", - "types": "dist/index.d.js", - "scripts": { - "build": "tsc", - "clean": "rimraf dist tsconfig.tsbuildinfo coverage", - "dev": "tsc --watch", - "test": "echo no @atj/dependency-graph tests" - }, - "dependencies": { - "@pnpm/find-workspace-packages": "^6.0.9", - "@pnpm/logger": "^5.2.0", - "graphviz": "^0.0.9" - }, - "devDependencies": { - "@types/graphviz": "^0.0.39" - } -} diff --git a/packages/dependency-graph/src/get-dependencies.ts b/packages/dependency-graph/src/get-dependencies.ts deleted file mode 100644 index 2cf62295f..000000000 --- a/packages/dependency-graph/src/get-dependencies.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { findWorkspacePackages, Project } from '@pnpm/find-workspace-packages'; - -const filterNamespace = ( - namespaceAlias: string, - dependencies?: Record -): string[] => { - return Object.keys(dependencies || {}).filter(dependency => - dependency.startsWith(namespaceAlias) - ); -}; - -const getProjectDependencies = (project: Project, namespaceAlias: string) => { - return [ - project.manifest.name, - [ - ...filterNamespace(namespaceAlias, project.manifest.devDependencies), - ...filterNamespace(namespaceAlias, project.manifest.dependencies), - ], - ]; -}; - -export type DependencyMap = Record; - -export const getWorkspaceDependencies = ( - workspaceRoot: string, - namespaceAlias: string -): Promise => { - return findWorkspacePackages(workspaceRoot).then((projects: Project[]) => { - const workspaceDependencies = Object.fromEntries( - projects - .filter(project => project.manifest.name?.startsWith(namespaceAlias)) - .map(project => getProjectDependencies(project, namespaceAlias)) - ); - return workspaceDependencies; - }); -}; diff --git a/packages/dependency-graph/src/graph-dependencies.ts b/packages/dependency-graph/src/graph-dependencies.ts deleted file mode 100644 index 2b08f4842..000000000 --- a/packages/dependency-graph/src/graph-dependencies.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as graphviz from 'graphviz'; -import { DependencyMap } from './get-dependencies.js'; - -const createGraphvizDigraph = (workspaceDependencies: DependencyMap) => { - const graph = graphviz.digraph('workspace'); - graph.set('layout', 'neato'); - Object.entries(workspaceDependencies).forEach( - ([projectName, projectDependencies]) => { - graph.addNode(projectName); - projectDependencies.forEach(dependency => - graph.addEdge(projectName, dependency) - ); - } - ); - return graph; -}; - -export const writeDependencyGraph = ( - workspaceDependencies: DependencyMap, - dependencyGraphOutputPath: string -) => { - const graph = createGraphvizDigraph(workspaceDependencies); - graph.output({ type: 'svg', use: 'dot' }, dependencyGraphOutputPath); -}; diff --git a/packages/dependency-graph/src/index.ts b/packages/dependency-graph/src/index.ts deleted file mode 100644 index e66efff35..000000000 --- a/packages/dependency-graph/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getWorkspaceDependencies } from './get-dependencies.js'; -import { writeDependencyGraph } from './graph-dependencies.js'; - -export const createDependencyGraph = async ( - workspaceRoot: string, - workspaceAlias: string = '@atj', - dependencyGraphOutputPath?: string -) => { - const workspaceDependencies = await getWorkspaceDependencies( - workspaceRoot, - workspaceAlias - ); - if (!dependencyGraphOutputPath) { - dependencyGraphOutputPath = `${workspaceRoot}/workspace-dependencies.svg`; - } - writeDependencyGraph(workspaceDependencies, dependencyGraphOutputPath); -}; diff --git a/packages/dependency-graph/tsconfig.json b/packages/dependency-graph/tsconfig.json deleted file mode 100644 index a374c5276..000000000 --- a/packages/dependency-graph/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "emitDeclarationOnly": false, - "rootDir": "./src" - }, - "include": ["./src"], - "references": [] -} diff --git a/packages/design/package.json b/packages/design/package.json index 47c908774..791d7de4d 100644 --- a/packages/design/package.json +++ b/packages/design/package.json @@ -32,7 +32,6 @@ } ], "devDependencies": { - "@playwright/test": "1.48.1", "@storybook/addon-a11y": "^8.4.7", "@storybook/addon-coverage": "^1.0.5", "@storybook/addon-essentials": "^8.4.7", diff --git a/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx b/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx index ff14afad0..9309f8dde 100644 --- a/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx +++ b/packages/design/src/Form/components/DateOfBirth/DateOfBirth.tsx @@ -35,6 +35,7 @@ export const DateOfBirthPattern: PatternComponent = ({ hint, required, error, + value, }) => { const { register } = useFormContext(); const errorId = `input-error-message-${monthId}`; @@ -42,87 +43,96 @@ export const DateOfBirthPattern: PatternComponent = ({ return (
- - {label} - {required && *} - - {hint && ( - - {hint} - - )} - {error && ( - - )} -
-
- - + - ))} - -
-
- - -
-
- - + {months.map((option, index) => ( + + ))} + +
+
+ + +
+
+ + +
diff --git a/packages/design/src/Form/components/EmailInput/EmailInput.tsx b/packages/design/src/Form/components/EmailInput/EmailInput.tsx index 2f2d9cc81..ee58e2d79 100644 --- a/packages/design/src/Form/components/EmailInput/EmailInput.tsx +++ b/packages/design/src/Form/components/EmailInput/EmailInput.tsx @@ -9,14 +9,20 @@ export const EmailInputPattern: PatternComponent = ({ label, required, error, + value, }) => { const { register } = useFormContext(); const errorId = `input-error-message-${emailId}`; return (
-
-
diff --git a/packages/design/src/Form/components/GenderId/index.tsx b/packages/design/src/Form/components/GenderId/index.tsx index 565b4e35b..3047cf7fc 100644 --- a/packages/design/src/Form/components/GenderId/index.tsx +++ b/packages/design/src/Form/components/GenderId/index.tsx @@ -10,7 +10,7 @@ const GenderIdPattern: PatternComponent = ({ label, required, error, - value = '', + value, preferNotToAnswerText, preferNotToAnswerChecked: initialPreferNotToAnswerChecked = false, }) => { @@ -22,7 +22,7 @@ const GenderIdPattern: PatternComponent = ({ const errorId = `input-error-message-${genderId}`; const hintId = `hint-${genderId}`; const preferNotToAnswerId = `${genderId}.preferNotToAnswer`; - const inputId = `${genderId}.input`; + const inputId = `${genderId}.gender`; const watchedValue = useWatch({ name: inputId, defaultValue: value }); diff --git a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx index 2661a3d23..dbe47e919 100644 --- a/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx +++ b/packages/design/src/Form/components/PhoneNumber/PhoneNumber.tsx @@ -4,6 +4,15 @@ import { useFormContext } from 'react-hook-form'; import { type PhoneNumberProps } from '@atj/forms'; import { type PatternComponent } from '../../index.js'; +const formatPhoneNumber = (value: string) => { + const rawValue = value.replace(/[^\d]/g, ''); // Remove non-digit characters + + if (rawValue.length <= 3) return rawValue; + if (rawValue.length <= 6) + return `${rawValue.slice(0, 3)}-${rawValue.slice(3)}`; + return `${rawValue.slice(0, 3)}-${rawValue.slice(3, 6)}-${rawValue.slice(6, 10)}`; +}; + export const PhoneNumberPattern: PatternComponent = ({ phoneId, hint, @@ -12,10 +21,15 @@ export const PhoneNumberPattern: PatternComponent = ({ error, value, }) => { - const { register } = useFormContext(); + const { register, setValue } = useFormContext(); const errorId = `input-error-message-${phoneId}`; const hintId = `hint-${phoneId}`; + const handlePhoneChange = (e: React.ChangeEvent) => { + const formattedPhone = formatPhoneNumber(e.target.value); + setValue(phoneId, formattedPhone, { shouldValidate: true }); + }; + return (
@@ -39,13 +53,14 @@ export const PhoneNumberPattern: PatternComponent = ({
)} = props => { {props.legend} {props.options.map((option, index) => { + const id = option.id; return (
-
diff --git a/packages/design/src/Form/components/Repeater/Repeater.stories.tsx b/packages/design/src/Form/components/Repeater/Repeater.stories.tsx new file mode 100644 index 000000000..305ec9d60 --- /dev/null +++ b/packages/design/src/Form/components/Repeater/Repeater.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import Repeater from './index.js'; +import { FormProvider, useForm } from 'react-hook-form'; +import { defaultPatternComponents } from '../index.js'; +import type { + DateOfBirthProps, + EmailInputProps, + RepeaterProps, +} from '@atj/forms'; +import { expect, within } from '@storybook/test'; + +const defaultArgs = { + legend: 'Default Heading', + _patternId: 'test-id', + type: 'repeater', +} satisfies RepeaterProps; + +const mockChildComponents = (index: number, withError = false) => [ + { + props: { + _patternId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.a6c217f0-fe84-44ef-b606-69142ecb3365`, + type: 'date-of-birth', + label: 'Date of Birth', + hint: 'For example: January 19 2000', + dayId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.a6c217f0-fe84-44ef-b606-69142ecb3365.day`, + monthId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.a6c217f0-fe84-44ef-b606-69142ecb3365.month`, + yearId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.a6c217f0-fe84-44ef-b606-69142ecb3365.year`, + required: false, + error: withError + ? { + type: 'custom', + message: 'Invalid date of birth', + } + : undefined, + } as DateOfBirthProps, + children: [], + }, + { + props: { + _patternId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.7d5df1c1-ca92-488c-81ca-8bb180f952b6`, + type: 'email-input', + label: 'Email Input', + emailId: `3fdb2cb6-5d65-4de1-b773-3fb8636f5d09.${index}.7d5df1c1-ca92-488c-81ca-8bb180f952b6.email`, + required: false, + error: withError + ? { + type: 'custom', + message: 'Invalid email address', + } + : undefined, + } as EmailInputProps, + children: [], + }, +]; + +export default { + title: 'patterns/Repeater', + component: Repeater, + decorators: [ + (Story, args) => { + const FormDecorator = () => { + const formMethods = useForm(); + return ( + +
+ +
+
+ ); + }; + return ; + }, + ], + tags: ['autodocs'], +} satisfies Meta; + +export const Default = { + args: { + ...defaultArgs, + }, +} satisfies StoryObj; + +export const WithContents = { + args: { + ...defaultArgs, + childComponents: mockChildComponents(0), + context: { + components: { + 'date-of-birth': defaultPatternComponents['date-of-birth'], + 'email-input': defaultPatternComponents['email-input'], + }, + config: { + patterns: {}, + }, + uswdsRoot: '/uswds/', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const legend = await canvas.findByText('Default Heading'); + expect(legend).toBeInTheDocument(); + + const listItems = canvas.getAllByRole('list'); + expect(listItems.length).toBe(1); + + const dobLabel = await canvas.findByText('Date of Birth'); + expect(dobLabel).toBeInTheDocument(); + + const emailLabel = await canvas.findByText('Email Input'); + expect(emailLabel).toBeInTheDocument(); + }, +} satisfies StoryObj; + +export const ErrorState = { + args: { + ...defaultArgs, + childComponents: mockChildComponents(0, true), + error: { + type: 'custom', + message: 'This field has an error', + }, + context: { + components: { + 'date-of-birth': defaultPatternComponents['date-of-birth'], + 'email-input': defaultPatternComponents['email-input'], + }, + config: { + patterns: {}, + }, + uswdsRoot: '/uswds/', + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const legend = await canvas.findByText('Default Heading'); + expect(legend).toBeInTheDocument(); + + const listItems = canvas.getAllByRole('list'); + expect(listItems.length).toBe(1); + + const dobError = await canvas.findByText('Invalid date of birth'); + expect(dobError).toBeInTheDocument(); + + const emailError = await canvas.findByText('Invalid email address'); + expect(emailError).toBeInTheDocument(); + }, +} satisfies StoryObj; diff --git a/packages/design/src/Form/components/Repeater/index.tsx b/packages/design/src/Form/components/Repeater/index.tsx new file mode 100644 index 000000000..3f11494d4 --- /dev/null +++ b/packages/design/src/Form/components/Repeater/index.tsx @@ -0,0 +1,223 @@ +import React, { Children, useMemo } from 'react'; +import { useFieldArray } from 'react-hook-form'; +import { type RepeaterProps, type PromptComponent } from '@atj/forms'; + +import { type PatternComponent } from '../../index.js'; +import { renderPromptComponents } from '../../form-common.js'; + +interface RepeaterRowProps { + children: React.ReactNode[]; + index: number; + fieldsLength: number; + patternId: string; + onDelete: (index: number) => void; +} + +const RepeaterRow = ({ + children, + index, + fieldsLength, + patternId, + onDelete, +}: RepeaterRowProps) => { + const handleDelete = React.useCallback(() => { + onDelete(index); + }, [onDelete, index]); + + return ( +
  • + {children.map((child, i) => ( + {child} + ))} + {index !== fieldsLength - 1 && fieldsLength > 1 && ( + + )} +
  • + ); +}; + +interface ChildrenGroups { + [key: string]: React.ReactNode[]; +} + +type RepeaterValue = Record[]; + +interface ComponentProps { + props: { + _patternId?: string; + id?: string; + value?: unknown; + error?: unknown; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +interface ChildElementProps { + component: ComponentProps; +} + +const Repeater: PatternComponent = props => { + const { fields, append } = useFieldArray({ + name: `${props._patternId}.fields`, + }); + + const groupChildrenByIndex = useMemo(() => { + const groups: ChildrenGroups = {}; + + const children = renderPromptComponents( + props.context, + props.childComponents + ); + Children.forEach(children, (child, idx) => { + if (!React.isValidElement(child)) return; + const childProps = (props.childComponents as PromptComponent[])[idx] + .props; + const patternId = childProps._patternId; + + const parts = patternId.split('.'); + const index = parts[1]; + const childId = parts[2]; + + const repeaterValues = (props.value as RepeaterValue) || []; + const rowData = repeaterValues[Number(index)] || {}; + const childValue = rowData[childId]; + + const childError = props.error?.fields?.[patternId]; + + if (!groups[index]) groups[index] = []; + + const enrichedChild = React.cloneElement(child, { + ...child.props, + component: { + ...child.props.component, + props: { + ...childProps, + value: childValue, + error: childError, + }, + }, + }); + groups[index].push(enrichedChild); + }); + + return groups; + }, [props.childComponents, props.value, props.error, props._patternId]); + + const hasFields = Object.keys(groupChildrenByIndex).length > 0; + + React.useEffect(() => { + const groupCount = Object.keys(groupChildrenByIndex).length; + if (groupCount > fields.length) { + const diff = groupCount - fields.length; + Array(diff) + .fill({}) + .forEach(() => { + append({}); + }); + } else if (fields.length === 0) { + append({}); + } + }, [groupChildrenByIndex, fields.length, append]); + + const handleDelete = React.useCallback( + (index: number) => { + const input = document.getElementById( + `${props._patternId}-delete-index` + ) as HTMLInputElement; + if (input) { + input.value = index.toString(); + } + }, + [props._patternId] + ); + + const handleDeleteLast = React.useCallback( + (e: React.MouseEvent) => { + const form = e.currentTarget.form; + if (form) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'deleteIndex'; + input.value = (fields.length - 1).toString(); + form.appendChild(input); + } + }, + [fields.length] + ); + + const renderRows = useMemo( + () => + fields.map((field, index) => ( + + {groupChildrenByIndex[index.toString()] || []} + + )), + [fields, groupChildrenByIndex, props._patternId, handleDelete] + ); + + return ( +
    + + + {props.legend && ( + + {props.legend} + + )} + {props.error && ( + + )} + {hasFields && ( + <> +
      {renderRows}
    +
    + + +
    + + )} +
    + ); +}; + +export default Repeater; diff --git a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx index 9ae3a59a3..88f461232 100644 --- a/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx +++ b/packages/design/src/Form/components/SelectDropdown/SelectDropdown.tsx @@ -16,32 +16,39 @@ export const SelectDropdownPattern: PatternComponent = ({ return (
    - - {error && ( - - )} - + - ))} - + {options.map((option, index) => ( + + ))} + +
    ); }; diff --git a/packages/design/src/Form/components/SubmissionConfirmation/index.tsx b/packages/design/src/Form/components/SubmissionConfirmation/index.tsx index 7e422663c..19a332555 100644 --- a/packages/design/src/Form/components/SubmissionConfirmation/index.tsx +++ b/packages/design/src/Form/components/SubmissionConfirmation/index.tsx @@ -40,30 +40,35 @@ const SubmissionConfirmation: PatternComponent< Submission details - + {/* + EG: turn this off for now. Will need some design perhaps to see what the presentation + should look like. This was a minimal blocker for the repeater field due to the flat data structure + that was there previously. + */} + {/*