diff --git a/.github/ISSUE_TEMPLATE/story.md b/.github/ISSUE_TEMPLATE/story.md new file mode 100644 index 00000000..75fd9065 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/story.md @@ -0,0 +1,64 @@ +--- +name: Story +about: Suggest a user story for this product +title: '' +labels: story +assignees: '' +projects: ['GSA-TTS/17'] +--- + +## Overview + +As a _, I would like _, so that I can _. + +## Context + +*Optional: Any reference material or thoughts we may need for later reference, or assumptions of prior or future work that's out of scope for this story.* + +- [ ] + +## Acceptance Criteria + +*Required outcomes of the story* + +- [ ] + +## Research Questions + +- *Optional: Any initial questions for research* + +## Tasks + +*Research, design, and engineering work needed to complete the story.* + +- [ ] + +## Definition of done + +The "definition of done" ensures our quality standards are met with each bit of user-facing behavior we add. Everything that can be done incrementally should be done incrementally, while the context and details are fresh. If it’s inefficient or “hard” to do so, the team should figure out why and add OPEX/DEVEX backlog items to make it easier and more efficient. + +- [ ] Behavior + - [ ] Acceptance criteria met + - [ ] Implementation matches design decisions +- [ ] Documentation + - [ ] ADRs (`/documents/adr` folder) + - [ ] Relevant `README.md`(s) +- [ ] Code quality + - [ ] Code refactored for clarity and no design/technical debt + - [ ] Adhere to separation of concerns; code is not tightly coupled, especially to 3rd party dependencies; dependency rule followed + - [ ] Code is reviewed by team member + - [ ] Code quality checks passed +- [ ] Security and privacy + - [ ] Automated security and privacy gates passed +- [ ] Testing tasks completed + - [ ] Automated tests pass + - [ ] Unit test coverage of our code >= 90% +- [ ] Build and deploy + - [ ] Build process updated + - [ ] API(s) are versioned + - [ ] Feature toggles created and/or deleted. Document the feature toggle + - [ ] Source code is merged to the main branch + +## Decisions + +- *Optional: Any decisions we've made while working on this story* diff --git a/.github/workflows/_end-to-end.yml b/.github/workflows/_end-to-end.yml new file mode 100644 index 00000000..fcf1c22f --- /dev/null +++ b/.github/workflows/_end-to-end.yml @@ -0,0 +1,12 @@ +name: End-to-end tests +on: + workflow_call: +jobs: + end-to-end: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build Docker to run tests + run: | + docker build --platform linux/amd64 --tag 'playwright' . -f ./e2e/Dockerfile --target test diff --git a/.github/workflows/_playwright.yml b/.github/workflows/_playwright.yml deleted file mode 100644 index f3a13ccc..00000000 --- a/.github/workflows/_playwright.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Playwright Tests -on: - workflow_call: -jobs: - end_to_end: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm install -g pnpm && pnpm install - - name: Install Playwright Browsers - run: pnpm --filter=end-to-end-tests exec playwright install --with-deps - - name: Debugging steps - run: | - echo "Workflow triggered in branch '${{ github.ref }}'." - echo "Workflow triggered in head_ref '${{ github.head_ref }}'." - echo "Workflow triggered by event '${{ github.event_name }}'." - echo "Workflow triggered by actor '${{ github.actor }}''." - - name: Determine E2E_ENDPOINT - run: > - echo E2E_ENDPOINT=$(if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.head_ref }}" != "main" ]]; then - echo "${{ vars.PREVIEW_URL }}/${{ github.head_ref }}"; - else - echo "${{ vars.MAIN_URL }}"; - fi) >> $GITHUB_ENV - - name: Run Playwright tests - run: pnpm --filter=end-to-end-tests exec playwright test - env: - E2E_ENDPOINT: ${{ env.E2E_ENDPOINT }} - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/_terraform-apply.yml b/.github/workflows/_terraform-apply.yml index 8f4ddb87..9290107e 100644 --- a/.github/workflows/_terraform-apply.yml +++ b/.github/workflows/_terraform-apply.yml @@ -31,7 +31,6 @@ jobs: uses: pnpm/action-setup@v4 id: pnpm-install with: - version: 8 run_install: false - name: Get pnpm store directory @@ -53,7 +52,7 @@ jobs: - name: Initialize Terraform CDK configuration shell: bash - working-directory: infra + working-directory: infra/cdktf run: | pnpm cdktf get pnpm build:tsc @@ -68,7 +67,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} shell: bash - working-directory: infra + working-directory: infra/cdktf run: | cf api https://api.fr.cloud.gov DEPLOY_ENV=${DEPLOY_ENV} pnpm cdktf deploy --auto-approve diff --git a/.github/workflows/_terraform-plan-pr-comment.yml b/.github/workflows/_terraform-plan-pr-comment.yml index 5340be84..25e665c2 100644 --- a/.github/workflows/_terraform-plan-pr-comment.yml +++ b/.github/workflows/_terraform-plan-pr-comment.yml @@ -33,7 +33,6 @@ jobs: uses: pnpm/action-setup@v4 id: pnpm-install with: - version: 8 run_install: false - name: Get pnpm store directory @@ -55,7 +54,7 @@ jobs: - name: Initialize Terraform CDK configuration shell: bash - working-directory: infra + working-directory: infra/cdktf run: | pnpm cdktf get pnpm build:tsc @@ -70,13 +69,13 @@ jobs: cf api https://api.fr.cloud.gov - name: Synthesize Terraform configuration - working-directory: infra + working-directory: infra/cdktf run: | DEPLOY_ENV=${DEPLOY_ENV} pnpm cdktf synth - name: Get Terraform stack name id: get_stack_name - working-directory: infra + working-directory: infra/cdktf run: | DEPLOY_ENV=${DEPLOY_ENV} pnpm cdktf output --outputs-file outputs.json echo "stack_name=$(jq -r 'keys[0]' outputs.json)" >> $GITHUB_OUTPUT @@ -84,5 +83,5 @@ jobs: - name: Create Terraform plan uses: dflook/terraform-plan@v1 with: - path: infra/cdktf.out/stacks/${{ steps.get_stack_name.outputs.stack_name }} + path: infra/cdktf/cdktf.out/stacks/${{ steps.get_stack_name.outputs.stack_name }} label: ${{ steps.get_stack_name.outputs.stack_name }} diff --git a/.github/workflows/_validate.yml b/.github/workflows/_validate.yml index 7acb5b7c..38f321bb 100644 --- a/.github/workflows/_validate.yml +++ b/.github/workflows/_validate.yml @@ -17,7 +17,7 @@ jobs: run: echo ::set-output name=NODE_VERSION::$(cat .nvmrc) - name: Install required node.js version - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ steps.nvmrc.outputs.NODE_VERSION }} @@ -25,7 +25,6 @@ jobs: uses: pnpm/action-setup@v4 id: pnpm-install with: - version: 8 run_install: false - name: Get pnpm store directory @@ -34,7 +33,7 @@ jobs: run: | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Setup pnpm cache with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} @@ -56,12 +55,12 @@ jobs: - name: Run test suite shell: bash - run: pnpm test:ci + run: AUTH_SECRET=not-super-secret pnpm test:ci - name: Initialize Terraform CDK configuration shell: bash run: | - cd infra + cd infra/cdktf pnpm cdktf get pnpm build:tsc @@ -69,6 +68,6 @@ jobs: shell: bash run: pnpm typecheck - #- name: Vitest Coverage Report - # if: always() - # uses: davelosert/vitest-coverage-report-action@v2.2.0 + # - name: Vitest Coverage Report + # if: always() + # uses: davelosert/vitest-coverage-report-action@v2.5.0 diff --git a/.github/workflows/add-terraform-plan-to-pr.yml b/.github/workflows/add-terraform-plan-to-pr.yml index fa0f3562..b072931f 100644 --- a/.github/workflows/add-terraform-plan-to-pr.yml +++ b/.github/workflows/add-terraform-plan-to-pr.yml @@ -15,4 +15,4 @@ jobs: uses: ./.github/workflows/_terraform-plan-pr-comment.yml secrets: inherit with: - deploy-env: staging + deploy-env: ${{ github.base_ref }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b884b8e..0c8e3e06 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,4 +27,4 @@ jobs: uses: ./.github/workflows/_terraform-apply.yml secrets: inherit with: - deploy-env: ${{ github.ref_name }} \ No newline at end of file + deploy-env: ${{ github.ref_name }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5eb62822..63742921 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -10,6 +10,5 @@ jobs: run-tests: uses: ./.github/workflows/_validate.yml e2e: - needs: [run-tests] - uses: ./.github/workflows/_playwright.yml + uses: ./.github/workflows/_end-to-end.yml secrets: inherit \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9dc08d2e..9e3b68c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .env +.pnpm-store/ *.code-workspace _site .turbo/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 59bc435b..c7760777 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,4 @@ +#!/bin/sh pnpm lint pnpm format -pnpm test \ No newline at end of file +pnpm test diff --git a/.nvmrc b/.nvmrc index c12134be..80a9956e 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.15.0 +v20.16.0 diff --git a/apps/cli/README.md b/apps/cli/README.md index 560a89f4..0be9620d 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,13 +1,11 @@ # @atj/cli-app -This package includes a very simple command-line interface. +This package defines the platform's command-line interface. -## Example commands - -Examples: +Commands are defined to aid with platform management operations. To see available commands, run: ```bash -pnpm run cli create-workspace-graph +pnpm cli --help ``` ## Development diff --git a/apps/cli/build.js b/apps/cli/build.js new file mode 100644 index 00000000..0180ce36 --- /dev/null +++ b/apps/cli/build.js @@ -0,0 +1,14 @@ +const esbuild = require('esbuild'); + +esbuild + .build({ + bundle: true, + entryPoints: ['./src/index.ts'], + format: 'cjs', + minify: true, + outdir: './dist', + platform: 'node', + sourcemap: true, + target: 'es2020', + }) + .catch(() => process.exit(1)); diff --git a/apps/cli/package.json b/apps/cli/package.json index fc6e41ae..613a3327 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -2,17 +2,19 @@ "name": "@atj/cli-app", "version": "1.0.0", "description": "10x ATJ command-line interface", - "type": "commonjs", + "type": "module", "license": "CC0", "main": "src/index.ts", "scripts": { - "build": "echo 'skipping...' #tsc -p .", - "cli": "ts-node src/index.ts", + "build": "tsup src/* --format esm", + "clean": "rimraf dist tsconfig.tsbuildinfo coverage", + "cli": "node dist/index.js", "dev": "tsup src/* --watch", "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.ts b/apps/cli/src/cli-controller.ts deleted file mode 100644 index 91c4bf10..00000000 --- a/apps/cli/src/cli-controller.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Command } from 'commander'; - -import { createDependencyGraph } from '@atj/dependency-graph'; - -type Context = { - console: Console; - workspaceRoot: string; - //docassemble: DocassembleClientContext; -}; - -export const CliController = (ctx: Context) => { - const cli = new Command().description( - 'CLI to interact with the ATJ workspace' - ); - - cli - .command('hello') - .description('say hello') - .action(() => { - 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'); - }); - - /* - const docassemble = cli - .command('docassemble') - .description('docassemble commands'); - - docassemble - .command('populate') - .description('populate a docassemble instance with test data') - .option( - '-r, --repository', - 'repository to populate from', - 'https://github.com/SuffolkLITLab/docassemble-MassAccess' - ) - .option('-b, --branch', 'branch of git repository to populate from', 'main') - .action(async ({ repository, branch }) => { - const client = new DocassembleClient(ctx.docassemble); - const result = await client.addPackage(repository, branch); - ctx.console.log('populated docassemble instance', result); - }); - - docassemble - .command('list-interviews') - .description('list docassemble interviews') - .action(async () => { - const client = new DocassembleClient(ctx.docassemble); - const interviews = await client.getInterviews(); - ctx.console.log('populated docassemble instance'); - }); - */ - - return cli; -}; diff --git a/apps/cli/src/cli-controller.test.ts b/apps/cli/src/cli-controller/cli-controller.test.ts similarity index 75% rename from apps/cli/src/cli-controller.test.ts rename to apps/cli/src/cli-controller/cli-controller.test.ts index 0e9c43f8..96f08046 100644 --- a/apps/cli/src/cli-controller.test.ts +++ b/apps/cli/src/cli-controller/cli-controller.test.ts @@ -1,18 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { CliController } from './cli-controller'; +import { CliController } from './index.js'; describe('cli controller', () => { it('works', async () => { const ctx = { console: mock({ log: vi.fn() }), workspaceRoot: '.', - docassemble: { - fetch: fetch, - apiUrl: '', - apiKey: '', - }, }; const app = CliController(ctx); await app.parseAsync(['node.js', 'dist/index.js', 'hello']); diff --git a/apps/cli/src/cli-controller/index.ts b/apps/cli/src/cli-controller/index.ts new file mode 100644 index 00000000..b78b7424 --- /dev/null +++ b/apps/cli/src/cli-controller/index.ts @@ -0,0 +1,30 @@ +import { Command } from 'commander'; + +import { createDependencyGraph } from '@atj/dependency-graph'; +import type { Context } from './types.js'; +import { addSecretCommands } from './secrets.js'; + +export const CliController = (ctx: Context) => { + const cli = new Command().description( + 'CLI to interact with the ATJ workspace' + ); + + cli + .command('hello') + .description('say hello') + .action(() => { + 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/cli/src/cli-controller/secrets.ts b/apps/cli/src/cli-controller/secrets.ts new file mode 100644 index 00000000..3af58398 --- /dev/null +++ b/apps/cli/src/cli-controller/secrets.ts @@ -0,0 +1,84 @@ +import path from 'path'; +import { Command } from 'commander'; + +import { type DeployEnv, commands, getSecretsVault } from '@atj/infra-core'; +import { type Context } from './types.js'; + +export const addSecretCommands = (ctx: Context, cli: Command) => { + const cmd = cli + .command('secrets') + .option('-f, --file ', 'Source JSON file for secrets.', path => { + ctx.file = path; + }) + .description('secrets management commands'); + + cmd + .command('delete') + .description('delete a secret') + .argument('', 'secret key name') + .action(async (key: string) => { + const vault = await getSecretsVault(ctx.file); + await commands.deleteSecret(vault, key); + }); + + cmd + .command('get') + .description('get a secret value') + .argument('', 'secret key name') + .action(async (key: string) => { + const vault = await getSecretsVault(ctx.file); + const secret = await commands.getSecret(vault, key); + console.log(secret); + }); + + cmd + .command('set') + .description('set a secret value') + .argument('', 'secret key name') + .argument('', 'secret value to set') + .action(async (key: string, value: string) => { + const vault = await getSecretsVault(ctx.file); + await commands.setSecret(vault, key, value); + }); + + cmd + .command('list') + .description('list all secret keys') + .action(async () => { + const vault = await getSecretsVault(ctx.file); + const secretKeys = await commands.getSecretKeyList(vault); + console.log(JSON.stringify(secretKeys, null, 2)); + }); + + cmd + .command('show') + .description('show all secrets') + .action(async () => { + const vault = await getSecretsVault(ctx.file); + const allSecrets = await commands.getSecrets(vault); + console.log(JSON.stringify(allSecrets, null, 2)); + }); + + cmd + .command('set-login-gov-keys') + .description( + 'generate and save login.gov keypair; if it already exists, it is not ' + + 'updated (future work might include adding key rotation)' + ) + .argument('', 'deployment environment (dev, staging)') + .argument('', 'application key') + .action(async (env: DeployEnv, appKey: string) => { + const vault = await getSecretsVault(ctx.file); + const secretsDir = path.resolve(__dirname, '../../../infra/secrets'); + const loginResult = await commands.setLoginGovSecrets( + { vault, secretsDir }, + env, + appKey + ); + if (loginResult.preexisting) { + console.log('Keypair already exists.'); + } else { + console.log(`New keypair added`); + } + }); +}; diff --git a/apps/cli/src/cli-controller/types.ts b/apps/cli/src/cli-controller/types.ts new file mode 100644 index 00000000..90b95261 --- /dev/null +++ b/apps/cli/src/cli-controller/types.ts @@ -0,0 +1,5 @@ +export type Context = { + console: Console; + workspaceRoot: string; + file?: string; +}; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 4987c825..599810e9 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,6 +1,5 @@ import { join } from 'path'; -import process from 'process'; -import { CliController } from './cli-controller'; +import { CliController } from './cli-controller/index.js'; // This should map to the directory containing the package.json. // By convention, assume that the originating process was run from the root @@ -10,10 +9,5 @@ const workspaceRoot = join(process.cwd(), '../../'); const app = CliController({ console, workspaceRoot, - /*docassemble: { - fetch, - apiUrl: 'http://localhost:8011', - apiKey: process.env.VITE_DOCASSEMBLE_API_KEY || '', - },*/ }); -app.parseAsync(process.argv).then(() => console.log('Done')); +app.parseAsync(process.argv).then(() => console.error('Exiting...')); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index a165fb86..11cf6557 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "CommonJS", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "./dist", "emitDeclarationOnly": true }, diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts new file mode 100644 index 00000000..7b9a120f --- /dev/null +++ b/apps/cli/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entryPoints: ['src/index.ts'], + format: ['cjs', 'esm'], + target: 'es2020', + minify: true, + sourcemap: true, + clean: true, + bundle: true, +}); diff --git a/apps/rest-api/package.json b/apps/rest-api/package.json index 4d287506..e4824114 100644 --- a/apps/rest-api/package.json +++ b/apps/rest-api/package.json @@ -6,14 +6,14 @@ "scripts": { "build": "esbuild src/index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js", "build:client": "tsup src/* --env.NODE_ENV production --dts-resolve", - "clean": "rm -rf dist tsconfig.tsbuildinfo", + "clean": "rimraf dist tsconfig.tsbuildinfo", "dev": "tsup src/* --watch" }, "dependencies": { "@atj/forms": "workspace:*" }, "devDependencies": { - "@types/aws-lambda": "^8.10.109", + "@types/aws-lambda": "^8.10.143", "esbuild": "^0.20.2" } } diff --git a/apps/server-doj/build.js b/apps/server-doj/build.js new file mode 100644 index 00000000..aaec3737 --- /dev/null +++ b/apps/server-doj/build.js @@ -0,0 +1,14 @@ +import esbuild from 'esbuild'; + +esbuild + .build({ + bundle: true, + entryPoints: ['./src/index.ts'], + format: 'esm', + minify: true, + outdir: './dist', + platform: 'node', + sourcemap: true, + target: 'esnext', + }) + .catch(() => process.exit(1)); diff --git a/apps/server-doj/package.json b/apps/server-doj/package.json index a897725c..649d5864 100644 --- a/apps/server-doj/package.json +++ b/apps/server-doj/package.json @@ -6,16 +6,18 @@ "license": "CC0", "main": "src/index.ts", "scripts": { - "start": "node dist/index.js", "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/node": "^20.14.8", + "@types/node": "^20.14.14", "@types/supertest": "^6.0.2", "supertest": "^7.0.0" } diff --git a/apps/server-doj/src/index.ts b/apps/server-doj/src/index.ts index 82e7c8ce..96981459 100644 --- a/apps/server-doj/src/index.ts +++ b/apps/server-doj/src/index.ts @@ -1,8 +1,22 @@ -import { createCustomServer } from './server'; +import { createPostgresDatabaseContext } from '@atj/database/context'; +import { createCustomServer } from './server.js'; const port = process.env.PORT || 4321; -const app = await createCustomServer(); -app.listen(port, () => { +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-doj/src/server.ts b/apps/server-doj/src/server.ts index 0ea9d736..32671777 100644 --- a/apps/server-doj/src/server.ts +++ b/apps/server-doj/src/server.ts @@ -1,7 +1,30 @@ -import { createServer } from '@atj/server/dist/index.js'; +import { type DatabaseContext } from '@atj/database'; +import { createServer } from '@atj/server'; -export const createCustomServer = () => { +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', + // DOJ test users + 'deserene.h.worsley@usdoj.gov', + 'jordan.pendergrass@usdoj.gov', + 'kira.gillespie@usdoj.gov', + 'kameron.c.thomas@usdoj.gov', + ].includes(email.toLowerCase()); + }, }); }; diff --git a/apps/server-doj/tests/integration.test.ts b/apps/server-doj/tests/integration.test.ts index 1d535e0d..91fd7671 100644 --- a/apps/server-doj/tests/integration.test.ts +++ b/apps/server-doj/tests/integration.test.ts @@ -1,16 +1,14 @@ import request from 'supertest'; -import { beforeAll, describe, expect, test } from 'vitest'; +import { describe, expect, test } from 'vitest'; + +import { createInMemoryDatabaseContext } from '@atj/database/context'; import { createCustomServer } from '../src/server'; describe('DOJ Form Service', () => { - let app: any; - - beforeAll(async () => { - app = await createCustomServer(); - }); - 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-doj/tsconfig.json b/apps/server-doj/tsconfig.json index 1e4b75b9..11cf6557 100644 --- a/apps/server-doj/tsconfig.json +++ b/apps/server-doj/tsconfig.json @@ -1,11 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "ESNext", - "emitDeclarationOnly": true, - "outDir": "./dist" + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "emitDeclarationOnly": true }, - "include": ["./src/**/*"], - "exclude": ["./dist"], + "include": ["./src"], "references": [] } diff --git a/apps/server-doj/vitest.config.ts b/apps/server-doj/vitest.config.ts new file mode 100644 index 00000000..d18c1f86 --- /dev/null +++ b/apps/server-doj/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +import sharedTestConfig from '../../vitest.shared'; + +export default defineConfig({ + ...sharedTestConfig, + test: { + ...sharedTestConfig.test, + }, +}); diff --git a/apps/server-kansas/package.json b/apps/server-kansas/package.json index 57692999..6458dd9f 100644 --- a/apps/server-kansas/package.json +++ b/apps/server-kansas/package.json @@ -6,16 +6,18 @@ "license": "CC0", "main": "src/index.ts", "scripts": { - "start": "node dist/index.js", "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/node": "^20.14.8", + "@types/node": "^20.14.14", "@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 index 82e7c8ce..96981459 100644 --- a/apps/server-kansas/src/index.ts +++ b/apps/server-kansas/src/index.ts @@ -1,8 +1,22 @@ -import { createCustomServer } from './server'; +import { createPostgresDatabaseContext } from '@atj/database/context'; +import { createCustomServer } from './server.js'; const port = process.env.PORT || 4321; -const app = await createCustomServer(); -app.listen(port, () => { +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 index c110b6ff..195b57b4 100644 --- a/apps/server-kansas/src/server.ts +++ b/apps/server-kansas/src/server.ts @@ -1,7 +1,25 @@ -import { createServer } from '@atj/server/dist/index.js'; +import { type DatabaseContext } from '@atj/database'; +import { createServer } from '@atj/server'; -export const createCustomServer = () => { +export const createCustomServer = async (db: DatabaseContext): Promise => { return createServer({ - title: 'KS Courts Form Service', + 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', + ].includes(email.toLowerCase()); + }, }); }; diff --git a/apps/server-kansas/tests/integration.test.ts b/apps/server-kansas/tests/integration.test.ts index 97e88fb1..e7382113 100644 --- a/apps/server-kansas/tests/integration.test.ts +++ b/apps/server-kansas/tests/integration.test.ts @@ -1,18 +1,15 @@ import request from 'supertest'; -import { beforeAll, describe, expect, test } from 'vitest'; +import { describe, expect, test } from 'vitest'; +import { createInMemoryDatabaseContext } from '@atj/database/context'; import { createCustomServer } from '../src/server'; describe('Kansas State Courts Form Service', () => { - let app: any; - - beforeAll(async () => { - app = await createCustomServer(); - }); - 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(/KS Courts Form Service/); + expect(response.text).toMatch(/DOJ Form Service/); }); }); diff --git a/apps/server-kansas/tsconfig.json b/apps/server-kansas/tsconfig.json index 1e4b75b9..6f1380c8 100644 --- a/apps/server-kansas/tsconfig.json +++ b/apps/server-kansas/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", "emitDeclarationOnly": true, "outDir": "./dist" }, diff --git a/apps/spotlight/astro.config.mjs b/apps/spotlight/astro.config.mjs index 64c13689..01f796f2 100644 --- a/apps/spotlight/astro.config.mjs +++ b/apps/spotlight/astro.config.mjs @@ -7,13 +7,16 @@ const githubRepository = await getGithubRepository(process.env); // https://astro.build/config export default defineConfig({ - trailingSlash: 'always', base: addTrailingSlash(process.env.BASEURL || ''), integrations: [ react({ include: ['src/components/react/**'], }), ], + security: { + checkOrigin: true, + }, + trailingSlash: 'always', vite: { define: { 'import.meta.env.GITHUB': JSON.stringify(githubRepository), diff --git a/apps/spotlight/package.json b/apps/spotlight/package.json index d31d8ab2..aacc8d0e 100644 --- a/apps/spotlight/package.json +++ b/apps/spotlight/package.json @@ -5,21 +5,21 @@ "scripts": { "astro": "astro", "build": "astro build", - "clean": "rm -rf dist", + "clean": "rimraf dist tsconfig.tsbuildinfo coverage", "dev": "astro dev", "preview": "astro preview" }, "dependencies": { - "@astrojs/react": "^3.0.9", + "@astrojs/react": "^3.6.1", "@atj/design": "workspace:*", "@atj/forms": "workspace:*", - "astro": "^4.3.3", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.12" + "astro": "^4.13.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13" }, "devDependencies": { "@astrojs/check": "^0.4.1", - "@types/react": "^18.2.37" + "@types/react": "^18.3.3" } } diff --git a/apps/spotlight/src/components/AppAvailableFormList.tsx b/apps/spotlight/src/components/AppAvailableFormList.tsx index 4e1fee8b..14637a41 100644 --- a/apps/spotlight/src/components/AppAvailableFormList.tsx +++ b/apps/spotlight/src/components/AppAvailableFormList.tsx @@ -2,9 +2,10 @@ import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { AvailableFormList } from '@atj/design'; -import { getAppContext } from '../context'; -import { getFormManagerUrlById, getFormUrl } from '../routes'; -import DebugTools from './DebugTools'; + +import { getAppContext } from '../context.js'; +import { getFormManagerUrlById, getFormUrl } from '../routes.js'; +import DebugTools from './DebugTools.js'; export default () => { const ctx = getAppContext(); diff --git a/apps/spotlight/src/components/AppFormManager.tsx b/apps/spotlight/src/components/AppFormManager.tsx index 176ca59f..c6944d16 100644 --- a/apps/spotlight/src/components/AppFormManager.tsx +++ b/apps/spotlight/src/components/AppFormManager.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { FormManager } from '@atj/design'; -import { getAppContext } from '../context'; -import { getFormManagerUrlById, getFormUrl } from '../routes'; +import { getAppContext } from '../context.js'; +import { getFormManagerUrlById, getFormUrl } from '../routes.js'; export default function () { const ctx = getAppContext(); diff --git a/apps/spotlight/src/components/AppFormRouter.tsx b/apps/spotlight/src/components/AppFormRouter.tsx index 26849ee9..787692c8 100644 --- a/apps/spotlight/src/components/AppFormRouter.tsx +++ b/apps/spotlight/src/components/AppFormRouter.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { FormRouter, defaultPatternComponents } from '@atj/design'; -import { getAppContext } from '../context'; +import { FormRouter } from '@atj/design'; +import { getAppContext } from '../context.js'; export default function AppFormRouter() { const ctx = getAppContext(); diff --git a/apps/spotlight/src/components/Footer.astro b/apps/spotlight/src/components/Footer.astro index f11a3421..60d45c07 100644 --- a/apps/spotlight/src/components/Footer.astro +++ b/apps/spotlight/src/components/Footer.astro @@ -1,5 +1,5 @@ --- -import { type GithubRepository, getBranchTreeUrl } from '../lib/github'; +import { type GithubRepository, getBranchTreeUrl } from '../lib/github.js'; type Props = { github: GithubRepository; diff --git a/apps/spotlight/src/context.ts b/apps/spotlight/src/context.ts index b3d92083..0059da9c 100644 --- a/apps/spotlight/src/context.ts +++ b/apps/spotlight/src/context.ts @@ -1,14 +1,19 @@ -import { FormConfig } from '@atj/forms'; +import { + type FormConfig, + type FormService, + createFormService, +} from '@atj/forms'; import { defaultFormConfig } from '@atj/forms'; -import { service } from '@atj/forms'; +import { BrowserFormRepository } from '@atj/forms/context'; -import { type GithubRepository } from './lib/github'; +import { type GithubRepository } from './lib/github.js'; +import { createTestBrowserFormService } from '@atj/forms/context'; export type AppContext = { baseUrl: `${string}/`; github: GithubRepository; formConfig: FormConfig; - formService: service.FormService; + formService: FormService; uswdsRoot: `${string}/`; }; @@ -33,8 +38,13 @@ const createAppContext = (env: any): AppContext => { const createAppFormService = () => { if (globalThis.window) { - return service.createBrowserFormService(); + const repository = new BrowserFormRepository(window.localStorage); + return createFormService({ + repository, + config: defaultFormConfig, + isUserLoggedIn: () => true, + }); } else { - return service.createTestFormService(); + return createTestBrowserFormService(); } }; diff --git a/apps/spotlight/src/env.d.ts b/apps/spotlight/src/env.d.ts index f964fe0c..acef35f1 100644 --- a/apps/spotlight/src/env.d.ts +++ b/apps/spotlight/src/env.d.ts @@ -1 +1,2 @@ +/// /// diff --git a/apps/spotlight/src/images/video-screenshot-gray-80.jpg b/apps/spotlight/src/images/video-screenshot-gray-80.jpg new file mode 100644 index 00000000..f34c9fe0 Binary files /dev/null and b/apps/spotlight/src/images/video-screenshot-gray-80.jpg differ diff --git a/apps/spotlight/src/images/video-screenshot-gray-loading-80.jpg b/apps/spotlight/src/images/video-screenshot-gray-loading-80.jpg new file mode 100644 index 00000000..0e2f41be Binary files /dev/null and b/apps/spotlight/src/images/video-screenshot-gray-loading-80.jpg differ diff --git a/apps/spotlight/src/layouts/Layout.astro b/apps/spotlight/src/layouts/Layout.astro index 4d2f9210..e3f7d573 100644 --- a/apps/spotlight/src/layouts/Layout.astro +++ b/apps/spotlight/src/layouts/Layout.astro @@ -49,10 +49,12 @@ const context = getAppContext(); href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> + {title} +
diff --git a/apps/spotlight/src/pages/splash/index.astro b/apps/spotlight/src/pages/splash/index.astro index 7531b2d1..d9ffc07e 100644 --- a/apps/spotlight/src/pages/splash/index.astro +++ b/apps/spotlight/src/pages/splash/index.astro @@ -4,67 +4,130 @@ import atjInfographics from '../../images/atj-infographics.png'; --- -

A people-centered platform for online document assembly

- -
- +
+
+
+
+

+ A people-centered platform for online document assembly +

+ +

+ + Play Video | 5 minutes + Demo: 10x Access to Justice Forms Platform + +

+
+ +
+
+
+
+

The 10x Access to Justice Forms Platform is revolutionizing the way courts and government agencies handle forms, replacing outdated fillable PDFs with modern, web-based digital forms that are compliant with the + + 21st Century Integrated Digital Experience Act (IDEA) + +

+
+
+
+
+
+

+ 10x ATJ Infographic +

+
+
+

Empower the Public

+

Our platform's “guided interview” design simplifies form completion for self-represented litigants, empowering them to advocate for themselves more effectively.

+
+
+
+
+

Enhanced User Experience

+

The intuitive interface ensures a seamless experience, reducing frustration and errors, and improving overall experience for the public and form authors.

+
+
+
+
+
+
+

Save Time for Staff

+

By minimizing incorrect and missing data, our digital forms free up valuable staff time, allowing government employees to focus on more critical tasks.

+
+
+
+
+

Seamless Integration and Implementation

+

Easily create, host, and integrate digital forms without major IT overhauls, complementing existing systems and supporting necessary policy and process improvements.

+
+
+
+
+
+
+
+

How we’re getting there

+

Taking a user-driven, research-first approach, we are piloting with federal and state partners, targeting the high-value PDF-to-web workflow. We plan to expand into other government domains as we prove value in the justice space.

+

Forward thinking courts are already using open source tools that have delivered big wins for their communities. However, we have identified critical barriers to adoption. In Phase 3, we designed and tested several loosely-couple parts focused on high-value workflows, and built interest from the A2J community.

+

In Phase 4, we are working to scale the system with complete, end-to-end user experiences and integrate with backend case management systems. We are building the system in a manner that we believe positions it as a viable "Forms as a Service" product that may, in the future, more broadly serve other government domains.

+
+
+
+
+

Help us shape the future of forms

+

If you have a backlog of PDF forms that you need to get into compliance with the 21st Century IDEA, sign up to have an initial discussion with the Forms Platform project team. We’ll see if you’d be a good fit for our pilot program.

+

Your inputs will make it possible for us to continue our mission of enabling all government offices to be able to incrementally deliver trusted digital forms using customer experiences best practices with current-year money.

+

+ Contact project team +

+
+
+ diff --git a/apps/spotlight/src/routes.ts b/apps/spotlight/src/routes.ts index 8249a19d..310b295b 100644 --- a/apps/spotlight/src/routes.ts +++ b/apps/spotlight/src/routes.ts @@ -1,4 +1,4 @@ -import { getAppContext } from './context'; +import { getAppContext } from './context.js'; export const getFormUrl = (formId: string) => { const context = getAppContext(); diff --git a/apps/spotlight/src/styles.css b/apps/spotlight/src/styles.css index a6caa7a3..70448945 100644 --- a/apps/spotlight/src/styles.css +++ b/apps/spotlight/src/styles.css @@ -1 +1,98 @@ @import '@atj/design/static/uswds/styles/styles.css'; + +/* Splash Page */ + +.offline-homepage .grid-container { + max-width: 64rem; + padding: 3rem 2rem; +} + +.offline-homepage .usa-hero { + background-image: url("../src/images/video-screenshot-gray-80.jpg"); +} + +.offline-homepage-modal.usa-modal--lg { + max-width: 45rem; +} + +.offline-homepage-modal.usa-modal--lg .usa-modal__main { + max-width: 100%; + padding: 1.5rem; +} + +.offline-homepage-modal .usa-modal__close { + font-size: 1.5rem; + padding: 0.5rem; +} + +.offline-homepage-modal .js-modal-content-target { + position: relative; + padding-bottom: 62.5%; + height: 0; + background-image: url("../src/images/video-screenshot-gray-loading-80.jpg"); + background-size: contain; +} + +.offline-homepage .usa-hero__callout { + max-width: 611px; + background: white; +} + +.offline-homepage .usa-hero__heading { + padding: 32px; + font-size: 2.5rem; +} + +.offline-homepage .play-video { + position: relative; +} + +.offline-homepage .play-video .usa-button { + display: block; + width: 100%; + height: 100%; + border-radius: 0; + padding: 18px 18px 18px 78px; + text-align: left; +} + +.offline-homepage .play-video .usa-button:hover { + background: black; +} + +.offline-homepage .play-video a::before { + content: " "; + display: block; + left: 32px; + top: 18px; + position: absolute; + width: 0; + height: 0; + border-top: 16.5px solid transparent; + border-bottom: 16.4px solid transparent; + border-left: 28px solid white; +} + +.offline-homepage .body-content-gray { + background: #f7f9fd; +} + +.offline-homepage .usa-graphic-list .usa-media-block { + margin: 3rem 0 0; +} + +.offline-homepage .usa-graphic-list .grid-container, +.offline-homepage .grid-container.intro-block, +.offline-homepage .bg-white .grid-container, +.offline-homepage .body-content-gray .grid-container { + padding: 0 2rem; +} + +.offline-homepage .bg-base-darkest { + padding-bottom: 3rem; +} + +.offline-homepage .bg-white h2, +.offline-homepage .body-content-gray h2 { + margin-top: 1rem; +} diff --git a/apps/spotlight/tsconfig.json b/apps/spotlight/tsconfig.json index f2044fb5..a4ef7740 100644 --- a/apps/spotlight/tsconfig.json +++ b/apps/spotlight/tsconfig.json @@ -3,7 +3,8 @@ // https://github.com/withastro/astro/blob/main/packages/astro/tsconfigs/base.json "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", "jsx": "react", "resolveJsonModule": true }, diff --git a/documents/adr/0010-end-to-end-testing.md b/documents/adr/0010-end-to-end-testing.md index c7f5c9aa..361c1789 100644 --- a/documents/adr/0010-end-to-end-testing.md +++ b/documents/adr/0010-end-to-end-testing.md @@ -1,6 +1,6 @@ -# 10. End to end testing +# 10. End-to-end and interaction testing -Date: 2024-07-01 +Date: 2024-07-08 ## Status @@ -11,8 +11,8 @@ Pending Certain tests are not able to be performed with Storybook and JSDOM (e.g. drag-and-drop). The ability to replicate more complex user interactions in the test suite through an actual browser can provide this feature. ## Decision -The end-to-end tests should be used sparingly since they are slower to run than the ones through JSDOM. Storybook still should be the primary mechanism for testing, and the Playwright tests will round out what isn't possible there. +The end-to-end tests should be used sparingly since they are slower to run than the ones through JSDOM. We will use Playwright in CI/CD for the comprehensive tests and JSDOM during development for speed. Storybook still should be the primary mechanism for UI testing, and during CI/CD, the interaction tests will be run in Playwright using a docker container against the build. ## Consequences -The deployed application will include Playwright tests in the e2e package. +There are some tests that will be end-to-end that run against the built application, while the interaction tests will run against the built Storybook. The end-to-end tests will be in the e2e directory, and docker will be used to make the test environment consistent. \ No newline at end of file diff --git a/documents/adr/0011-secrets-management.md b/documents/adr/0011-secrets-management.md new file mode 100644 index 00000000..73275bd3 --- /dev/null +++ b/documents/adr/0011-secrets-management.md @@ -0,0 +1,31 @@ +# 11. Secrets management + +Date: 2024-07-11 + +## Status + +Approved + +## Context + +The Form Platform requires a method of managing secrets. During the early prototyping phase, we used Terraform with AWS Systems Manager Parameter Store. Secrets were manually created via the AWS console, and lookups were handled by Terraform's corresponding data provider. + +As we look to operationalize management processes, tooling for working with secrets will become increasingly helpful. This has become apparent with our first scenario, the login.gov keypair, which needs to be unique for each deployed application. + +## Decision + +We will abstract secrets management into a package in the project's monorepo, and provide commands in the existing `cli` application for common operations. + +Additionally, adapters to the backend vault will be utilized. This will enable managing secrets in a production secret store, or via in-memory or local storage for testing purposes. + +We will continue to utilize AWS Systems Manager Parameter Store. + +The implementation will live in `infra/core`, which will be the home for other abstract infrastructure code, while `infra/cdktf` will include the concrete implementation of the deployment. + +## Consequences + +Secrets management that requires repeated manual operations will be automated. In the future, this will include things like secrets rotation via the same command-line interface. + +Writing secrets management code as Typescript (rather than Terraform or shell scripts), means we can easily write unit tests for the logic, as well as maintain type-safety across infrastructure code (handling secrets wiring) and the applications that utilize secrets. + +Additionally, this approach will make migrating from one secrets storage backend vault to another very easy. We may want to move from Parameter Store to Github Secrets, to limit the surface area of cloud services we utilize. diff --git a/e2e/Dockerfile b/e2e/Dockerfile index 72572fd8..018501ea 100644 --- a/e2e/Dockerfile +++ b/e2e/Dockerfile @@ -1,22 +1,29 @@ # base image with Node.js and playwright preinstalled -FROM mcr.microsoft.com/playwright:v1.43.1-jammy as base +FROM mcr.microsoft.com/playwright:v1.46.0-jammy as base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" ENV NODE_ENV=test +RUN apt-get update && apt-get install -y netcat WORKDIR /srv/apps/atj-platform -COPY . . +COPY ./pnpm-lock.yaml ./pnpm-lock.yaml +COPY ./package.json ./package.json RUN corepack enable -RUN pnpm install --filter=@atj/spotlight --frozen-lockfile FROM base as test ENV E2E_ENDPOINT=http://localhost:9090 +ENV CI=true +COPY . . RUN npm install -g serve EXPOSE 9090 -RUN pnpm build --filter=@atj/spotlight +EXPOSE 9191 +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile +RUN pnpm build --filter=@atj/spotlight --filter=@atj/design WORKDIR ./e2e -RUN serve ../apps/spotlight/dist -p 9090 & sleep 5; pnpm playwright test; +RUN serve ../apps/spotlight/dist -p 9090 -L & while ! nc -z localhost 9090; do sleep 1; done; pnpm playwright test; +RUN serve ../packages/design/storybook-static -p 9191 -L & while ! nc -z localhost 9191; do sleep 1; done; pnpm --filter=end-to-end-tests test:storybook --url http://localhost:9191 --config-dir ../packages/design/.storybook/ --browsers firefox chromium FROM base as serve ENV E2E_ENDPOINT=http://localhost:4321 -EXPOSE 4321 9292 9323 -CMD ["pnpm", "dev", "--filter=@atj/spotlight", "--", "--host"] \ No newline at end of file +EXPOSE 4321 9292 9323 9009 8080 +RUN git config --global --add safe.directory /srv/apps/atj-platform +CMD ["pnpm", "dev"] diff --git a/e2e/README.md b/e2e/README.md index ef98b367..079dfdca 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,34 +1,45 @@ -# End-to-end testing -E2E testing runs in a docker container. +# End-to-end and interaction testing +E2E testing runs in a docker container. There is a shell script (`./scripts/end-to-end.sh`) that provides configuration to automate several Docker-related commands. -```bash -# run from project root -docker build --tag 'playwright' . -f ./e2e/Dockerfile -``` +Parameters: +-p : Configure the port on which Storybook will be served. Defaults to 9009 if no value is specified. +-c : Configure the name of the Docker container. Defaults to e2e if no value is specified. +-f : Specify the function(s) you'd like to run. You can add multiple -f parameters followed by function name, like `-f build_container -f run_container` +-t : This parameter lets you specify the Docker build target. This should be either `serve` or `test`. + +Functions: +`build_container` : Builds a Docker container, where the build target is specified using a -t flag (the default target is `test`). +`run_container` : Runs the built Docker container. +`end_to_end` : Performs playwright test command inside the Docker container for end-to-end testing. Requires the container to be running. +`interaction` : Performs the interaction tests inside the Docker container against the storybook instance. Requires the container to be running. + +If Docker is not installed on your machine, running the script will prompt you to install Docker. If no function(s) are defined using -f flag, it runs `end_to_end` and `interaction` function by default. + +## Build targets +The `test` target is self-contained and meant to run mostly in the build pipeline. The `serve` target is most useful during local development. When the `serve` container is run, it will mount a volume from your local machine and start a dev server, so you get persistent storage without having to rebuild the image. + +## Getting Started + +First, make sure the script is executable: -To see the output of the tests and run everything when the docker container is built, run the command below: ```bash -# run from project root -docker build --tag 'playwright' . -f ./e2e/Dockerfile --progress=plain --target test +# from the project root +chmod +x ./e2e/scripts/end-to-end.sh ``` -You can add the `--no-cache` flag to build from scratch. -To run the container (best for development): +Examples: ```bash -# run from project root -docker run -p 4321:4321 -it --name e2e --rm playwright +# builds the test container (also will run the tests) +./e2e/scripts/end-to-end.sh -f build_container -t test ``` ```bash -# run from project root -docker exec -it e2e pnpm playwright test +# builds the serve container and start it +./e2e/scripts/end-to-end.sh -f build_container -t serve -f run_container ``` -### Debugging -To debug and follow the flow of a test in a browser, you can run: - ```bash -# run from this directory -export E2E_ENDPOINT=http://localhost:4321; pnpm playwright test --ui-port=8080 --ui-host=0.0.0.0 -``` \ No newline at end of file +# Runs the default tasks `end_to_end` and `interaction` +./e2e/scripts/end-to-end.sh +``` diff --git a/e2e/package.json b/e2e/package.json index e442a851..947c3973 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -3,10 +3,15 @@ "version": "1.0.0", "scripts": { "dev": "tsc -w", + "test:storybook": "test-storybook", "test": "export E2E_ENDPOINT=http://localhost:4321; pnpm playwright test --ui-port=8080 --ui-host=0.0.0.0" }, "devDependencies": { - "@playwright/test": "^1.43.1", - "path-to-regexp": "^7.0.0" + "@playwright/test": "^1.46.0", + "@storybook/test-runner": "^0.19.1", + "path-to-regexp": "^7.1.0" + }, + "dependencies": { + "@atj/common": "workspace:*" } } diff --git a/e2e/script/end-to-end.sh b/e2e/script/end-to-end.sh new file mode 100755 index 00000000..deafb3ed --- /dev/null +++ b/e2e/script/end-to-end.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# default values +STORYBOOK_PORT=9009 +CONTAINER_NAME="e2e" +BUILD_TARGET="test" +BASE_PATH=$(dirname $0) +FUNCS="" + +# Parse flag parameters +while getopts "p:c:f:t:" flag +do + case "${flag}" in + p) STORYBOOK_PORT=${OPTARG};; + c) CONTAINER_NAME=${OPTARG};; + f) FUNCS="$FUNCS ${OPTARG}";; + t) BUILD_TARGET="${OPTARG}";; + esac +done + +build_container() { + if [[ $BUILD_TARGET != "serve" ]] && [[ $BUILD_TARGET != "test" ]]; then + echo "Invalid BUILD_TARGET! It should be either 'serve' or 'test.' You need to pass these values in with the -t flag (e.g. -t serve)." + exit 1 + fi + + docker build --tag 'playwright' . -f $BASE_PATH/../Dockerfile --progress=plain --target $BUILD_TARGET +} + +run_container() { + docker run -p 4321:4321 -p $STORYBOOK_PORT:$STORYBOOK_PORT --name $CONTAINER_NAME -v $(dirname $0)/../../:/srv/apps/atj-platform -it --rm playwright +} + +end_to_end() { + docker exec -w /srv/apps/atj-platform/e2e -it $CONTAINER_NAME pnpm playwright test +} + +interaction() { + docker exec -it $CONTAINER_NAME pnpm --filter=end-to-end-tests test:storybook --url http://localhost:$STORYBOOK_PORT --config-dir ../packages/design/.storybook/ --browsers firefox chromium +} + +if ! command -v docker &> /dev/null; then + echo "Docker is not installed. Please install Docker to run this script." + exit 1 +fi + + +if [ -z "$FUNCS" ]; then + end_to_end + interaction +else + for FUNC in $FUNCS; do + case $FUNC in + "build_container") build_container ;; + "run_container") run_container ;; + "interaction") interaction ;; + "end_to_end") end_to_end ;; + *) echo "Invalid flag: ${FUNC}. Ignored." ;; + esac + done +fi diff --git a/e2e/src/constants.ts b/e2e/src/constants.ts index 1ba4089d..61f6e813 100644 --- a/e2e/src/constants.ts +++ b/e2e/src/constants.ts @@ -1 +1,2 @@ export const BASE_URL = process.env.E2E_ENDPOINT; +export const STORYBOOK_PATH = 'design/iframe.html?id='; diff --git a/e2e/src/create.spec.ts b/e2e/src/create.spec.ts index 3f6e2c96..a5c2997c 100644 --- a/e2e/src/create.spec.ts +++ b/e2e/src/create.spec.ts @@ -3,13 +3,19 @@ import { GuidedFormCreation, Create } from '../../packages/design/src/FormManage import { BASE_URL } from './constants'; import { pathToRegexp } from 'path-to-regexp'; - const createNewForm = async (page: Page) => { - console.log(`${BASE_URL}/${GuidedFormCreation.getUrl()}`); await page.goto(`${BASE_URL}/${GuidedFormCreation.getUrl()}`); await page.getByRole('button', { name: 'Create New' }).click(); } +const addQuestions = async (page: Page) => { + const menuButton = page.getByRole('button', { name: 'Question', exact: true }); + await menuButton.click(); + await page.getByRole('button', { name: 'Short Answer' }).click(); + await menuButton.click(); + await page.getByRole('button', { name: 'Radio Buttons' }).click(); +} + test('Create form from scratch', async ({ page }) => { const regexp = pathToRegexp(Create.path); await createNewForm(page); @@ -30,16 +36,11 @@ test('Create form from scratch', async ({ page }) => { test('Add questions', async ({ page }) => { await createNewForm(page); - - const menuButton = page.getByRole('button', { name: 'Question' }); - await menuButton.click(); - await page.getByRole('button', { name: 'Short Answer' }).click(); - await menuButton.click(); - await page.getByRole('button', { name: 'Radio Buttons' }).click(); + await addQuestions(page); // Create locators for both elements const fields = page.locator('.usa-label'); - const element1 = fields.filter({ hasText: 'Field Label' }) + const element1 = fields.filter({ hasText: 'Field label' }); const element2 = fields.filter({ hasText: 'Radio group label' }); expect(element1.first()).toBeTruthy(); expect(element2.first()).toBeTruthy(); @@ -50,4 +51,25 @@ test('Add questions', async ({ page }) => { const element2Index = htmlContent.indexOf((await element2.textContent() as string)); expect(element1Index).toBeLessThan(element2Index); -}); \ No newline at end of file +}); + +test('Drag-and-drop questions via mouse interaction', async ({ page }) => { + await createNewForm(page); + await addQuestions(page); + + const handle = page.locator('[aria-describedby="DndDescribedBy-0"]').first(); + await handle.hover(); + await page.mouse.down(); + const nextElement = page.locator('.draggable-list-item-wrapper').nth(1); + await nextElement.hover(); + await page.mouse.up(); + + // Initiating a reorder clones the element. We want to await the count to go back to 1 to + // signify the invocation of the drag event has completed and the update has rendered. + await expect(page.getByText('Field label')).toHaveCount(1, { + timeout: 10000 + }); + + await expect(page.locator('.draggable-list-item-wrapper').nth(1)).toContainText('Field label'); + +}); diff --git a/e2e/src/edit.spec.ts b/e2e/src/edit.spec.ts new file mode 100644 index 00000000..b3b0c888 --- /dev/null +++ b/e2e/src/edit.spec.ts @@ -0,0 +1,117 @@ +import { expect, test, Page } from '@playwright/test'; +import { BASE_URL } from './constants'; + +interface PageDataTest { + title: string; + pattern?: Array; +} + +class TestPage { + page: Page; + constructor(page: Page) { + this.page = page; + } + async setLocalStorage(key: string, value: any) { + await this.page.context().addInitScript(([key, value]) => { + localStorage.setItem(key, value); + }, [key, JSON.stringify(value)]); + } + async moveListItem(buttonText: string, pageTitles: string[]) { + const handle = this.page.locator('li').filter({ hasText: `${buttonText}${pageTitles[0]}` }).getByRole('button'); + await handle.hover(); + await this.page.mouse.down(); + const nextElement = this.page.locator('li').filter({ hasText: `${buttonText}${pageTitles[1]}` }).getByRole('button'); + await nextElement.hover(); + await this.page.mouse.up(); + } + async checkFirstUrl(path: string) { + const firstUrl = new URL(this.page.url()); + expect(firstUrl.hash.indexOf(path)).toEqual(-1); + } + async checkNextUrl(path: string) { + const nextUrl = new URL(this.page.url()); + expect(nextUrl.hash.indexOf(path)).not.toEqual(-1); + } +} + +const preparePageTitles = (items: any) => { + return Object.values(items).filter(item => item.type === 'page').map(item => (item.data as PageDataTest).title); +} + +const prepareUpdatedPageTitles = (items: string[]) => { + const newFirstItem = items.shift(); + return items.splice(1, 0, newFirstItem || ''); +} + +test('Drag-and-drop pages via mouse interaction', async ({ context, page }) => { + const key = '62ba7264-e869-40fc-ac68-5892f1228a9b'; + const obj = { + "summary": { + "title": "My form - 2024-07-10T20:20:08.037Z", + "description": "" + }, + "root": "root", + "patterns": { + "root": { + "type": "page-set", + "id": "root", + "data": { + "pages": [ + "cbd810ef-fd29-48f6-901b-fa88ab4f4100", + "5e4381b5-c05e-4471-ba1c-cd4ff045ab74", + "aca1a6a4-e7ba-4f37-bdf1-2726aa40831a" + ] + } + }, + "cbd810ef-fd29-48f6-901b-fa88ab4f4100": { + "type": "page", + "id": "cbd810ef-fd29-48f6-901b-fa88ab4f4100", + "data": { + "title": "Page 1", + "patterns": [] + } + }, + "5e4381b5-c05e-4471-ba1c-cd4ff045ab74": { + "id": "5e4381b5-c05e-4471-ba1c-cd4ff045ab74", + "type": "page", + "data": { + "title": "A new page", + "patterns": [] + } + }, + "aca1a6a4-e7ba-4f37-bdf1-2726aa40831a": { + "id": "aca1a6a4-e7ba-4f37-bdf1-2726aa40831a", + "type": "page", + "data": { + "title": "Different Page", + "patterns": [] + } + } + }, + "outputs": [] + }; + const testPage = new TestPage(page); + await testPage.setLocalStorage(key, obj); + const pageTitles = preparePageTitles(obj.patterns); + + await page.goto(`${BASE_URL}`); + await page.getByRole('link', { name: 'Edit' }).click(); + const buttonText = 'Move this item'; + + await testPage.checkFirstUrl('?page='); + await testPage.moveListItem(buttonText, pageTitles); + + await page.waitForFunction(([pageTitles, buttonText]) => { + const items = document.querySelectorAll('.usa-sidenav .draggable-list-item-wrapper'); + return (items[0] as HTMLElement).innerText === buttonText + '\n' + pageTitles[1] && items.length === 3; + }, [pageTitles, buttonText]); + + const pageTitlesCopy = [...pageTitles]; + const newPageTitles = prepareUpdatedPageTitles(pageTitlesCopy); + + const reorderedFirst = page.locator('ul').filter({ hasText: buttonText + newPageTitles.join(buttonText) }).getByRole('button').first(); + + await expect(reorderedFirst).toBeVisible(); + await testPage.checkNextUrl('?page=1'); + +}); \ No newline at end of file diff --git a/e2e/src/my-forms.spec.ts b/e2e/src/my-forms.spec.ts index 06ef59f3..5e6bddc0 100644 --- a/e2e/src/my-forms.spec.ts +++ b/e2e/src/my-forms.spec.ts @@ -5,4 +5,4 @@ import { BASE_URL } from './constants'; test('Go to MyForms', async ({ page }) => { const response = await page.goto(`${BASE_URL}/${MyForms.getUrl()}`); expect(response?.ok()).toBe(true); -}); \ No newline at end of file +}); diff --git a/esbuild.config.js b/esbuild.config.js new file mode 100644 index 00000000..b1e3e14c --- /dev/null +++ b/esbuild.config.js @@ -0,0 +1,12 @@ +module.exports = { + target: 'esnext', + format: 'esm', + bundle: true, + minify: true, + external: [], + loader: { + '.ts': 'ts', + '.tsx': 'tsx', + }, + outdir: 'dist', +}; diff --git a/infra/.gitignore b/infra/cdktf/.gitignore similarity index 96% rename from infra/.gitignore rename to infra/cdktf/.gitignore index 82c4875d..9683373c 100644 --- a/infra/.gitignore +++ b/infra/cdktf/.gitignore @@ -1,6 +1,7 @@ tsconfig.tsbuildinfo cdktf.out/ dist/ +secrets/ # This package does not build to a dist directory, so add explicity exclude # patterns for the Typescript artifacts. diff --git a/infra/README.md b/infra/cdktf/README.md similarity index 100% rename from infra/README.md rename to infra/cdktf/README.md diff --git a/infra/__tests__/main-test.ts b/infra/cdktf/__tests__/main-test.ts similarity index 100% rename from infra/__tests__/main-test.ts rename to infra/cdktf/__tests__/main-test.ts diff --git a/infra/cdktf.json b/infra/cdktf/cdktf.json similarity index 100% rename from infra/cdktf.json rename to infra/cdktf/cdktf.json diff --git a/infra/jest.config.js b/infra/cdktf/jest.config.js similarity index 100% rename from infra/jest.config.js rename to infra/cdktf/jest.config.js diff --git a/infra/jest.setup.js b/infra/cdktf/jest.setup.js similarity index 100% rename from infra/jest.setup.js rename to infra/cdktf/jest.setup.js diff --git a/infra/package.json b/infra/cdktf/package.json similarity index 69% rename from infra/package.json rename to infra/cdktf/package.json index cf455b17..94a583c1 100644 --- a/infra/package.json +++ b/infra/cdktf/package.json @@ -1,5 +1,5 @@ { - "name": "infra", + "name": "@atj/infra-cdktf", "version": "1.0.0", "main": "src/index.js", "types": "src/index.ts", @@ -10,7 +10,8 @@ "build:synth:main": "DEPLOY_ENV=main cdktf synth", "build:synth:staging": "DEPLOY_ENV=staging cdktf synth", "build:tsc": "tsc --pretty", - "clean": "rm -rf .gen cdktf.out", + "clean": "rimraf cdktf.out dist tsconfig.tsbuildinfo", + "clean:gen": "rimraf .gen", "deploy:main": "DEPLOY_ENV=main cdktf deploy", "deploy:staging": "DEPLOY_ENV=staging cdktf deploy", "dev": "tsc -w", @@ -18,14 +19,15 @@ "test:watch": "jest --watch" }, "dependencies": { - "cdktf": "^0.20.7", - "cdktf-cli": "^0.20.7", + "@aws-sdk/client-ssm": "^3.624.0", + "cdktf": "^0.20.8", + "cdktf-cli": "^0.20.8", "constructs": "^10.3.0" }, "devDependencies": { - "@types/jest": "^29.5.6", - "@types/node": "^20.8.7", + "@types/jest": "^29.5.12", + "@types/node": "^20.14.14", "jest": "^29.7.0", - "ts-jest": "^29.1.1" + "ts-jest": "^29.2.4" } } diff --git a/infra/scripts/cloud.sh b/infra/cdktf/scripts/cloud.sh similarity index 100% rename from infra/scripts/cloud.sh rename to infra/cdktf/scripts/cloud.sh diff --git a/infra/scripts/recreate.sh b/infra/cdktf/scripts/recreate.sh similarity index 100% rename from infra/scripts/recreate.sh rename to infra/cdktf/scripts/recreate.sh diff --git a/infra/src/index.ts b/infra/cdktf/src/index.ts similarity index 100% rename from infra/src/index.ts rename to infra/cdktf/src/index.ts diff --git a/infra/src/lib/app-stack.ts b/infra/cdktf/src/lib/app-stack.ts similarity index 82% rename from infra/src/lib/app-stack.ts rename to infra/cdktf/src/lib/app-stack.ts index d171b876..0b1e2e3a 100644 --- a/infra/src/lib/app-stack.ts +++ b/infra/cdktf/src/lib/app-stack.ts @@ -8,15 +8,18 @@ import { withBackend } from './backend'; import { CloudGovSpace } from './cloud.gov/space'; import { DataAwsSsmParameter } from '../../.gen/providers/aws/data-aws-ssm-parameter'; -export const registerAppStack = (stackPrefix: string, deployEnv: string) => { +export const registerAppStack = ( + stackPrefix: string, + gitCommitHash: string +) => { const app = new App(); - const stack = new AppStack(app, stackPrefix, deployEnv); + const stack = new AppStack(app, stackPrefix, gitCommitHash); withBackend(stack, stackPrefix); app.synth(); }; class AppStack extends TerraformStack { - constructor(scope: Construct, id: string, deployEnv: string) { + constructor(scope: Construct, id: string, gitCommitHash: string) { super(scope, id); new AwsProvider(this, 'AWS', { @@ -45,7 +48,7 @@ class AppStack extends TerraformStack { password: cfPassword.value, }); - new CloudGovSpace(this, id, deployEnv); + new CloudGovSpace(this, id, gitCommitHash); //new Docassemble(this, `${id}-docassemble`); //new FormService(this, `${id}-rest-api`); diff --git a/infra/src/lib/backend.ts b/infra/cdktf/src/lib/backend.ts similarity index 100% rename from infra/src/lib/backend.ts rename to infra/cdktf/src/lib/backend.ts diff --git a/infra/src/lib/cloud.gov/config.ts b/infra/cdktf/src/lib/cloud.gov/config.ts similarity index 100% rename from infra/src/lib/cloud.gov/config.ts rename to infra/cdktf/src/lib/cloud.gov/config.ts diff --git a/infra/src/lib/cloud.gov/index.ts b/infra/cdktf/src/lib/cloud.gov/index.ts similarity index 100% rename from infra/src/lib/cloud.gov/index.ts rename to infra/cdktf/src/lib/cloud.gov/index.ts diff --git a/infra/cdktf/src/lib/cloud.gov/node-astro.ts b/infra/cdktf/src/lib/cloud.gov/node-astro.ts new file mode 100644 index 00000000..3c3915d1 --- /dev/null +++ b/infra/cdktf/src/lib/cloud.gov/node-astro.ts @@ -0,0 +1,95 @@ +import { Construct } from 'constructs'; +import * as cloudfoundry from '../../../.gen/providers/cloudfoundry'; + +export class AstroService extends Construct { + constructor( + scope: Construct, + id: string, + spaceId: string, + imageName: `${string}:${string}`, + secrets: { + loginGovPrivateKey: string; + } + ) { + super(scope, id); + + const domain = + new cloudfoundry.dataCloudfoundryDomain.DataCloudfoundryDomain( + scope, + `${id}-data-domain`, + { + name: 'app.cloud.gov', + } + ); + + const route = new cloudfoundry.route.Route(this, `${id}-route`, { + domain: domain.id, + space: spaceId, + hostname: id, + }); + + const loginGovService = + new cloudfoundry.userProvidedService.UserProvidedService( + this, + `${id}-login-gov-service`, + { + name: `${id}-login-gov-service`, + space: spaceId, + credentials: { + loginGovPrivateKey: secrets.loginGovPrivateKey, + }, + } + ); + + const rds = + new cloudfoundry.dataCloudfoundryService.DataCloudfoundryService( + scope, + `${id}-data-aws-rds`, + { + name: 'aws-rds', + } + ); + + const dbInstance = new cloudfoundry.serviceInstance.ServiceInstance( + this, + `${id}-db`, + { + name: `${id}-db`, + servicePlan: rds.servicePlans.lookup('micro-psql'), + space: spaceId, + jsonParams: '{"version": "15"}', + lifecycle: { + preventDestroy: true, + }, + timeouts: { + create: '60m', + update: '60m', + delete: '2h', + }, + } + ); + + new cloudfoundry.app.App(this, `${id}-app`, { + name: `${id}-app`, + space: spaceId, + dockerImage: `ghcr.io/gsa-tts/atj-platform/${imageName}`, + memory: 1024, + diskQuota: 4096, + healthCheckType: 'http', + healthCheckHttpEndpoint: '/', + routes: [ + { + route: route.id, + }, + ], + serviceBinding: [ + { + serviceInstance: dbInstance.id, + }, + { + serviceInstance: loginGovService.id, + }, + ], + }); + } +} diff --git a/infra/src/lib/cloud.gov/space.ts b/infra/cdktf/src/lib/cloud.gov/space.ts similarity index 58% rename from infra/src/lib/cloud.gov/space.ts rename to infra/cdktf/src/lib/cloud.gov/space.ts index 4e2e4228..77c36925 100644 --- a/infra/src/lib/cloud.gov/space.ts +++ b/infra/cdktf/src/lib/cloud.gov/space.ts @@ -3,9 +3,10 @@ import { Construct } from 'constructs'; import * as cloudfoundry from '../../../.gen/providers/cloudfoundry'; import { CLOUD_GOV_ORG_NAME } from './config'; import { AstroService } from './node-astro'; +import { getSecret } from '../secrets'; export class CloudGovSpace extends Construct { - constructor(scope: Construct, id: string, deployEnv: string) { + constructor(scope: Construct, id: string, gitCommitHash: string) { super(scope, id); const space = new cloudfoundry.dataCloudfoundrySpace.DataCloudfoundrySpace( @@ -21,13 +22,25 @@ export class CloudGovSpace extends Construct { scope, `${id}-server-doj`, space.id, - `server-doj:${deployEnv}` + `server-doj:${gitCommitHash}`, + { + loginGovPrivateKey: getSecret( + this, + `/${id}/server-doj/login.gov/private-key` + ), + } ); new AstroService( scope, `${id}-server-kansas`, space.id, - `server-kansas:${deployEnv}` + `server-kansas:${gitCommitHash}`, + { + loginGovPrivateKey: getSecret( + this, + `/${id}/server-kansas/login.gov/private-key` + ), + } ); } } diff --git a/infra/src/lib/docassemble.ts b/infra/cdktf/src/lib/docassemble.ts similarity index 100% rename from infra/src/lib/docassemble.ts rename to infra/cdktf/src/lib/docassemble.ts diff --git a/infra/src/lib/rest-api.ts b/infra/cdktf/src/lib/rest-api.ts similarity index 100% rename from infra/src/lib/rest-api.ts rename to infra/cdktf/src/lib/rest-api.ts diff --git a/infra/cdktf/src/lib/secrets.ts b/infra/cdktf/src/lib/secrets.ts new file mode 100644 index 00000000..5aa4a310 --- /dev/null +++ b/infra/cdktf/src/lib/secrets.ts @@ -0,0 +1,9 @@ +import { Construct } from 'constructs'; +import { DataAwsSsmParameter } from '../../.gen/providers/aws/data-aws-ssm-parameter'; + +export const getSecret = (scope: Construct, name: string) => { + const parameter = new DataAwsSsmParameter(scope, name, { + name, + }); + return parameter.value; +}; diff --git a/infra/cdktf/src/spaces/main.ts b/infra/cdktf/src/spaces/main.ts new file mode 100644 index 00000000..839824f9 --- /dev/null +++ b/infra/cdktf/src/spaces/main.ts @@ -0,0 +1,6 @@ +import { execSync } from 'child_process'; + +import { registerAppStack } from '../lib/app-stack'; + +const gitCommitHash = execSync('git rev-parse HEAD').toString().trim(); +registerAppStack('tts-10x-atj-dev', gitCommitHash); diff --git a/infra/cdktf/src/spaces/staging.ts b/infra/cdktf/src/spaces/staging.ts new file mode 100644 index 00000000..ac44cf85 --- /dev/null +++ b/infra/cdktf/src/spaces/staging.ts @@ -0,0 +1,6 @@ +import { execSync } from 'child_process'; + +import { registerAppStack } from '../lib/app-stack'; + +const gitCommitHash = execSync('git rev-parse HEAD').toString().trim(); +registerAppStack('tts-10x-atj-staging', gitCommitHash); diff --git a/infra/tsconfig.json b/infra/cdktf/tsconfig.json similarity index 100% rename from infra/tsconfig.json rename to infra/cdktf/tsconfig.json diff --git a/infra/core/.gitignore b/infra/core/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/infra/core/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/forms/src/service/operations/index.ts b/infra/core/README.md similarity index 100% rename from packages/forms/src/service/operations/index.ts rename to infra/core/README.md diff --git a/infra/core/build.js b/infra/core/build.js new file mode 100644 index 00000000..2f942bc6 --- /dev/null +++ b/infra/core/build.js @@ -0,0 +1,14 @@ +import esbuild from 'esbuild'; + +esbuild + .build({ + //bundle: true, + entryPoints: ['./src/index.ts'], + format: 'esm', + minify: true, + outdir: './dist', + platform: 'node', + sourcemap: true, + target: 'es2020', + }) + .catch(() => process.exit(1)); diff --git a/infra/core/package.json b/infra/core/package.json new file mode 100644 index 00000000..7fd3a639 --- /dev/null +++ b/infra/core/package.json @@ -0,0 +1,21 @@ +{ + "name": "@atj/infra-core", + "version": "1.0.0", + "description": "10x ATJ secrets storage backend", + "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": "vitest run --coverage" + }, + "dependencies": { + "@atj/common": "workspace:*", + "@atj/forms": "workspace:*", + "@aws-sdk/client-ssm": "^3.624.0", + "zod": "^3.23.8" + } +} diff --git a/infra/core/src/commands/delete-secret.test.ts b/infra/core/src/commands/delete-secret.test.ts new file mode 100644 index 00000000..600c38fa --- /dev/null +++ b/infra/core/src/commands/delete-secret.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemorySecretsVault } from '../lib/index.js'; +import { deleteSecret } from './delete-secret.js'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('delete-secret command', () => { + it('removes key', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + await deleteSecret(vault, 'secret-key-1'); + expect(await vault.getSecretKeys()).toEqual([]); + }); + + it('silently handles non-existent keys', async () => { + const vault = getTestVault({}); + await deleteSecret(vault, 'secret-key-1'); + expect(await vault.getSecretKeys()).toEqual([]); + }); +}); diff --git a/infra/core/src/commands/delete-secret.ts b/infra/core/src/commands/delete-secret.ts new file mode 100644 index 00000000..9bb43685 --- /dev/null +++ b/infra/core/src/commands/delete-secret.ts @@ -0,0 +1,5 @@ +import type { SecretKey, SecretsVault } from '../lib/types.js'; + +export const deleteSecret = async (vault: SecretsVault, key: SecretKey) => { + return await vault.deleteSecret(key); +}; diff --git a/infra/core/src/commands/get-secret-key-list.test.ts b/infra/core/src/commands/get-secret-key-list.test.ts new file mode 100644 index 00000000..296944b6 --- /dev/null +++ b/infra/core/src/commands/get-secret-key-list.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemorySecretsVault } from '../lib/index.js'; +import { getSecretKeyList } from './get-secret-key-list.js'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('list-secret-keys command', () => { + it('gets keys for vault', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + 'secret-key-2': 'value-2', + 'secret-key-3': 'value-3', + }); + const keys = await getSecretKeyList(vault); + expect(keys).toEqual(['secret-key-1', 'secret-key-2', 'secret-key-3']); + }); + + it('returns empty array for empty vault', async () => { + const vault = getTestVault({}); + const keys = await getSecretKeyList(vault); + expect(keys).toEqual([]); + }); +}); diff --git a/infra/core/src/commands/get-secret-key-list.ts b/infra/core/src/commands/get-secret-key-list.ts new file mode 100644 index 00000000..6964946d --- /dev/null +++ b/infra/core/src/commands/get-secret-key-list.ts @@ -0,0 +1,5 @@ +import { type SecretsVault } from '../lib/types.js'; + +export const getSecretKeyList = async (vault: SecretsVault) => { + return await vault.getSecretKeys(); +}; diff --git a/infra/core/src/commands/get-secret.test.ts b/infra/core/src/commands/get-secret.test.ts new file mode 100644 index 00000000..7377bb2c --- /dev/null +++ b/infra/core/src/commands/get-secret.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { getSecret } from './get-secret.js'; +import { createInMemorySecretsVault } from '../lib/index.js'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('get-secret command', () => { + it('gets existing value', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + const value = await getSecret(vault, 'secret-key-1'); + expect(value).toEqual('value-1'); + }); + + it('return undefined with non-existing value', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + const value = await getSecret(vault, 'secret-key-2'); + expect(value).toEqual(undefined); + }); +}); diff --git a/infra/core/src/commands/get-secret.ts b/infra/core/src/commands/get-secret.ts new file mode 100644 index 00000000..d0b8df68 --- /dev/null +++ b/infra/core/src/commands/get-secret.ts @@ -0,0 +1,5 @@ +import { type SecretsVault } from '../lib/types.js'; + +export const getSecret = async (vault: SecretsVault, key: string) => { + return await vault.getSecret(key); +}; diff --git a/infra/core/src/commands/get-secrets.test.ts b/infra/core/src/commands/get-secrets.test.ts new file mode 100644 index 00000000..db17c73e --- /dev/null +++ b/infra/core/src/commands/get-secrets.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { getSecrets } from './get-secrets.js'; +import { createInMemorySecretsVault } from '../lib/index.js'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('get-secrets command', () => { + it('returns existing values', async () => { + const vaultSecrets = { + 'secret-key-1': 'value-1', + 'secret-key-2': 'value-2', + }; + const vault = getTestVault(vaultSecrets); + const secrets = await getSecrets(vault); + expect(secrets).toEqual(vaultSecrets); + }); + + it('returns empty object for empty vault', async () => { + const vault = getTestVault({}); + const value = await getSecrets(vault); + expect(value).toEqual({}); + }); +}); diff --git a/infra/core/src/commands/get-secrets.ts b/infra/core/src/commands/get-secrets.ts new file mode 100644 index 00000000..d856345a --- /dev/null +++ b/infra/core/src/commands/get-secrets.ts @@ -0,0 +1,6 @@ +import { type SecretsVault } from '../lib/types.js'; + +export const getSecrets = async (vault: SecretsVault) => { + const allKeys = await vault.getSecretKeys(); + return await vault.getSecrets(allKeys); +}; diff --git a/infra/core/src/commands/index.ts b/infra/core/src/commands/index.ts new file mode 100644 index 00000000..c6174b88 --- /dev/null +++ b/infra/core/src/commands/index.ts @@ -0,0 +1,6 @@ +export { deleteSecret } from './delete-secret.js'; +export { getSecret } from './get-secret.js'; +export { getSecrets } from './get-secrets.js'; +export { getSecretKeyList } from './get-secret-key-list.js'; +export { setLoginGovSecrets } from './set-login-gov-secrets.js'; +export { setSecret } from './set-secret.js'; diff --git a/infra/core/src/commands/set-login-gov-secrets.test.ts b/infra/core/src/commands/set-login-gov-secrets.test.ts new file mode 100644 index 00000000..5388e98d --- /dev/null +++ b/infra/core/src/commands/set-login-gov-secrets.test.ts @@ -0,0 +1,78 @@ +import { randomUUID } from 'crypto'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +import { createInMemorySecretsVault } from '../lib/index.js'; +import { setLoginGovSecrets } from './set-login-gov-secrets.js'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('set-login-gov-secrets command', () => { + it('sets app secrets when uninitialized', async () => { + const context = { + vault: getTestVault({}), + secretsDir: path.resolve(__dirname, '../../../../infra/secrets'), + generateLoginGovKey: async () => ({ + publicKey: 'mock public key', + privateKey: 'mock private key', + }), + }; + const appKey = randomUUID(); + const result = await setLoginGovSecrets(context, 'dev', appKey); + expect(result.preexisting).toEqual(false); + expect( + await context.vault.getSecrets(await context.vault.getSecretKeys()) + ).toEqual({ + [`/tts-10x-atj-dev/${appKey}/login.gov/public-key`]: 'mock public key', + [`/tts-10x-atj-dev/${appKey}/login.gov/private-key`]: 'mock private key', + }); + }); + + it('leaves initialized secrets as-is', async () => { + const context = { + vault: getTestVault({}), + secretsDir: path.resolve(__dirname, '../../../../infra/secrets'), + }; + const appKey = randomUUID(); + + await setLoginGovSecrets( + { + ...context, + generateLoginGovKey: async () => ({ + publicKey: 'mock public key - 1', + privateKey: 'mock private key - 1', + }), + }, + 'dev', + appKey + ); + const secondResult = await setLoginGovSecrets( + { + ...context, + generateLoginGovKey: async () => ({ + publicKey: 'mock public key - 2', + privateKey: 'mock private key - 2', + }), + }, + 'dev', + appKey + ); + + expect(secondResult.preexisting).toEqual(true); + expect( + await context.vault.getSecrets(await context.vault.getSecretKeys()) + ).toEqual({ + [`/tts-10x-atj-dev/${appKey}/login.gov/public-key`]: + 'mock public key - 1', + [`/tts-10x-atj-dev/${appKey}/login.gov/private-key`]: + 'mock private key - 1', + }); + }); +}); diff --git a/infra/core/src/commands/set-login-gov-secrets.ts b/infra/core/src/commands/set-login-gov-secrets.ts new file mode 100644 index 00000000..04f201bb --- /dev/null +++ b/infra/core/src/commands/set-login-gov-secrets.ts @@ -0,0 +1,81 @@ +import { exec } from 'child_process'; +import { promises as fs } from 'fs'; +import { promisify } from 'util'; + +import { type SecretsVault } from '../lib/types.js'; +import { type DeployEnv, getAppLoginGovKeys } from '../values.js'; + +const execPromise = promisify(exec); + +type GenerateLoginGovKey = ( + privateKeyPath: string, + publicKeyPath: string +) => Promise<{ + publicKey: string; + privateKey: string; +}>; + +type Context = { + vault: SecretsVault; + secretsDir: string; + generateLoginGovKey?: GenerateLoginGovKey; +}; + +export const setLoginGovSecrets = async ( + ctx: Context, + env: DeployEnv, + appKey: string +) => { + const loginKeys = getAppLoginGovKeys(env, appKey); + + // If the keypair is already set, do nothing and return it. + const existingPublicKey = await ctx.vault.getSecret(loginKeys.publicKey); + const existingPrivateKey = await ctx.vault.getSecret(loginKeys.privateKey); + if (existingPublicKey && existingPrivateKey) { + return { + preexisting: true, + publicKey: existingPublicKey, + privateKey: existingPrivateKey, + }; + } + + // Generate a new keypair and return it. + const myGenerateKey = ctx.generateLoginGovKey || generateLoginGovKey; + const { publicKey, privateKey } = await myGenerateKey( + loginGovPrivateKeyPath(ctx.secretsDir, appKey), + loginGovPublicKeyPath(ctx.secretsDir, appKey) + ); + await ctx.vault.setSecret(loginKeys.privateKey, privateKey); + await ctx.vault.setSecret(loginKeys.publicKey, publicKey); + return { + preexisting: false, + publicKey, + privateKey, + }; +}; + +const loginGovPublicKeyPath = (secretsDir: string, appKey: string) => + `${secretsDir}/login-gov-${appKey}-key.pem`; + +const loginGovPrivateKeyPath = (secretsDir: string, appKey: string) => + `${secretsDir}/login-gov-${appKey}-cert.pem`; + +const generateLoginGovKey: GenerateLoginGovKey = async ( + privateKeyPath: string, + publicKeyPath: string +) => { + const shellCommand = `openssl req \ + -nodes \ + -x509 \ + -days 365 \ + -newkey rsa:2048 \ + -new \ + -subj "/C=US/O=General Services Administration/OU=TTS/CN=gsa.gov" \ + -keyout ${privateKeyPath} \ + -out ${publicKeyPath}`; + await execPromise(shellCommand); + return { + publicKey: (await fs.readFile(publicKeyPath)).toString(), + privateKey: (await fs.readFile(privateKeyPath)).toString(), + }; +}; diff --git a/infra/core/src/commands/set-secret.test.ts b/infra/core/src/commands/set-secret.test.ts new file mode 100644 index 00000000..cfff82be --- /dev/null +++ b/infra/core/src/commands/set-secret.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { setSecret } from './set-secret.js'; +import { createInMemorySecretsVault } from '../lib/index.js'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('set-secret command', () => { + it('sets existing value', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + await setSecret(vault, 'secret-key-1', 'secret-value-updated'); + expect(await vault.getSecrets(await vault.getSecretKeys())).toEqual({ + 'secret-key-1': 'secret-value-updated', + }); + }); + + it('sets unset value', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + await setSecret(vault, 'secret-key-2', 'secret-value-updated'); + expect(await vault.getSecrets(await vault.getSecretKeys())).toEqual({ + 'secret-key-1': 'value-1', + 'secret-key-2': 'secret-value-updated', + }); + }); +}); diff --git a/infra/core/src/commands/set-secret.ts b/infra/core/src/commands/set-secret.ts new file mode 100644 index 00000000..45c759be --- /dev/null +++ b/infra/core/src/commands/set-secret.ts @@ -0,0 +1,9 @@ +import { type SecretsVault } from '../lib/types.js'; + +export const setSecret = async ( + vault: SecretsVault, + key: string, + value: string +) => { + await vault.setSecret(key, value); +}; diff --git a/infra/core/src/index.ts b/infra/core/src/index.ts new file mode 100644 index 00000000..85a4f8b0 --- /dev/null +++ b/infra/core/src/index.ts @@ -0,0 +1,3 @@ +export * as commands from './commands/index.js'; +export { getSecretsVault } from './lib/index.js'; +export { type DeployEnv } from './values.js'; diff --git a/infra/core/src/lib/adapters/aws-param-store.ts b/infra/core/src/lib/adapters/aws-param-store.ts new file mode 100644 index 00000000..32d8da46 --- /dev/null +++ b/infra/core/src/lib/adapters/aws-param-store.ts @@ -0,0 +1,128 @@ +import { + DeleteParameterCommand, + DescribeParametersCommand, + DescribeParametersCommandOutput, + GetParameterCommand, + GetParametersCommand, + ParameterNotFound, + PutParameterCommand, + SSMClient, +} from '@aws-sdk/client-ssm'; + +import type { + SecretKey, + SecretMap, + SecretValue, + SecretsVault, +} from '../types.js'; + +export class AWSParameterStoreSecretsVault implements SecretsVault { + client: SSMClient; + + constructor() { + this.client = new SSMClient(); + } + + async deleteSecret(key: SecretKey) { + try { + await this.client.send( + new DeleteParameterCommand({ + Name: key, + }) + ); + console.log(`Secret "${key}" deleted successfully.`); + } catch (error) { + console.warn('Skipped deleting parameter due to error:', error); + } + } + + async getSecret(key: SecretKey) { + try { + const response = await this.client.send( + new GetParameterCommand({ + Name: key, + WithDecryption: true, + }) + ); + return response.Parameter?.Value || ''; + } catch (error) { + if (error instanceof ParameterNotFound) { + return undefined; + } + console.error('Error getting parameter:', error); + throw error; + } + } + + async getSecrets(keys: SecretKey[]) { + try { + const response = await this.client.send( + new GetParametersCommand({ + Names: keys, + WithDecryption: true, + }) + ); + const values: { [key: SecretKey]: SecretValue } = {}; + if (response.Parameters) { + for (const parameter of response.Parameters) { + if (parameter.Name && parameter.Value) { + values[parameter.Name] = parameter.Value; + } + } + } + return values; + } catch (error) { + console.error('Error getting parameters:', error); + throw error; + } + } + + async setSecret(key: SecretKey, value: SecretValue) { + try { + await this.client.send( + new PutParameterCommand({ + Name: key, + Value: value, + Type: 'SecureString', + Overwrite: true, + }) + ); + console.log(`Secret "${key}" set successfully.`); + } catch (error) { + console.error('Error setting parameter:', error); + throw error; + } + } + + async setSecrets(secrets: SecretMap) { + const promises = Object.entries(secrets).map(([key, value]) => + this.setSecret(key, value) + ); + await Promise.all(promises); + } + + async getSecretKeys() { + let keys: string[] = []; + let nextToken: string | undefined; + do { + try { + const response: DescribeParametersCommandOutput = + await this.client.send( + new DescribeParametersCommand({ + NextToken: nextToken, + MaxResults: 50, + }) + ); + if (response.Parameters) { + keys.push(...response.Parameters.map(param => param.Name!)); + } + nextToken = response.NextToken; + } catch (error) { + console.error('Error describing parameters:', error); + throw error; + } + } while (nextToken); + + return keys; + } +} diff --git a/infra/core/src/lib/adapters/in-memory.ts b/infra/core/src/lib/adapters/in-memory.ts new file mode 100644 index 00000000..4b5f4611 --- /dev/null +++ b/infra/core/src/lib/adapters/in-memory.ts @@ -0,0 +1,30 @@ +import type { SecretMap, SecretsVault } from '../types.js'; + +export class InMemorySecretsVault implements SecretsVault { + constructor(private secretMap: SecretMap) {} + + async deleteSecret(key: string) { + delete this.secretMap[key]; + } + + async getSecret(key: string) { + return this.secretMap[key]; + } + + async getSecrets(keys: string[]) { + const entries = keys.map(key => [key, this.secretMap[key]]); + return Object.fromEntries(entries); + } + + async setSecret(key: string, value: string) { + this.secretMap[key] = value; + } + + async setSecrets(secretMap: SecretMap) { + this.secretMap = secretMap; + } + + async getSecretKeys() { + return Object.keys(this.secretMap); + } +} diff --git a/infra/core/src/lib/adapters/index.ts b/infra/core/src/lib/adapters/index.ts new file mode 100644 index 00000000..6e9628d0 --- /dev/null +++ b/infra/core/src/lib/adapters/index.ts @@ -0,0 +1,43 @@ +import { promises as fs } from 'fs'; + +import * as r from '@atj/common'; + +import { AWSParameterStoreSecretsVault } from './aws-param-store.js'; +import { getSecretMapFromJsonString, type SecretsVault } from '../types.js'; +import { InMemorySecretsVault } from './in-memory.js'; + +/** + * Returns either a production vault or an in-memory vault initialized with the + * contents of a JSON file. + * @param jsonFilePath Optional path to a local JSON file that will stand-in + * for a secrets vault. + * @returns In-memory or production vault. + */ +export const getSecretsVault = async (jsonFilePath?: string) => { + if (jsonFilePath) { + const maybeJsonString = (await fs.readFile(jsonFilePath)).toString(); + const result = createInMemorySecretsVault(maybeJsonString); + if (result.success) { + return result.data; + } else { + throw new Error(result.error); + } + } else { + return getAWSSecretsVault(); + } +}; + +export const getAWSSecretsVault = (): SecretsVault => { + return new AWSParameterStoreSecretsVault(); +}; + +export const createInMemorySecretsVault = ( + jsonString?: any +): r.Result => { + const result = getSecretMapFromJsonString(jsonString); + if (result.success) { + return r.success(new InMemorySecretsVault(result.data)); + } else { + return r.failure(result.error); + } +}; diff --git a/infra/core/src/lib/index.ts b/infra/core/src/lib/index.ts new file mode 100644 index 00000000..8702eb7a --- /dev/null +++ b/infra/core/src/lib/index.ts @@ -0,0 +1,10 @@ +import { type SecretMap, type SecretsVault } from './types.js'; + +export { getSecretMapFromJsonString } from './types.js'; +export * from './adapters/index.js'; + +export const getSecretMap = async (vault: SecretsVault): Promise => { + const secretKeys = await vault.getSecretKeys(); + const secretMap = await vault.getSecrets(secretKeys); + return secretMap; +}; diff --git a/infra/core/src/lib/types.ts b/infra/core/src/lib/types.ts new file mode 100644 index 00000000..d853b773 --- /dev/null +++ b/infra/core/src/lib/types.ts @@ -0,0 +1,35 @@ +import * as z from 'zod'; +import { type Result } from '@atj/common'; + +export type SecretKey = string; +export type SecretValue = string | undefined; +export type SecretMap = Record; + +const secretMap = z.record(z.string()); + +export const getSecretMapFromJsonString = ( + jsonString?: string +): Result => { + const inputObject = jsonString ? JSON.parse(jsonString) : null; + const result = secretMap.safeParse(inputObject); + if (result.success) { + return { + success: true, + data: result.data as SecretMap, + }; + } else { + return { + success: false, + error: result.error.message, + }; + } +}; + +export interface SecretsVault { + deleteSecret(key: SecretKey): Promise; + getSecret(key: SecretKey): Promise; + getSecrets(keys: SecretKey[]): Promise; + setSecret(key: SecretKey, value: SecretValue): Promise; + setSecrets(secrets: SecretMap): Promise; + getSecretKeys(): Promise; +} diff --git a/infra/core/src/values.ts b/infra/core/src/values.ts new file mode 100644 index 00000000..60c0a01d --- /dev/null +++ b/infra/core/src/values.ts @@ -0,0 +1,11 @@ +export type DeployEnv = 'dev' | 'staging'; + +const getPathPrefix = (env: DeployEnv) => `/tts-10x-atj-${env}`; + +export const getAppLoginGovKeys = (env: DeployEnv, appKey: string) => { + const prefix = getPathPrefix(env); + return { + privateKey: `${prefix}/${appKey}/login.gov/private-key`, + publicKey: `${prefix}/${appKey}/login.gov/public-key`, + }; +}; diff --git a/infra/core/tsconfig.json b/infra/core/tsconfig.json new file mode 100644 index 00000000..7fad7f24 --- /dev/null +++ b/infra/core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src"], + "references": [] +} diff --git a/infra/src/lib/cloud.gov/node-astro.ts b/infra/src/lib/cloud.gov/node-astro.ts deleted file mode 100644 index d437cdc2..00000000 --- a/infra/src/lib/cloud.gov/node-astro.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Construct } from 'constructs'; -import * as cloudfoundry from '../../../.gen/providers/cloudfoundry'; - -export class AstroService extends Construct { - constructor( - scope: Construct, - id: string, - spaceId: string, - imageName: `${string}:${string}` - ) { - super(scope, id); - - const domain = - new cloudfoundry.dataCloudfoundryDomain.DataCloudfoundryDomain( - scope, - `${id}-data-domain`, - { - name: 'app.cloud.gov', - } - ); - - const route = new cloudfoundry.route.Route(scope, `${id}-route`, { - domain: domain.id, - space: spaceId, - hostname: id, - }); - - new cloudfoundry.app.App(this, `${id}-app`, { - name: `${id}-app`, - space: spaceId, - dockerImage: `ghcr.io/gsa-tts/atj-platform/${imageName}`, - memory: 1024, - diskQuota: 4096, - healthCheckType: 'http', - healthCheckHttpEndpoint: '/', - routes: [ - { - route: route.id, - }, - ], - }); - } -} diff --git a/infra/src/spaces/main.ts b/infra/src/spaces/main.ts deleted file mode 100644 index 34ed8c1c..00000000 --- a/infra/src/spaces/main.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { registerAppStack } from '../lib/app-stack'; - -registerAppStack('tts-10x-atj-dev', 'main'); diff --git a/infra/src/spaces/staging.ts b/infra/src/spaces/staging.ts deleted file mode 100644 index d6425f1e..00000000 --- a/infra/src/spaces/staging.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { registerAppStack } from '../lib/app-stack'; - -registerAppStack('tts-10x-atj-staging', 'staging'); diff --git a/package.json b/package.json index c73cc80f..eedf645d 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,17 @@ "type": "module", "main": "index.js", "license": "CC0", + "packageManager": "pnpm@9.8.0", "scripts": { - "build": "turbo run build --filter=!infra", + "build": "turbo run build --filter=!@atj/infra-cdktf", "clean": "turbo run clean", - "dev": "turbo run dev --concurrency 14", + "dev": "turbo run dev --concurrency 18", "format": "prettier --write \"packages/*/src/**/*.{js,jsx,ts,tsx,scss}\"", "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.reporter=text --coverage.reporter=json-summary --coverage.reporter=json --coverage.reportOnFailure --reporter vitest-github-actions-reporter", - "test:infra": "turbo run --filter=infra test", + "test:ci": "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", "prepare": "husky" }, @@ -22,24 +23,25 @@ "pre-commit": "pnpm format" }, "devDependencies": { - "@types/node": "^20.11.16", - "@vitest/coverage-c8": "^0.33.0", - "@vitest/coverage-v8": "^1.2.2", - "@vitest/ui": "^1.2.2", - "eslint": "^8.56.0", - "husky": "^9.0.11", + "@rollup/plugin-commonjs": "^26.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@types/node": "^20.14.14", + "@vitest/coverage-v8": "^2.0.5", + "@vitest/ui": "^2.0.5", + "esbuild": "^0.23.0", + "eslint": "^8.57.0", + "husky": "^9.1.4", "npm-run-all": "^4.1.5", - "prettier": "^3.2.5", + "prettier": "^3.3.3", + "rimraf": "^6.0.1", + "rollup": "^4.20.0", + "rollup-plugin-typescript2": "^0.36.0", "ts-node": "^10.9.2", - "tsup": "^8.0.1", - "turbo": "^1.12.3", - "type-fest": "^4.10.2", - "typescript": "^5.3.3", - "vitest": "^1.6.0", - "vitest-github-actions-reporter": "^0.11.1", - "vitest-mock-extended": "^1.3.1" - }, - "dependencies": { - "astro": "^4.3.5" + "tsup": "^8.2.4", + "turbo": "^2.0.14", + "typescript": "^5.5.4", + "vitest": "^2.0.5", + "vitest-mock-extended": "^2.0.0" } } diff --git a/packages/auth/.gitignore b/packages/auth/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/packages/auth/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/auth/global.d.ts b/packages/auth/global.d.ts new file mode 100644 index 00000000..d85ddf6d --- /dev/null +++ b/packages/auth/global.d.ts @@ -0,0 +1 @@ +import 'vitest-fetch-mock'; diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 00000000..711b30f0 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,29 @@ +{ + "name": "@atj/auth", + "version": "1.0.0", + "description": "10x ATJ auth module", + "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": "vitest run --coverage" + }, + "dependencies": { + "@atj/common": "workspace:^", + "@atj/database": "workspace:*", + "@lucia-auth/adapter-postgresql": "^3.1.2", + "@lucia-auth/adapter-sqlite": "^3.0.2", + "arctic": "^1.9.2", + "better-sqlite3": "^11.1.2", + "lucia": "^3.2.0", + "oslo": "^1.2.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "vitest-fetch-mock": "^0.3.0" + } +} diff --git a/packages/auth/src/context/base.ts b/packages/auth/src/context/base.ts new file mode 100644 index 00000000..e4181e4f --- /dev/null +++ b/packages/auth/src/context/base.ts @@ -0,0 +1,48 @@ +import { Cookie, Lucia } from 'lucia'; + +import { type AuthServiceContext, type UserSession } from '../index.js'; +import { + createPostgresLuciaAdapter, + createSqliteLuciaAdapter, +} from '../lucia.js'; +import { LoginGov } from '../provider.js'; +import { type AuthRepository } from '../repository/index.js'; + +export class BaseAuthContext implements AuthServiceContext { + private lucia?: Lucia; + + constructor( + public db: AuthRepository, + public provider: LoginGov, + public getCookie: (name: string) => string | undefined, + public setCookie: (cookie: Cookie) => void, + public setUserSession: (userSession: UserSession) => void, + public isUserAuthorized: (email: string) => Promise + ) {} + + async getLucia() { + const sqlite3Adapter = + this.db.getContext().engine === 'sqlite' + ? createSqliteLuciaAdapter( + await (this.db.getContext() as any).getSqlite3() + ) + : createPostgresLuciaAdapter( + await (this.db.getContext() as any).getPostgresPool() + ); + if (!this.lucia) { + this.lucia = new Lucia(sqlite3Adapter, { + sessionCookie: { + attributes: { + secure: false, + }, + }, + getUserAttributes: attributes => { + return { + email: attributes.email, + }; + }, + }); + } + return this.lucia; + } +} diff --git a/packages/auth/src/context/test.ts b/packages/auth/src/context/test.ts new file mode 100644 index 00000000..00eb32f9 --- /dev/null +++ b/packages/auth/src/context/test.ts @@ -0,0 +1,77 @@ +import { Cookie, Lucia } from 'lucia'; +import { vi } from 'vitest'; + +import { createInMemoryDatabaseContext } from '@atj/database/context'; + +import { AuthServiceContext, UserSession } from '../index.js'; +import { createSqliteLuciaAdapter } from '../lucia.js'; +import { LoginGov } from '../provider.js'; +import { + createAuthRepository, + type AuthRepository, +} from '../repository/index.js'; + +type Options = { + getCookie: (name: string) => string | undefined; + setCookie: (cookie: Cookie) => void; + setUserSession: (userSession: UserSession) => void; + isUserAuthorized: (email: string) => Promise; +}; + +export const createTestAuthContext = async (opts?: Partial) => { + const options: Options = { + getCookie: opts?.getCookie || vi.fn(), + setCookie: opts?.setCookie || vi.fn(), + setUserSession: opts?.setUserSession || vi.fn(), + isUserAuthorized: opts?.isUserAuthorized || vi.fn(async () => true), + }; + const dbContext = await createInMemoryDatabaseContext(); + const authRepo = createAuthRepository(dbContext); + return new TestAuthContext( + authRepo, + new LoginGov({ + loginGovUrl: 'https://idp.int.identitysandbox.gov', + clientId: + 'urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:tts-10x-atj-dev-server-doj', + //clientSecret: 'super-secret', + redirectURI: 'http://www.10x.gov/a2j/signin/callback', + }), + options.getCookie, + options.setCookie, + options.setUserSession, + options.isUserAuthorized + ); +}; + +export class TestAuthContext implements AuthServiceContext { + private lucia?: Lucia; + + constructor( + public db: AuthRepository, + public provider: LoginGov, + public getCookie: (name: string) => string | undefined, + public setCookie: (cookie: Cookie) => void, + public setUserSession: (userSession: UserSession) => void, + public isUserAuthorized: (email: string) => Promise + ) {} + + async getLucia() { + const sqlite3 = await (this.db.getContext() as any).getSqlite3(); + const sqlite3Adapter = createSqliteLuciaAdapter(sqlite3); + if (!this.lucia) { + this.lucia = new Lucia(sqlite3Adapter, { + sessionCookie: { + attributes: { + secure: false, + }, + }, + getUserAttributes: attributes => { + return { + email: attributes.email, + }; + }, + }); + } + return this.lucia; + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 00000000..158bef72 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,20 @@ +import { type User, type Session } from 'lucia'; + +export { BaseAuthContext } from './context/base.js'; +import { type LoginGovOptions, LoginGov } from './provider.js'; +export { type LoginGovOptions, LoginGov }; +export { + type AuthRepository, + createAuthRepository, +} from './repository/index.js'; +export { getProviderRedirect } from './services/get-provider-redirect.js'; +export { logOut } from './services/logout.js'; +export { processProviderCallback } from './services/process-provider-callback.js'; +export { processSessionCookie } from './services/process-session-cookie.js'; +export { User, Session }; +export { type AuthServiceContext } from './services/index.js'; + +export type UserSession = { + user: User | null; + session: Session | null; +}; diff --git a/packages/auth/src/lucia.ts b/packages/auth/src/lucia.ts new file mode 100644 index 00000000..4761306d --- /dev/null +++ b/packages/auth/src/lucia.ts @@ -0,0 +1,57 @@ +import { NodePostgresAdapter } from '@lucia-auth/adapter-postgresql'; +import { BetterSqlite3Adapter } from '@lucia-auth/adapter-sqlite'; +import { type Database as Sqlite3Database } from 'better-sqlite3'; +import { Lucia } from 'lucia'; + +import { type Database } from '@atj/database'; + +export const createSqliteLuciaAdapter = (db: Sqlite3Database) => { + const adapter = new BetterSqlite3Adapter(db, { + user: 'users', + session: 'sessions', + }); + return adapter; +}; + +export const createPostgresLuciaAdapter = (pgPool: any) => { + const adapter = new NodePostgresAdapter(pgPool, { + user: 'users', + session: 'sessions', + }); + return adapter; +}; + +declare module 'lucia' { + interface Register { + Lucia: Lucia; + DatabaseUserAttributes: Omit; + } +} + +/* +export class KyselyAdapter implements Adapter { + getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + throw new Error('Method not implemented.'); + } + getUserSessions(userId: string): Promise { + throw new Error('Method not implemented.'); + } + setSession(session: DatabaseSession): Promise { + throw new Error('Method not implemented.'); + } + updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + throw new Error('Method not implemented.'); + } + deleteSession(sessionId: string): Promise { + throw new Error('Method not implemented.'); + } + deleteUserSessions(userId: string): Promise { + throw new Error('Method not implemented.'); + } + deleteExpiredSessions(): Promise { + throw new Error('Method not implemented.'); + } +} +*/ diff --git a/packages/auth/src/provider.ts b/packages/auth/src/provider.ts new file mode 100644 index 00000000..51ae383a --- /dev/null +++ b/packages/auth/src/provider.ts @@ -0,0 +1,99 @@ +import { OAuth2ProviderWithPKCE } from 'arctic'; +import { TimeSpan, createDate } from 'oslo'; +import { parseJWT } from 'oslo/jwt'; +import { OAuth2Client } from 'oslo/oauth2'; + +export type LoginGovUrl = + | 'https://idp.int.identitysandbox.gov' + | 'https://secure.login.gov'; + +const getTokenEndpoint = (url: LoginGovUrl) => + `${url}/api/openid_connect/token`; +const getAuthorizeEndpoint = (url: LoginGovUrl) => + `${url}/openid_connect/authorize?acr_values=http://idmanagement.gov/ns/assurance/ial/1`; + +export type LoginGovOptions = { + loginGovUrl: LoginGovUrl; + clientId: string; + //clientSecret: string; + redirectURI?: string; +}; + +export class LoginGov implements OAuth2ProviderWithPKCE { + private client: OAuth2Client; + //private clientSecret: string; + + constructor(opts: LoginGovOptions) { + this.client = new OAuth2Client( + opts.clientId, + getAuthorizeEndpoint(opts.loginGovUrl), + getTokenEndpoint(opts.loginGovUrl), + { + redirectURI: opts.redirectURI, + } + ); + //this.clientSecret = opts.clientSecret; + } + + public async createAuthorizationURL( + state: string, + codeVerifier: string, + options?: { + scopes?: string[]; + nonce?: string; + } + ): Promise { + const scopes = options?.scopes ?? []; + const url = await this.client.createAuthorizationURL({ + state, + codeVerifier, + codeChallengeMethod: 'S256', + // User attributes (scopes): https://developers.login.gov/attributes/ + scopes: [...scopes, 'openid', 'email'], + }); + if (options?.nonce) { + url.searchParams.set('nonce', options?.nonce); + } + return url; + } + + public async validateAuthorizationCode( + code: string, + codeVerifier: string + ): Promise { + const result = + await this.client.validateAuthorizationCode( + code, + { + authenticateWith: 'request_body', + //credentials: this.clientSecret, + codeVerifier, + } + ); + + const tokens: LoginGovTokens = { + accessToken: result.access_token, + refreshToken: result.refresh_token ?? null, + accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, 's')), + idToken: result.id_token, + decodedToken: parseJWT(result.id_token)!.payload, + }; + + return tokens; + } +} + +interface AuthorizationCodeResponseBody { + access_token: string; + refresh_token?: string; + expires_in: number; + id_token: string; +} + +export interface LoginGovTokens { + accessToken: string; + refreshToken: string | null; + accessTokenExpiresAt: Date; + idToken: string; + decodedToken: any; +} diff --git a/packages/auth/src/repository/create-session.test.ts b/packages/auth/src/repository/create-session.test.ts new file mode 100644 index 00000000..369eac2c --- /dev/null +++ b/packages/auth/src/repository/create-session.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; +import { createUser } from './create-user.js'; +import { createSession } from './create-session.js'; + +describeDatabase('create session', () => { + it('fails with unknown userId', async ({ db }) => { + expect(() => + createSession(db.ctx, { + id: '31b72aca-116e-412d-b9b8-467300a53748', + expiresAt: new Date(), + sessionToken: 'token', + userId: 'user-id', + }) + ).rejects.toThrow(); + }); + + it('works with existing user', async ({ db }) => { + const user = await createUser(db.ctx, 'user@test.gov'); + if (user === null) { + expect.fail('User was not created'); + } + const sessionId = await createSession(db.ctx, { + id: '31b72aca-116e-412d-b9b8-467300a53748', + expiresAt: new Date(), + sessionToken: 'token', + userId: user.id, + }); + if (sessionId === null) { + expect.fail('Session was not created'); + } + + const kysely = await db.ctx.getKysely(); + const insertedSession = await kysely + .selectFrom('sessions') + .select(['id']) + .where('id', '=', sessionId) + .executeTakeFirst(); + expect(insertedSession?.id).toEqual(sessionId); + }); +}); diff --git a/packages/auth/src/repository/create-session.ts b/packages/auth/src/repository/create-session.ts new file mode 100644 index 00000000..752c2a0a --- /dev/null +++ b/packages/auth/src/repository/create-session.ts @@ -0,0 +1,28 @@ +import { type DatabaseContext, dateValue } from '@atj/database'; + +type Session = { + id: string; + expiresAt: Date; + sessionToken: string; + userId: string; +}; + +export const createSession = async (ctx: DatabaseContext, session: Session) => { + const db = await ctx.getKysely(); + const result = await db.transaction().execute(async trx => { + return await trx + .insertInto('sessions') + .values({ + id: session.id, + expires_at: dateValue(ctx.engine, session.expiresAt), + session_token: session.sessionToken, + user_id: session.userId, + //...session.attributes, + }) + .execute(); + }); + if (result.length === 0) { + return null; + } + return session.id; +}; diff --git a/packages/auth/src/repository/create-user.test.ts b/packages/auth/src/repository/create-user.test.ts new file mode 100644 index 00000000..3b429a42 --- /dev/null +++ b/packages/auth/src/repository/create-user.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; + +import { createUser } from './create-user.js'; + +describeDatabase('create user', () => { + it('works with unknown email address', async ({ db }) => { + const resultUser = await createUser(db.ctx, 'new-user@email.com'); + if (resultUser === null) { + expect.fail('User was not created'); + } + + const kysely = await db.ctx.getKysely(); + const insertedUser = await kysely + .selectFrom('users') + .select(['email', 'id']) + .where('id', '=', resultUser.id) + .executeTakeFirst(); + + expect(resultUser.id).not.to.be.null; + expect(insertedUser?.id).toEqual(resultUser.id); + }); + + it('fails with known email address', async ({ db }) => { + const existingUserResult = await createUser(db.ctx, 'new-user@email.com'); + if (existingUserResult === null) { + expect.fail('User was not created'); + } + const resultUser = await createUser(db.ctx, 'new-user@email.com'); + expect(resultUser).toBeNull(); + + // Check that there is only one row in the users table + const kysely = await db.ctx.getKysely(); + const insertedUser = await kysely + .selectFrom('users') + .select(kysely.fn.count('id').as('count')) + .executeTakeFirst(); + expect(Number(insertedUser?.count)).toEqual(1); + }); +}); diff --git a/packages/auth/src/repository/create-user.ts b/packages/auth/src/repository/create-user.ts new file mode 100644 index 00000000..8f618538 --- /dev/null +++ b/packages/auth/src/repository/create-user.ts @@ -0,0 +1,26 @@ +import { randomUUID } from 'crypto'; + +import { type DatabaseContext } from '@atj/database'; + +export const createUser = async (ctx: DatabaseContext, email: string) => { + const id = randomUUID(); + + const db = await ctx.getKysely(); + const result = await db + .insertInto('users') + .values({ + id, + email, + }) + .onConflict(oc => oc.doNothing()) + .executeTakeFirst(); + + if (!result.numInsertedOrUpdatedRows) { + return null; + } + + return { + id, + email, + }; +}; diff --git a/packages/auth/src/repository/get-user-id.test.ts b/packages/auth/src/repository/get-user-id.test.ts new file mode 100644 index 00000000..03840cc0 --- /dev/null +++ b/packages/auth/src/repository/get-user-id.test.ts @@ -0,0 +1,30 @@ +import { randomUUID } from 'crypto'; +import { expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; + +import { getUserId } from './get-user-id.js'; + +describeDatabase('get user id', () => { + it('returns null for non-existent user', async ({ db }) => { + const userId = await getUserId(db.ctx, 'new-user@email.com'); + expect(userId).to.be.null; + }); + + it('returns null for non-existent user', async ({ db }) => { + const id = randomUUID(); + + const kysely = await db.ctx.getKysely(); + await kysely + .insertInto('users') + .values({ + id, + email: 'user@agency.gov', + }) + .executeTakeFirst(); + + const userId = await getUserId(db.ctx, 'user@agency.gov'); + expect(userId).not.to.be.null; + expect(userId).to.be.equal(id); + }); +}); diff --git a/packages/auth/src/repository/get-user-id.ts b/packages/auth/src/repository/get-user-id.ts new file mode 100644 index 00000000..4d4af6f3 --- /dev/null +++ b/packages/auth/src/repository/get-user-id.ts @@ -0,0 +1,18 @@ +import { type DatabaseContext } from '@atj/database'; + +export const getUserId = async (ctx: DatabaseContext, email: string) => { + const db = await ctx.getKysely(); + const user = await db.transaction().execute(trx => { + return trx + .selectFrom('users') + .select('id') + .where('email', '=', email) + .executeTakeFirst(); + }); + + if (!user) { + return null; + } + + return user.id; +}; diff --git a/packages/auth/src/repository/index.ts b/packages/auth/src/repository/index.ts new file mode 100644 index 00000000..84b6c323 --- /dev/null +++ b/packages/auth/src/repository/index.ts @@ -0,0 +1,15 @@ +import { createService } from '@atj/common'; +import { type DatabaseContext } from '@atj/database'; + +import { createSession } from './create-session.js'; +import { createUser } from './create-user.js'; +import { getUserId } from './get-user-id.js'; + +export const createAuthRepository = (ctx: DatabaseContext) => + createService(ctx, { + createSession, + createUser, + getUserId, + }); + +export type AuthRepository = ReturnType; diff --git a/packages/auth/src/services/get-provider-redirect.test.ts b/packages/auth/src/services/get-provider-redirect.test.ts new file mode 100644 index 00000000..63e6ee41 --- /dev/null +++ b/packages/auth/src/services/get-provider-redirect.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { createTestAuthContext } from '../context/test.js'; + +import { getProviderRedirect } from './get-provider-redirect.js'; + +describe('getProviderRedirect database gateway', () => { + it('returns cookies and redirect url', async () => { + const ctx = await createTestAuthContext(); + const result = await getProviderRedirect(ctx); + expect(Object.fromEntries(result.url.searchParams)).toEqual( + expect.objectContaining({ + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + response_type: 'code', + client_id: + 'urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:tts-10x-atj-dev-server-doj', + state: expect.any(String), + scope: 'openid email', + redirect_uri: 'http://www.10x.gov/a2j/signin/callback', + code_challenge: expect.any(String), + code_challenge_method: 'S256', + nonce: expect.any(String), + }) + ); + expect(result.cookies).toEqual([ + expect.objectContaining({ + name: 'oauth_state', + sameSite: 'lax', + value: expect.any(String), + }), + expect.objectContaining({ + name: 'code_verifier', + sameSite: expect.any(Boolean), + value: expect.any(String), + }), + expect.objectContaining({ + name: 'nonce_code', + sameSite: 'lax', + value: expect.any(String), + }), + ]); + }); +}); diff --git a/packages/auth/src/services/get-provider-redirect.ts b/packages/auth/src/services/get-provider-redirect.ts new file mode 100644 index 00000000..3988664d --- /dev/null +++ b/packages/auth/src/services/get-provider-redirect.ts @@ -0,0 +1,31 @@ +import { generateCodeVerifier, generateState } from 'arctic'; +import { type AuthServiceContext } from './index.js'; + +export const getProviderRedirect = async (ctx: AuthServiceContext) => { + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const nonceCode = generateCodeVerifier(); + const url = await ctx.provider.createAuthorizationURL(state, codeVerifier, { + nonce: nonceCode, + }); + return { + cookies: [ + { + name: 'oauth_state', + value: state, + sameSite: 'lax' as const, + }, + { + name: 'code_verifier', + value: codeVerifier, + sameSite: false as const, + }, + { + name: 'nonce_code', + value: nonceCode, + sameSite: 'lax' as const, + }, + ], + url, + }; +}; diff --git a/packages/auth/src/services/index.ts b/packages/auth/src/services/index.ts new file mode 100644 index 00000000..d791ab9e --- /dev/null +++ b/packages/auth/src/services/index.ts @@ -0,0 +1,36 @@ +import { Cookie, Lucia } from 'lucia'; + +import { createService } from '@atj/common'; + +import { + type UserSession, + getProviderRedirect, + logOut, + processProviderCallback, + processSessionCookie, +} from '../index.js'; +import { LoginGov } from '../provider.js'; +import { type AuthRepository } from '../repository/index.js'; + +export type AuthServiceContext = { + db: AuthRepository; + provider: LoginGov; + getCookie: (name: string) => string | undefined; + setCookie: (cookie: Cookie) => void; + setUserSession: (userSession: UserSession) => void; + getLucia: () => Promise; + isUserAuthorized: (email: string) => Promise; +}; + +export const createAuthService = (ctx: AuthServiceContext) => + createService(ctx, { + getProviderRedirect, + logOut, + processProviderCallback, + processSessionCookie, + }); + +export type AuthService = Omit< + ReturnType, + 'getContext' +>; diff --git a/packages/auth/src/services/logout.test.ts b/packages/auth/src/services/logout.test.ts new file mode 100644 index 00000000..a33efc12 --- /dev/null +++ b/packages/auth/src/services/logout.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTestAuthContext } from '../context/test.js'; +import { logOut } from './logout.js'; + +describe('logOut database gateway', () => { + it('works', async () => { + const ctx = await createTestAuthContext(); + vi.setSystemTime(new Date(2024, 1, 1)); + const result = await logOut(ctx, { + expiresAt: new Date(2024, 1, 2), + fresh: true, + id: 'session-id', + userId: 'user-id', + }); + expect(result).toEqual({ + attributes: { + httpOnly: true, + maxAge: 0, + path: '/', + sameSite: 'lax', + secure: false, + }, + name: 'auth_session', + value: '', + }); + }); +}); diff --git a/packages/auth/src/services/logout.ts b/packages/auth/src/services/logout.ts new file mode 100644 index 00000000..00179c70 --- /dev/null +++ b/packages/auth/src/services/logout.ts @@ -0,0 +1,9 @@ +import { type Session } from 'lucia'; +import { type AuthServiceContext } from './index.js'; + +export const logOut = async (ctx: AuthServiceContext, session: Session) => { + const lucia = await ctx.getLucia(); + await lucia.invalidateSession(session.id); + const sessionCookie = lucia.createBlankSessionCookie(); + return sessionCookie; +}; diff --git a/packages/auth/src/services/process-provider-callback.test.ts b/packages/auth/src/services/process-provider-callback.test.ts new file mode 100644 index 00000000..9a777472 --- /dev/null +++ b/packages/auth/src/services/process-provider-callback.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { processProviderCallback } from './process-provider-callback.js'; +import { createTestAuthContext } from '../context/test.js'; +import { type AuthServiceContext } from './index.js'; + +describe('processProviderCallback', () => { + let ctx: AuthServiceContext; + + beforeEach(async () => { + // Set up global mocks + //fetchMock.resetMocks(); + + // Create test auth context with a test user in the db + ctx = await createTestAuthContext(); + const user = await ctx.db.createUser('fake-user@gsa.com'); + if (!user) { + expect.fail('error creating test user'); + } + + // Mock the response from login.gov's `userinfo` endpoint. + fetchMock.doMock(async request => { + if (request.url.endsWith('/api/openid_connect/token')) { + return JSON.stringify({ + access_token: 'x1lKd1e3CIrsSN_rnu85SQ', + refresh_token: null, + id_token: + 'eyJraWQiOiJmNWNlMTIzOWUzOWQzZGE4MzZmOTYzYmNjZDg1Zjg1ZDU3ZDQzMzVjZmRjNmExNzAzOWYyNzQzNjFhMThiMTNjIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiI5YmY3MzRjNC01NGE0LTQ0MDYtYjJmMS00ZjBjNDZjMmE0YTYiLCJpc3MiOiJodHRwczovL2lkcC5pbnQuaWRlbnRpdHlzYW5kYm94Lmdvdi8iLCJlbWFpbCI6ImRhbmllbC5uYWFiQGdzYS5nb3YiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWFsIjoiaHR0cDovL2lkbWFuYWdlbWVudC5nb3YvbnMvYXNzdXJhbmNlL2lhbC8xIiwiYWFsIjoidXJuOmdvdjpnc2E6YWM6Y2xhc3NlczpzcDpQYXNzd29yZFByb3RlY3RlZFRyYW5zcG9ydDpkdW8iLCJub25jZSI6InQwajVBWTVrNG9MQWN4ZmFaZFJzTWZWWkdCQ2dCamxmaWhnb1ZxMzRZR28iLCJhdWQiOiJ1cm46Z292OmdzYTpvcGVuaWRjb25uZWN0LnByb2ZpbGVzOnNwOnNzbzpnc2E6dHRzLTEweC1hdGotZGV2LXNlcnZlci1kb2oiLCJqdGkiOiIyeEpaYlBsMmYxQlpxZmhhUG5aUlhBIiwiYXRfaGFzaCI6Im91U3JIdWhFQTdYX25UZ0VIeUlrM3ciLCJjX2hhc2giOiJWOUNDb1l1TElhTFd3VUZRelZwNS1RIiwiYWNyIjoiaHR0cDovL2lkbWFuYWdlbWVudC5nb3YvbnMvYXNzdXJhbmNlL2lhbC8xIiwiZXhwIjoxNzIyNTcxNTY4LCJpYXQiOjE3MjI1NzA2NjgsIm5iZiI6MTcyMjU3MDY2OH0.Aa8zNA5VyPAOR5hHObiO1c1n4Y2Tu43FF4ec4sgz2GEuHmr-N6q4OSg1icB7v7dX0Ekd2CrjieXx4p9qOE0UxNcEK6bXL0hpfmeu5qn3g6I435hyw-XNFw5QF7MCZD7tjwYSva6IVVmTsjPCELekcK1n_CzGXe31FiRVgyxyw9nttkymsAh48FxWzla2_PLcA4bwuSEJLwx_-YIYbvgEfVkqd1vcaK2QWr1grlIYFpsyobFFd8duBVco9UdJVuH_aBNjF92zZRG0CKLFnxF6AXP7iE6JCm0z8ppnA2__r3l-O9KPkOYe73D-K2U-kL_-aBpWPL1eioNTxG7Ah8ZDSg', + }); + } else if (request.url.endsWith('/api/openid_connect/userinfo')) { + return JSON.stringify({ + sub: 'ignored', + iss: 'ignored', + email: 'fake-user@gsa.gov', + email_verified: true, + ial: 'ignored', + aal: 'ignored', + }); + } else if (request.url.includes('openid_connect/authorize')) { + throw new Error('authorize endpoint: todo'); + } + throw new Error(`unexpected url: ${request.url}`); + }); + }); + + it('works with matching verification codes', async () => { + const ctx = await createTestAuthContext(); + const result = await processProviderCallback( + ctx, + { + code: 'params-code', + state: 'params-state', + }, + { + code: 'params-code', + state: 'params-state', + nonce: 't0j5AY5k4oLAcxfaZdRsMfVZGBCgBjlfihgoVq34YGo', + } + ); + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: { + email: 'fake-user@gsa.gov', + sessionCookie: { + name: 'auth_session', + value: expect.any(String), + attributes: { + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/', + maxAge: 2592000, + }, + }, + }, + }) + ); + }); + + it('fails with non-matching verification codes', async () => { + const ctx = await createTestAuthContext(); + vi.setSystemTime(new Date(2024, 1, 1)); + const result = await processProviderCallback( + ctx, + { + code: 'params-code', + state: 'params-state', + }, + { + code: 'cookie-stored-code', + state: 'cookie-stored-state', + nonce: '123456789012345678901234567890', + } + ); + expect(result).toEqual({ + success: false, + error: { + message: 'bad request', + status: 400, + }, + }); + }); + + it('fails with unauthorized email', async () => { + const ctx = await createTestAuthContext({ + isUserAuthorized: async () => false, + }); + const result = await processProviderCallback( + ctx, + { + code: 'params-code', + state: 'params-state', + }, + { + code: 'params-code', + state: 'params-state', + nonce: 't0j5AY5k4oLAcxfaZdRsMfVZGBCgBjlfihgoVq34YGo', + } + ); + expect(result).toEqual({ + error: { + message: 'permission denied', + status: 403, + }, + success: false, + }); + }); +}); diff --git a/packages/auth/src/services/process-provider-callback.ts b/packages/auth/src/services/process-provider-callback.ts new file mode 100644 index 00000000..4d5ac9b2 --- /dev/null +++ b/packages/auth/src/services/process-provider-callback.ts @@ -0,0 +1,115 @@ +import { OAuth2RequestError } from 'arctic'; +import { randomUUID } from 'crypto'; + +import * as r from '@atj/common'; +import { type AuthServiceContext } from './index.js'; + +type LoginGovUser = { + sub: string; + iss: string; + email: string; + email_verified: boolean; + ial: string; + aal: string; +}; + +type Params = { + code?: string | null; + state?: string | null; +}; + +export const processProviderCallback = async ( + ctx: AuthServiceContext, + params: Params, + storedParams: Params & { nonce: string | null }, + fetchUserData: typeof fetchUserDataImpl = fetchUserDataImpl +) => { + if ( + !params.code || + !storedParams.state || + !storedParams.code || + params.state !== storedParams.state + ) { + return r.failure({ status: 400, message: 'bad request' }); + } + + const validateResult = await ctx.provider + .validateAuthorizationCode(params.code, storedParams.code) + .then(result => { + return r.success(result); + }) + .catch(error => { + console.error(error, error.stack); + if ( + error instanceof OAuth2RequestError && + error.message === 'bad_verification_code' + ) { + return r.failure({ status: 400, message: 'bad verification code' }); + } + return r.failure({ + status: 500, + message: `unexpected error: ${error.message}`, + }); + }); + + if (validateResult.success === false) { + return validateResult; + } + + if (validateResult.data.decodedToken.nonce !== storedParams.nonce) { + return r.failure({ + status: 403, + message: 'nonce mismatch', + }); + } + + const userDataResult = await fetchUserData(validateResult.data.accessToken); + if (!userDataResult.success) { + return userDataResult; + } + const isAuthorized = await ctx.isUserAuthorized(userDataResult.data.email); + if (!isAuthorized) { + return r.failure({ status: 403, message: 'permission denied' }); + } + let userId = await ctx.db.getUserId(userDataResult.data.email); + if (!userId) { + const newUser = await ctx.db.createUser(userDataResult.data.email); + if (!newUser) { + return r.failure({ status: 500, message: 'error creating new user' }); + } + userId = newUser.id; + } + const lucia = await ctx.getLucia(); + const session = await lucia.createSession(userId, { + session_token: randomUUID(), + }); + const sessionCookie = lucia.createSessionCookie(session.id); + + return r.success({ + email: userDataResult.data.email, + sessionCookie, + }); +}; + +const fetchUserDataImpl = (accessToken: string) => + fetch('https://idp.int.identitysandbox.gov/api/openid_connect/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(response => response.json()) + .then((userData: LoginGovUser) => { + if (userData.email_verified === false) { + return r.failure({ + status: 403, + message: 'email address not verified', + }); + } + return r.success(userData); + }) + .catch(error => + r.failure({ + status: 500, + message: `error fetching user data: ${error.message}`, + }) + ); diff --git a/packages/auth/src/services/process-session-cookie.test.ts b/packages/auth/src/services/process-session-cookie.test.ts new file mode 100644 index 00000000..3865760c --- /dev/null +++ b/packages/auth/src/services/process-session-cookie.test.ts @@ -0,0 +1,133 @@ +import { randomUUID } from 'crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createTestAuthContext } from '../context/test.js'; +import { processSessionCookie } from './process-session-cookie.js'; + +describe('processSessionCookie', () => { + const today = new Date(2020, 1, 1); + const tenYearsAgo = new Date(2010, 1, 1); + + beforeEach(async () => { + vi.setSystemTime(today); + }); + + it('sets null user session with unset session cookie', async () => { + const mocks = { + getCookie: vi.fn(() => undefined), + setUserSession: vi.fn(), + }; + const ctx = await createTestAuthContext(mocks); + + const result = await processSessionCookie( + ctx, + new Request('http://localhost', { + headers: { Origin: 'http://localhost', Host: 'http://www.google.com' }, + }) + ); + + expect(result.success).toEqual(true); + expect(mocks.setUserSession).toHaveBeenCalledWith({ + session: null, + user: null, + }); + }); + + it('resets session cookie and sets user session with fresh session cookie', async () => { + const { ctx, mocks, sessionId, user } = await setUpTest(today); + + const result = await processSessionCookie( + ctx, + new Request('http://localhost', { + headers: { Origin: 'http://localhost', Host: 'http://www.google.com' }, + }) + ); + + expect(result.success).toEqual(true); + expect(mocks.setCookie).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + httpOnly: true, + maxAge: 2592000, + path: '/', + sameSite: 'lax', + secure: false, + }, + name: 'auth_session', + value: sessionId, + }) + ); + expect(mocks.setUserSession).toHaveBeenCalledWith( + expect.objectContaining({ + session: { + expiresAt: expect.any(Date), + fresh: true, + id: sessionId, + userId: user.id, + }, + user: { + email: 'user@test.gov', + id: user.id, + }, + }) + ); + }); + + it('clears cookies with stale session cookie', async () => { + const { ctx, mocks } = await setUpTest(tenYearsAgo); + + const result = await processSessionCookie( + ctx, + new Request('http://localhost', { + headers: { Origin: 'http://localhost', Host: 'http://www.google.com' }, + }) + ); + + expect(result.success).toEqual(true); + expect(mocks.setCookie).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + httpOnly: true, + maxAge: 0, + path: '/', + sameSite: 'lax', + secure: false, + }, + name: 'auth_session', + value: '', + }) + ); + expect(mocks.setUserSession).toHaveBeenCalledWith( + expect.objectContaining({ + session: null, + user: null, + }) + ); + }); +}); + +const addOneDay = (date: Date): Date => { + const newDate = new Date(date); + newDate.setDate(newDate.getDate() + 1); + return newDate; +}; + +const setUpTest = async (sessionExpirationDate: Date) => { + const mocks = { + getCookie: vi.fn(() => sessionId || ''), + setCookie: vi.fn(), + setUserSession: vi.fn(), + }; + const ctx = await createTestAuthContext(mocks); + const user = await ctx.db.createUser('user@test.gov'); + if (!user) { + expect.fail('error creating test user'); + } + const sessionId = await ctx.db.createSession({ + id: randomUUID(), + expiresAt: addOneDay(sessionExpirationDate), + sessionToken: 'my-token', + userId: user.id, + }); + return { ctx, mocks, sessionId, user }; +}; diff --git a/packages/auth/src/services/process-session-cookie.ts b/packages/auth/src/services/process-session-cookie.ts new file mode 100644 index 00000000..401a7122 --- /dev/null +++ b/packages/auth/src/services/process-session-cookie.ts @@ -0,0 +1,50 @@ +import { verifyRequestOrigin } from 'lucia'; + +import { type VoidResult } from '@atj/common'; + +import { type AuthServiceContext } from './index.js'; + +export const processSessionCookie = async ( + ctx: AuthServiceContext, + request: Request +): Promise> => { + if (request.method !== 'GET') { + const originHeader = request.headers.get('Origin'); + const hostHeader = request.headers.get('Host'); + if ( + !originHeader || + !hostHeader || + !verifyRequestOrigin(originHeader, [hostHeader]) + ) { + return { + success: false, + error: { + status: 403, + }, + }; + } + } + const lucia = await ctx.getLucia(); + + const sessionId = ctx.getCookie(lucia.sessionCookieName); + if (!sessionId) { + ctx.setUserSession({ user: null, session: null }); + return { + success: true, + }; + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + ctx.setCookie(sessionCookie); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + ctx.setCookie(sessionCookie); + } + ctx.setUserSession({ user, session }); + return { + success: true, + }; +}; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 00000000..0efd82a1 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "emitDeclarationOnly": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src", "global.d.ts"], + "exclude": ["./dist"], + "references": [] +} diff --git a/packages/auth/vitest.config.ts b/packages/auth/vitest.config.ts new file mode 100644 index 00000000..68cf95c0 --- /dev/null +++ b/packages/auth/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import { getVitestDatabaseContainerGlobalSetupPath } from '@atj/database'; +import sharedTestConfig from '../../vitest.shared'; + +export default mergeConfig( + sharedTestConfig, + defineConfig({ + test: { + globalSetup: [getVitestDatabaseContainerGlobalSetupPath()], + setupFiles: ['./vitest.setup.ts'], + }, + }) +); diff --git a/packages/auth/vitest.setup.ts b/packages/auth/vitest.setup.ts new file mode 100644 index 00000000..5c4c5e0f --- /dev/null +++ b/packages/auth/vitest.setup.ts @@ -0,0 +1,7 @@ +import createFetchMock from 'vitest-fetch-mock'; +import { vi } from 'vitest'; + +const fetchMocker = createFetchMock(vi); + +// sets globalThis.fetch and globalThis.fetchMock to our mocked version +fetchMocker.enableMocks(); diff --git a/packages/common/package.json b/packages/common/package.json index 0c846ae7..dafd39e5 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -4,10 +4,12 @@ "description": "10x ATJ shared resources", "type": "module", "license": "CC0", - "main": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "build": "tsup src/* --env.NODE_ENV production", - "dev": "tsup src/* --watch" + "build": "tsc", + "clean": "rimraf dist tsconfig.tsbuildinfo coverage", + "dev": "tsc --watch" }, "dependencies": {} } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index ad6f4016..45ae1556 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,3 +1,5 @@ +export { createService } from './service.js'; + export type Success = { success: true; data: T }; export type VoidSuccess = { success: true }; export type Failure = { success: false; error: E }; @@ -6,3 +8,5 @@ export type VoidResult = VoidSuccess | Failure; export const success = (data: T): Success => ({ success: true, data }); export const failure = (error: E): Failure => ({ success: false, error }); + +export { en as enLocale } from './locales/en/app.js'; diff --git a/packages/common/src/service.ts b/packages/common/src/service.ts new file mode 100644 index 00000000..c3ff8bc0 --- /dev/null +++ b/packages/common/src/service.ts @@ -0,0 +1,53 @@ +/** + * Exports `createService`, which creates a service object from a context and a + * set of service functions. + * Each service function takes a context as its first argument, with subsequent + * arguments being the function's parameters. + */ +type ServiceFunction = ( + context: Context, + ...args: Args +) => Return; + +type ServiceFunctions = { + [key: string]: ServiceFunction; +}; + +type WithoutFirstArg = F extends (context: any, ...args: infer P) => infer R + ? (...args: P) => R + : never; + +type Service< + Context extends any, + Functions extends ServiceFunctions, +> = { + [K in keyof Functions]: WithoutFirstArg; +} & { getContext: () => Context }; + +export const createService = < + Context extends any, + Functions extends ServiceFunctions, +>( + ctx: Context, + serviceFunctions: Functions +): Service => { + const handler: ProxyHandler = { + get(target: Functions, prop: string | symbol) { + if (prop === 'getContext') { + return () => ctx; + } + const propKey = prop as keyof Functions; + const originalFn = target[propKey]; + if (originalFn === undefined) { + return undefined; + } + return (...args: any[]) => + (originalFn as Function).call(null, ctx, ...args); + }, + }; + + return new Proxy(serviceFunctions, handler) as unknown as Service< + Context, + Functions + >; +}; diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index a7c17353..7e87852a 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -1,8 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "emitDeclarationOnly": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "./dist", - "emitDeclarationOnly": true + "rootDir": "./src" }, "include": ["./src"], "references": [] diff --git a/packages/database/.gitignore b/packages/database/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/packages/database/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/database/build.js b/packages/database/build.js new file mode 100644 index 00000000..35fcfb42 --- /dev/null +++ b/packages/database/build.js @@ -0,0 +1,15 @@ +import esbuild from 'esbuild'; + +esbuild + .build({ + bundle: false, + entryPoints: ['./src/index.ts'], + packages: 'external', + format: 'esm', + minify: true, + outdir: './dist', + platform: 'node', + sourcemap: true, + target: 'es2020', + }) + .catch(() => process.exit(1)); diff --git a/packages/database/knexfile.mjs b/packages/database/knexfile.mjs new file mode 100644 index 00000000..a17822f7 --- /dev/null +++ b/packages/database/knexfile.mjs @@ -0,0 +1,55 @@ +const migrationsDirectory = path.resolve(__dirname, './migrations'); + +/** + * @type { Object. } + */ +export default { + test: { + client: 'better-sqlite3', + connection: { + filename: ':memory:', + //filename: './main.db', + }, + useNullAsDefault: true, + migrations: { + directory: migrationsDirectory, + loadExtensions: ['.mjs'], + }, + }, + development: { + client: 'better-sqlite3', + connection: { + filename: './dev.sqlite3', + }, + }, + staging: { + client: 'postgresql', + connection: { + database: 'my_db', + user: 'username', + password: 'password', + }, + pool: { + min: 2, + max: 10, + }, + migrations: { + tableName: 'knex_migrations', + }, + }, + production: { + client: 'postgresql', + connection: { + database: 'my_db', + user: 'username', + password: 'password', + }, + pool: { + min: 2, + max: 10, + }, + migrations: { + tableName: 'knex_migrations', + }, + }, +}; diff --git a/packages/database/migrations/20240722180545_initial_users_session.mjs b/packages/database/migrations/20240722180545_initial_users_session.mjs new file mode 100644 index 00000000..cff7b085 --- /dev/null +++ b/packages/database/migrations/20240722180545_initial_users_session.mjs @@ -0,0 +1,35 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function up(knex) { + await knex.schema.createTable('users', table => { + //table.uuid('id').primary(); //.defaultTo(knex.raw('gen_random_uuid()')); + table.text('id').primary(); + table.string('email').notNullable().unique(); + table.timestamps(true, true); + }); + + await knex.schema.createTable('sessions', table => { + //table.uuid('id').primary(); //.defaultTo(knex.raw('gen_random_uuid()')); + table.text('id').primary(); + table + .text('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.string('session_token').notNullable().unique(); + table.datetime('expires_at').notNullable(); + table.timestamps(true, true); + }); +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex) { + await knex.schema.dropTableIfExists('sessions'); + await knex.schema.dropTableIfExists('users'); +} diff --git a/packages/database/migrations/20240820130044_forms.mjs b/packages/database/migrations/20240820130044_forms.mjs new file mode 100644 index 00000000..bda656d9 --- /dev/null +++ b/packages/database/migrations/20240820130044_forms.mjs @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function up(knex) { + await knex.schema.createTable('forms', table => { + table.uuid('id').primary(); + // We don't have an immediate need to query over the form, so we'll just + // store it as `json` rather than `jsonb` + table.text('data').notNullable(); + //table.timestamps(true, true); + }); +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex) { + await knex.schema.dropTableIfExists('forms'); +} diff --git a/packages/database/package.json b/packages/database/package.json new file mode 100644 index 00000000..30a065cf --- /dev/null +++ b/packages/database/package.json @@ -0,0 +1,47 @@ +{ + "name": "@atj/database", + "version": "1.0.0", + "description": "10x ATJ database", + "type": "module", + "license": "CC0", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "types": "./dist/types/index.d.ts" + }, + "./context": { + "import": "./dist/esm/context.js", + "require": "./dist/cjs/context.js", + "types": "./dist/types/context/index.d.ts" + }, + "./testing": { + "import": "./dist/esm/testing.js", + "require": "./dist/cjs/testing.js", + "types": "./dist/types/testing.d.ts" + } + }, + "scripts": { + "build": "rollup -c", + "clean": "rimraf dist tsconfig.tsbuildinfo coverage", + "dev": "rollup -c -w", + "test": "vitest run --coverage" + }, + "dependencies": { + "@atj/common": "workspace:*", + "@types/pg": "^8.11.6", + "better-sqlite3": "^11.1.2", + "knex": "^3.1.0", + "kysely": "^0.27.4", + "pg": "^8.12.0" + }, + "devDependencies": { + "@testcontainers/postgresql": "^10.11.0", + "@types/better-sqlite3": "^7.6.11", + "testcontainers": "^10.11.0", + "vite-tsconfig-paths": "^4.3.2" + } +} diff --git a/packages/database/rollup.config.js b/packages/database/rollup.config.js new file mode 100644 index 00000000..91515950 --- /dev/null +++ b/packages/database/rollup.config.js @@ -0,0 +1,55 @@ +import { builtinModules } from 'module'; + +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import typescript from 'rollup-plugin-typescript2'; + +import packageJson from './package.json' assert { type: 'json' }; +import workspacePackageJson from '../../package.json' assert { type: 'json' }; + +export default { + //input: ['src/index.ts', 'src/context/index.ts', 'src/testing.ts'], + input: { + index: 'src/index.ts', + context: 'src/context/index.ts', + testing: 'src/testing.ts', + }, + output: [ + { + dir: 'dist/esm', + format: 'esm', + sourcemap: true, + entryFileNames: '[name].js', + chunkFileNames: '[name]-[hash].js', + }, + { + dir: 'dist/cjs', + format: 'cjs', + sourcemap: true, + entryFileNames: '[name].js', + chunkFileNames: '[name]-[hash].js', + }, + ], + plugins: [ + nodeResolve(), + commonjs(), + json(), + typescript({ + tsconfig: './tsconfig.json', + useTsconfigDeclarationDir: true, + }), + ], + external: (() => { + // Externalize all the things + return [ + ...new Set([ + ...Object.keys(packageJson.dependencies || {}), + ...Object.keys(packageJson.devDependencies || {}), + ...Object.keys(workspacePackageJson.dependencies || {}), + ...Object.keys(workspacePackageJson.devDependencies || {}), + ...builtinModules, + ]), + ]; + })(), +}; diff --git a/packages/database/src/clients/knex.ts b/packages/database/src/clients/knex.ts new file mode 100644 index 00000000..8423ead0 --- /dev/null +++ b/packages/database/src/clients/knex.ts @@ -0,0 +1,57 @@ +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import knex, { type Knex } from 'knex'; + +const migrationsDirectory = path.resolve( + dirname(fileURLToPath(import.meta.url)), + '../../migrations' +); + +export const createKnex = (config: Knex.Config): Knex => knex(config); + +export const getPostgresKnex = ( + connectionString: string, + ssl: boolean = false +): Knex => { + return knex({ + client: 'pg', + connection: { + connectionString, + ssl: ssl ? { rejectUnauthorized: false } : false, + }, + useNullAsDefault: true, + migrations: { + directory: migrationsDirectory, + loadExtensions: ['.mjs'], + }, + }); +}; + +export const getInMemoryKnex = (): Knex => { + return knex({ + client: 'better-sqlite3', + connection: { + filename: ':memory:', + }, + useNullAsDefault: true, + migrations: { + directory: migrationsDirectory, + loadExtensions: ['.mjs'], + }, + }); +}; + +export const getFileSystemKnex = (path: string): Knex => { + return knex({ + client: 'better-sqlite3', + connection: { + filename: path, + }, + useNullAsDefault: true, + migrations: { + directory: migrationsDirectory, + loadExtensions: ['.mjs'], + }, + }); +}; diff --git a/packages/database/src/clients/kysely/db-helpers.ts b/packages/database/src/clients/kysely/db-helpers.ts new file mode 100644 index 00000000..8fb9869c --- /dev/null +++ b/packages/database/src/clients/kysely/db-helpers.ts @@ -0,0 +1,43 @@ +import { type Engine } from './types.js'; + +/** + * With Postgres, use TIMESTAMP, which Kysely will accept a Date object for. + * With SQLite, use INTEGER, which Kysely will accept a number for. + */ +export const dateValue = (engine: E, date: Date) => + dbValue(engine, date, { + postgres: date => date, + sqlite: date => Math.floor(date.getTime() / 1000), + }); +export type DbDate = ReturnType>; + +/** + * Helper function to map native Typescript values/types to database + * values/types. + */ +const dbValue = < + T, + E extends Engine, + PostgresReturnValue, + SqliteReturnValue, + ReturnValue = E extends 'postgres' + ? PostgresReturnValue + : E extends 'sqlite' + ? SqliteReturnValue + : never, +>( + engine: E, + value: T, + transformers: { + postgres: (val: T) => PostgresReturnValue; + sqlite: (val: T) => SqliteReturnValue; + } +): ReturnValue => { + if (engine === 'postgres') { + return transformers.postgres(value) as unknown as ReturnValue; + } else if (engine === 'sqlite') { + return transformers.sqlite(value) as unknown as ReturnValue; + } else { + throw new Error(`Unsupported engine: ${engine}`); + } +}; diff --git a/packages/database/src/clients/kysely/postgres.ts b/packages/database/src/clients/kysely/postgres.ts new file mode 100644 index 00000000..c6bcc597 --- /dev/null +++ b/packages/database/src/clients/kysely/postgres.ts @@ -0,0 +1,18 @@ +import { Kysely, PostgresDialect } from 'kysely'; +import pg from 'pg'; + +import { type Database } from './types.js'; + +export const createPostgresDatabase = ( + connectionString: string, + ssl: boolean +) => { + return new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ + connectionString, + ssl: ssl ? { rejectUnauthorized: false } : false, + }), + }), + }); +}; diff --git a/packages/database/src/clients/kysely/sqlite3.ts b/packages/database/src/clients/kysely/sqlite3.ts new file mode 100644 index 00000000..88e70077 --- /dev/null +++ b/packages/database/src/clients/kysely/sqlite3.ts @@ -0,0 +1,31 @@ +import { Kysely, SqliteDialect } from 'kysely'; +import BetterSqliteDatabase, { + type Database as SqliteDatabase, +} from 'better-sqlite3'; + +import { type Database } from './types.js'; + +type TestDatabase = { + kysely: Kysely; + sqlite: SqliteDatabase; +}; + +export const createInMemoryDatabase = (): TestDatabase => { + const database = new BetterSqliteDatabase(':memory:'); + return { + kysely: new Kysely({ + dialect: new SqliteDialect({ + database, + }), + }), + sqlite: database, + }; +}; + +export const createSqliteDatabase = (database: SqliteDatabase) => { + return new Kysely({ + dialect: new SqliteDialect({ + database, + }), + }); +}; diff --git a/packages/database/src/clients/kysely/types.ts b/packages/database/src/clients/kysely/types.ts new file mode 100644 index 00000000..4496d21a --- /dev/null +++ b/packages/database/src/clients/kysely/types.ts @@ -0,0 +1,50 @@ +import type { + Generated, + Insertable, + Kysely, + Selectable, + Updateable, +} from 'kysely'; +import { type DbDate } from './db-helpers.js'; + +export type Engine = 'sqlite' | 'postgres'; + +export interface Database { + users: UsersTable; + sessions: SessionsTable; + forms: FormsTable; +} + +interface UsersTable { + id: string; + email: string; + created_at: Generated; + updated_at: Generated; +} +export type UsersSelectable = Selectable; +export type UsersInsertable = Insertable; +export type UsersUpdateable = Updateable; + +interface SessionsTable { + id: string; + user_id: string; + session_token: string; + expires_at: DbDate; + created_at: Generated; + updated_at: Generated; +} +export type SessionsSelectable = Selectable>; +export type SessionsInsertable = Insertable>; +export type SessionsUpdateable = Updateable>; + +interface FormsTable { + id: string; + data: string; // Blueprint; + created_at: Generated; + updated_at: Generated; +} +export type FormsTableSelectable = Selectable; +export type FormsTableInsertable = Insertable; +export type FormsTableUpdateable = Updateable; + +export type DatabaseClient = Kysely; diff --git a/packages/database/src/clients/test-containers.ts b/packages/database/src/clients/test-containers.ts new file mode 100644 index 00000000..971a9291 --- /dev/null +++ b/packages/database/src/clients/test-containers.ts @@ -0,0 +1,96 @@ +import { + PostgreSqlContainer, + StartedPostgreSqlContainer, +} from '@testcontainers/postgresql'; +import pg from 'pg'; + +export type ConnectionDetails = { + host: string; + port: number; + username: string; + password: string; + database: string; +}; + +declare global { + var postgresTestContainer: Promise; +} + +/** + * Setup PostgreSQL test container. Intended to be exported from a Vitest + * globalSetup module. + * @param param0.provide Vitest provide function + * @returns cleanup function + */ +export const setupPostgresContainer = async ({ + provide, +}: { + provide: (name: string, value: any) => void; +}) => { + // Guard against running multiple PostgreSQL containers using a node.js global + if (global.postgresTestContainer === undefined) { + process.stdout.write('Starting PostgreSQL test container...'); + global.postgresTestContainer = new PostgreSqlContainer().start(); + } else { + process.stdout.write( + 'Using already initialized PostgreSQL test container...' + ); + } + + const container = await global.postgresTestContainer; + process.stdout.write('... Done!\n'); + + const connectionDetails: ConnectionDetails = { + host: container.getHost(), + port: container.getMappedPort(5432), + username: container.getUsername(), + password: container.getPassword(), + database: container.getDatabase(), + }; + + provide('postgresConnectionDetails', connectionDetails); + + return async () => { + process.stdout.write('Stopping PostgreSQL test container...'); + await container.stop(); + process.stdout.write('... Done!\n'); + }; +}; + +export const getConnectionString = (connectionDetails: ConnectionDetails) => { + return `postgresql://${connectionDetails.username}:${connectionDetails.password}@${connectionDetails.host}:${connectionDetails.port}/${connectionDetails.database}`; +}; + +export const createTestDatabase = async ( + connectionDetails: ConnectionDetails +) => { + const databaseName = `testdb_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`; + const connectionString = getConnectionString(connectionDetails); + + const client = new pg.Client({ + connectionString, + }); + await client.connect(); + await client.query(`CREATE DATABASE ${databaseName};`); + await client.end(); + + return { + connectionUri: getConnectionString({ + ...connectionDetails, + database: databaseName, + }), + databaseName, + }; +}; + +export const deleteTestDatabase = async ( + connectionDetails: ConnectionDetails, + databaseName: string +) => { + const client = new pg.Client({ + connectionString: getConnectionString(connectionDetails), + }); + await client.connect(); + await client.query(`DROP DATABASE IF EXISTS ${databaseName};`); + await client.end(); +}; diff --git a/packages/database/src/context/file-system.ts b/packages/database/src/context/file-system.ts new file mode 100644 index 00000000..8d7833dc --- /dev/null +++ b/packages/database/src/context/file-system.ts @@ -0,0 +1,78 @@ +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import { type Database as SqliteDatabase } from 'better-sqlite3'; +import knex, { type Knex } from 'knex'; +import { type Kysely } from 'kysely'; + +import { type Database } from '../clients/kysely/types.js'; +import { createSqliteDatabase } from '../clients/kysely/sqlite3.js'; +import { migrateDatabase } from '../management/migrate-database.js'; + +import { type DatabaseContext } from './types.js'; + +const migrationsDirectory = path.resolve( + dirname(fileURLToPath(import.meta.url)), + '../../migrations' +); + +export class FilesystemDatabaseContext implements DatabaseContext { + public readonly engine = 'sqlite'; + knex?: Knex; + kysely?: Kysely; + sqlite3?: SqliteDatabase; + + constructor(private path: string) {} + + async getKnex() { + if (!this.knex) { + this.knex = knex({ + client: 'better-sqlite3', + connection: { + filename: this.path, + }, + pool: { + min: 1, + max: 20, + }, + useNullAsDefault: true, + migrations: { + directory: migrationsDirectory, + loadExtensions: ['.mjs'], + }, + }); + } + return this.knex; + } + + async getSqlite3(): Promise { + const knex = await this.getKnex(); + if (!this.sqlite3) { + this.sqlite3 = (await knex.client.acquireConnection()) as SqliteDatabase; + } + return this.sqlite3; + } + + async getKysely() { + if (!this.kysely) { + const sqlite3 = await this.getSqlite3(); + this.kysely = createSqliteDatabase(sqlite3); + } + return this.kysely; + } + + async destroy() { + if (this.kysely) { + await this.kysely.destroy(); + } + if (this.knex) { + await this.knex.destroy(); + } + } +} + +export const createFilesystemDatabaseContext = async (path: string) => { + const ctx = new FilesystemDatabaseContext(path); + await migrateDatabase(ctx); + return ctx; +}; diff --git a/packages/database/src/context/in-memory.ts b/packages/database/src/context/in-memory.ts new file mode 100644 index 00000000..62c85f88 --- /dev/null +++ b/packages/database/src/context/in-memory.ts @@ -0,0 +1,60 @@ +import { type Database as SqliteDatabase } from 'better-sqlite3'; +import { type Knex } from 'knex'; +import { type Kysely } from 'kysely'; + +import { getInMemoryKnex } from '../clients/knex.js'; +import { createSqliteDatabase } from '../clients/kysely/sqlite3.js'; +import { type Database } from '../clients/kysely/types.js'; +import { migrateDatabase } from '../management/migrate-database.js'; + +import { type DatabaseContext } from './types.js'; + +export class InMemoryDatabaseContext implements DatabaseContext { + public readonly engine = 'sqlite'; + knex?: Knex; + kysely?: Kysely; + sqlite3?: SqliteDatabase; + + constructor() {} + + async getKnex() { + if (!this.knex) { + this.knex = getInMemoryKnex(); + } + return this.knex; + } + + async getSqlite3(): Promise { + const knex = await this.getKnex(); + if (!this.sqlite3) { + this.sqlite3 = (await knex.client.acquireConnection()) as SqliteDatabase; + } + return this.sqlite3; + } + + async getKysely() { + if (!this.kysely) { + const sqlite3 = await this.getSqlite3(); + this.kysely = createSqliteDatabase(sqlite3); + } + return this.kysely; + } + + async destroy() { + if (this.knex && this.sqlite3) { + this.knex.client.releaseConnection(this.sqlite3); + } + if (this.knex) { + await this.knex.destroy(); + } + if (this.kysely) { + await this.kysely.destroy(); + } + } +} + +export const createInMemoryDatabaseContext = async () => { + const ctx = new InMemoryDatabaseContext(); + await migrateDatabase(ctx); + return ctx; +}; diff --git a/packages/database/src/context/index.ts b/packages/database/src/context/index.ts new file mode 100644 index 00000000..77f5111a --- /dev/null +++ b/packages/database/src/context/index.ts @@ -0,0 +1,6 @@ +export { + type FilesystemDatabaseContext, + createFilesystemDatabaseContext, +} from './file-system.js'; +export { createInMemoryDatabaseContext } from './in-memory.js'; +export { createPostgresDatabaseContext } from './postgres.js'; diff --git a/packages/database/src/context/postgres.ts b/packages/database/src/context/postgres.ts new file mode 100644 index 00000000..c6658a4b --- /dev/null +++ b/packages/database/src/context/postgres.ts @@ -0,0 +1,62 @@ +import { type Knex } from 'knex'; +import { type Kysely } from 'kysely'; + +import { getPostgresKnex } from '../clients/knex.js'; +import { type Database } from '../clients/kysely/types.js'; +import { createPostgresDatabase } from '../clients/kysely/postgres.js'; +import { migrateDatabase } from '../management/migrate-database.js'; + +import { type DatabaseContext } from './types.js'; +import { Pool } from 'pg'; + +export class PostgresDatabaseContext implements DatabaseContext { + public readonly engine = 'postgres'; + knex?: Knex; + kysely?: Kysely; + pool?: Pool; + + constructor( + public readonly connectionUri: string, + private ssl: boolean = false + ) {} + + async getKnex() { + if (!this.knex) { + this.knex = getPostgresKnex(this.connectionUri, this.ssl); + } + return this.knex; + } + + async getPostgresPool(): Promise { + const knex = await this.getKnex(); + if (!this.pool) { + this.pool = (await knex.client.acquireConnection()) as Pool; + } + return this.pool; + } + + async getKysely(): Promise> { + if (!this.kysely) { + this.kysely = createPostgresDatabase(this.connectionUri, this.ssl); + } + return this.kysely; + } + + async destroy() { + if (this.kysely) { + await this.kysely.destroy(); + } + if (this.knex) { + await this.knex.destroy(); + } + } +} + +export const createPostgresDatabaseContext = async ( + connectionUri: string, + ssl: boolean +) => { + const ctx = new PostgresDatabaseContext(connectionUri, ssl); + await migrateDatabase(ctx); + return ctx; +}; diff --git a/packages/database/src/context/types.ts b/packages/database/src/context/types.ts new file mode 100644 index 00000000..0a8e26fb --- /dev/null +++ b/packages/database/src/context/types.ts @@ -0,0 +1,11 @@ +import { Knex } from 'knex'; +import { Kysely } from 'kysely'; + +import { type Database, type Engine } from '../clients/kysely/types.js'; + +export interface DatabaseContext { + readonly engine: Engine; + getKnex: () => Promise; + getKysely: () => Promise>; + destroy: () => Promise; +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts new file mode 100644 index 00000000..a3a716ba --- /dev/null +++ b/packages/database/src/index.ts @@ -0,0 +1,11 @@ +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +export { dateValue } from './clients/kysely/db-helpers.js'; +export { type Database } from './clients/kysely/types.js'; +export { type DatabaseContext } from './context/types.js'; +export { migrateDatabase } from './management/migrate-database.js'; + +export const getVitestDatabaseContainerGlobalSetupPath = () => { + return join(dirname(fileURLToPath(import.meta.url)), '../../vitest.setup.ts'); +}; diff --git a/packages/database/src/management/migrate-database.test.ts b/packages/database/src/management/migrate-database.test.ts new file mode 100644 index 00000000..843b170d --- /dev/null +++ b/packages/database/src/management/migrate-database.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '../testing.js'; +import { migrateDatabase } from './migrate-database.js'; + +describeDatabase( + 'Knex migrations', + () => { + it('migrate and rollback successfully', async ({ db }) => { + const rollback = await migrateDatabase(db.ctx); + const knex = await db.ctx.getKnex(); + expect(await knex.schema.hasTable('users')).to.be.true; + expect(await knex.schema.hasTable('sessions')).to.be.true; + + await rollback(); + expect(await knex.schema.hasTable('users')).to.be.false; + expect(await knex.schema.hasTable('sessions')).to.be.false; + + await knex.destroy(); + }); + }, + false +); diff --git a/packages/database/src/management/migrate-database.ts b/packages/database/src/management/migrate-database.ts new file mode 100644 index 00000000..0c26637c --- /dev/null +++ b/packages/database/src/management/migrate-database.ts @@ -0,0 +1,7 @@ +import { type DatabaseContext } from '../context/types.js'; + +export const migrateDatabase = async (ctx: DatabaseContext) => { + const db = await ctx.getKnex(); + await db.migrate.latest(); + return () => db.migrate.rollback(); +}; diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts new file mode 100644 index 00000000..319fe17a --- /dev/null +++ b/packages/database/src/schema.ts @@ -0,0 +1,8 @@ +export type SessionSchema = { + id: string; + user_id: string; + session_token: string; + expires_at: number; + created_at: number; + updated_at: number; +}; diff --git a/packages/database/src/testing.ts b/packages/database/src/testing.ts new file mode 100644 index 00000000..8b025994 --- /dev/null +++ b/packages/database/src/testing.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, inject, SuiteFactory } from 'vitest'; + +import { type Engine } from './clients/kysely/types.js'; +import { + type ConnectionDetails, + createTestDatabase, + deleteTestDatabase, +} from './clients/test-containers.js'; +import { InMemoryDatabaseContext } from './context/in-memory.js'; +import { PostgresDatabaseContext } from './context/postgres.js'; +import { type DatabaseContext } from './context/types.js'; +import { migrateDatabase } from './management/migrate-database.js'; + +export type DbTestContext = { + db: { + engine: Engine; + ctx: DatabaseContext; + }; +}; + +type PostgresDbTestContext = DbTestContext & { + db: DbTestContext['db'] & { + connectionDetails: ConnectionDetails; + databaseName: string; + }; +}; + +export const describeDatabase = ( + name: string, + fn: (...args: Parameters) => ReturnType, + runMigrations: boolean = true +) => { + describe(`PostgreSQL - ${name}`, { timeout: 60000 }, test => { + beforeEach(async context => { + const connectionDetails = inject('postgresConnectionDetails'); + if (!connectionDetails) { + throw new Error('Connection details not found'); + } + + const { connectionUri, databaseName } = + await createTestDatabase(connectionDetails); + const ctx = new PostgresDatabaseContext(connectionUri); + if (runMigrations) { + await migrateDatabase(ctx); + } + context.db = { + engine: 'postgres', + connectionDetails, + ctx, + databaseName, + }; + }); + + afterEach(async ({ db }) => { + await db.ctx.destroy(); + const { connectionDetails, databaseName } = db; + + deleteTestDatabase(connectionDetails, databaseName); + }); + + fn(test); + }); + + describe(`SQLite - ${name}`, test => { + beforeEach(async context => { + const ctx = new InMemoryDatabaseContext(); + if (runMigrations) { + await migrateDatabase(ctx); + } + context.db = { + engine: 'sqlite', + ctx, + }; + }); + + afterEach(async ({ db }) => { + db.ctx.destroy(); + }); + + fn(test); + }); +}; diff --git a/packages/database/tsconfig.json b/packages/database/tsconfig.json new file mode 100644 index 00000000..855dc453 --- /dev/null +++ b/packages/database/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationDir": "./dist/types", + "emitDeclarationOnly": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["vite-env.d.ts", "src"], + "exclude": ["./dist"], + "references": [], + "types": ["vite/client", "node", "vitest-env.d.ts"] +} diff --git a/packages/database/vite-env.d.ts b/packages/database/vite-env.d.ts new file mode 100644 index 00000000..707c4a2e --- /dev/null +++ b/packages/database/vite-env.d.ts @@ -0,0 +1,10 @@ +import { + type ConnectionDetails, + type globalSetupKey, +} from './src/clients/test-containers'; + +declare module 'vitest' { + export interface ProvidedContext { + postgresConnectionDetails: ConnectionDetails; + } +} diff --git a/packages/database/vitest.config.ts b/packages/database/vitest.config.ts new file mode 100644 index 00000000..3e17cd74 --- /dev/null +++ b/packages/database/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +import sharedTestConfig from '../../vitest.shared'; + +export default defineConfig({ + ...sharedTestConfig, + test: { + ...sharedTestConfig.test, + globalSetup: ['./vitest.setup.ts'], + }, +}); diff --git a/packages/database/vitest.setup.ts b/packages/database/vitest.setup.ts new file mode 100644 index 00000000..84fccc81 --- /dev/null +++ b/packages/database/vitest.setup.ts @@ -0,0 +1,3 @@ +import { setupPostgresContainer } from './src/clients/test-containers'; + +export default setupPostgresContainer; diff --git a/packages/dependency-graph/package.json b/packages/dependency-graph/package.json index 70c715f6..11fa3bd5 100644 --- a/packages/dependency-graph/package.json +++ b/packages/dependency-graph/package.json @@ -2,19 +2,22 @@ "name": "@atj/dependency-graph", "version": "1.0.0", "description": "generates a dependency graph of projects in a pnpm workspace", - "type": "commonjs", + "type": "module", "license": "CC0", - "main": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.js", "scripts": { - "build": "tsup src/* --env.NODE_ENV production", + "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.0.0", + "@pnpm/logger": "^5.2.0", "graphviz": "^0.0.9" }, "devDependencies": { - "@types/graphviz": "^0.0.37" + "@types/graphviz": "^0.0.39" } } diff --git a/packages/dependency-graph/src/graph-dependencies.ts b/packages/dependency-graph/src/graph-dependencies.ts index 98afbd06..2b08f484 100644 --- a/packages/dependency-graph/src/graph-dependencies.ts +++ b/packages/dependency-graph/src/graph-dependencies.ts @@ -1,8 +1,9 @@ import * as graphviz from 'graphviz'; -import { DependencyMap } from './get-dependencies'; +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); diff --git a/packages/dependency-graph/src/index.ts b/packages/dependency-graph/src/index.ts index 3a7093c7..e66efff3 100644 --- a/packages/dependency-graph/src/index.ts +++ b/packages/dependency-graph/src/index.ts @@ -1,5 +1,5 @@ -import { getWorkspaceDependencies } from './get-dependencies'; -import { writeDependencyGraph } from './graph-dependencies'; +import { getWorkspaceDependencies } from './get-dependencies.js'; +import { writeDependencyGraph } from './graph-dependencies.js'; export const createDependencyGraph = async ( workspaceRoot: string, diff --git a/packages/dependency-graph/tsconfig.json b/packages/dependency-graph/tsconfig.json index a7c17353..a374c527 100644 --- a/packages/dependency-graph/tsconfig.json +++ b/packages/dependency-graph/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "emitDeclarationOnly": true + "emitDeclarationOnly": false, + "rootDir": "./src" }, "include": ["./src"], "references": [] diff --git a/packages/design/.storybook/main.ts b/packages/design/.storybook/main.ts index 8a89b70f..38b60665 100644 --- a/packages/design/.storybook/main.ts +++ b/packages/design/.storybook/main.ts @@ -19,13 +19,16 @@ const config: StorybookConfig = { getAbsolutePath('@storybook/addon-a11y'), getAbsolutePath('@storybook/addon-coverage'), ], - framework: { - name: getAbsolutePath('@storybook/react-vite') as '@storybook/react-vite', - options: {}, + core: { + disableTelemetry: true, }, docs: { autodocs: 'tag', }, + framework: { + name: getAbsolutePath('@storybook/react-vite') as '@storybook/react-vite', + options: {}, + }, staticDirs: ['../static'], }; export default config; diff --git a/packages/design/package.json b/packages/design/package.json index 860a4c4b..0abb06c4 100644 --- a/packages/design/package.json +++ b/packages/design/package.json @@ -8,9 +8,9 @@ "build:lib": "vite build", "build:storybook": "storybook build", "build:styles": "gulp update", - "clean": "run-p: clean:*", - "clean:lib": "rm -rf dist", - "clean:styles": "rm -rf static", + "clean": "pnpm clean:lib && pnpm clean:styles", + "clean:lib": "rimraf dist", + "clean:styles": "rimraf static", "dev": "run-p dev:*", "dev:lib": "vite", "dev:storybook": "storybook dev -p 9009", @@ -23,39 +23,39 @@ "dist/**/*" ], "devDependencies": { - "@playwright/test": "^1.43.1", - "@storybook/addon-a11y": "^7.6.10", - "@storybook/addon-coverage": "^1.0.0", - "@storybook/addon-essentials": "^7.6.10", - "@storybook/addon-interactions": "^7.6.10", - "@storybook/addon-links": "^7.6.10", - "@storybook/blocks": "^7.6.10", - "@storybook/preview-api": "^7.6.10", - "@storybook/react": "^7.6.10", - "@storybook/react-vite": "^7.6.10", - "@storybook/test": "^7.6.10", - "@storybook/test-runner": "^0.16.0", - "@storybook/types": "^7.6.10", - "@testing-library/react": "^15.0.7", + "@playwright/test": "^1.46.0", + "@storybook/addon-a11y": "^8.2.8", + "@storybook/addon-coverage": "^1.0.4", + "@storybook/addon-essentials": "^8.2.8", + "@storybook/addon-interactions": "^8.2.8", + "@storybook/addon-links": "^8.2.8", + "@storybook/blocks": "^8.2.8", + "@storybook/preview-api": "^8.2.8", + "@storybook/react": "^8.2.8", + "@storybook/react-vite": "^8.2.8", + "@storybook/test": "^8.2.8", + "@storybook/test-runner": "^0.17.0", + "@storybook/types": "^8.2.8", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^16.0.0", "@types/deep-equal": "^1.0.4", "@types/prop-types": "^15.7.12", - "@types/react": "^18.2.79", - "@typescript-eslint/eslint-plugin": "^7.7.0", - "@typescript-eslint/parser": "^7.7.0", + "@types/react": "^18.3.3", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "@uswds/compile": "1.1.0", - "@vitejs/plugin-react": "^4.3.0", + "@vitejs/plugin-react": "^4.3.1", "concurrently": "^8.2.2", - "eslint": "^8.56.0", - "eslint-plugin-react": "^7.34.1", - "glob": "^10.3.12", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.35.0", "gulp": "^5.0.0", "http-server": "^14.1.1", "install": "^0.13.0", - "jsdom": "^24.1.0", + "jsdom": "^24.1.1", "prop-types": "^15.8.1", - "react-dom": "^18.2.0", - "vite": "^5.2.11", - "vite-plugin-dts": "^3.9.1", + "react-dom": "^18.3.1", + "vite": "^5.4.0", + "vite-plugin-dts": "^4.0.1", "wait-on": "^7.2.0" }, "dependencies": { @@ -64,14 +64,14 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", - "@uswds/uswds": "^3.8.0", + "@uswds/uswds": "^3.8.1", "classnames": "^2.5.1", "deep-equal": "^2.2.3", - "react": "^18.2.0", - "react-hook-form": "^7.51.3", - "react-router-dom": "^6.22.3", - "storybook": "^7.6.10", - "zustand": "^4.5.2", + "react": "^18.3.1", + "react-hook-form": "^7.52.2", + "react-router-dom": "^6.26.0", + "storybook": "^8.2.8", + "zustand": "^4.5.4", "zustand-utils": "^1.3.2" } } diff --git a/packages/design/sass/_uswds-custom-styles.scss b/packages/design/sass/_uswds-custom-styles.scss index 7f9588e1..c7fcbc64 100644 --- a/packages/design/sass/_uswds-custom-styles.scss +++ b/packages/design/sass/_uswds-custom-styles.scss @@ -62,6 +62,12 @@ main { } } } + .draggable-list-item-wrapper { + background-color: color('primary-lighter'); + .usa-sidenav__item { + border-top: none; + } + } } .usa-section { diff --git a/packages/design/src/AvailableFormList/AvailableFormList.stories.tsx b/packages/design/src/AvailableFormList/AvailableFormList.stories.tsx index ff3326b2..dab6662d 100644 --- a/packages/design/src/AvailableFormList/AvailableFormList.stories.tsx +++ b/packages/design/src/AvailableFormList/AvailableFormList.stories.tsx @@ -1,18 +1,18 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; -import AvailableFormList from '.'; -import { service } from '@atj/forms'; -import { createForm, nullSession } from '@atj/forms'; -import { MemoryRouter } from 'react-router-dom'; -import { FormManagerProvider } from '../FormManager/store'; -import { createTestFormManagerContext } from '../test-form'; -import React from 'react'; +import { type FormService, createForm, nullSession } from '@atj/forms'; +import { createTestBrowserFormService } from '@atj/forms/context'; +import { FormManagerProvider } from '../FormManager/store.js'; +import { createTestFormManagerContext } from '../test-form.js'; +import AvailableFormList from './index.js'; const meta: Meta = { title: 'FormManager/AvailableFormList', component: AvailableFormList, args: { - formService: service.createTestFormService({ + formService: createTestBrowserFormService({ 'form-1': createForm({ title: 'Form 1', description: 'Use this form to...', @@ -46,7 +46,7 @@ export const Empty = { title: 'Empty form list', component: AvailableFormList, args: { - formService: service.createTestFormService({}), + formService: createTestBrowserFormService({}) as FormService, urlForForm: () => `#`, urlForFormManager: () => `#`, }, diff --git a/packages/design/src/AvailableFormList/AvailableFormList.test.tsx b/packages/design/src/AvailableFormList/AvailableFormList.test.tsx index 25ec1095..d6e20e47 100644 --- a/packages/design/src/AvailableFormList/AvailableFormList.test.tsx +++ b/packages/design/src/AvailableFormList/AvailableFormList.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../test-helper'; -import meta, * as stories from './AvailableFormList.stories'; +import { describeStories } from '../test-helper.js'; +import meta, * as stories from './AvailableFormList.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/AvailableFormList/index.tsx b/packages/design/src/AvailableFormList/index.tsx index 9bb46ffb..cc646a8c 100644 --- a/packages/design/src/AvailableFormList/index.tsx +++ b/packages/design/src/AvailableFormList/index.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { service } from '@atj/forms'; -import * as AppRoutes from '../FormManager/routes'; import { Link } from 'react-router-dom'; +import { type FormService } from '@atj/forms'; + +import * as AppRoutes from '../FormManager/routes.js'; + type FormDetails = { id: string; title: string; @@ -16,16 +18,17 @@ export default function AvailableFormList({ urlForForm, urlForFormManager, }: { - formService: service.FormService; + formService: FormService; urlForForm: UrlForForm; urlForFormManager: UrlForFormManager; }) { const [forms, setForms] = useState([]); useEffect(() => { - const result = formService.getFormList(); - if (result.success) { - setForms(result.data); - } + formService.getFormList().then(result => { + if (result.success) { + setForms(result.data); + } + }); }, []); return ( <> diff --git a/packages/design/src/Form/ActionBar/ActionBar.stories.tsx b/packages/design/src/Form/ActionBar/ActionBar.stories.tsx index c1da7e44..2432d892 100644 --- a/packages/design/src/Form/ActionBar/ActionBar.stories.tsx +++ b/packages/design/src/Form/ActionBar/ActionBar.stories.tsx @@ -1,6 +1,6 @@ import type { Meta } from '@storybook/react'; -import ActionBar from '.'; +import ActionBar from './index.js'; export default { title: 'FormManager/ActionBar', diff --git a/packages/design/src/Form/ActionBar/ActionBar.test.ts b/packages/design/src/Form/ActionBar/ActionBar.test.ts index f24112b0..988efc20 100644 --- a/packages/design/src/Form/ActionBar/ActionBar.test.ts +++ b/packages/design/src/Form/ActionBar/ActionBar.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../test-helper'; -import meta, * as stories from './ActionBar.stories'; +import { describeStories } from '../../test-helper.js'; +import meta, * as stories from './ActionBar.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/Form/Form.stories.tsx b/packages/design/src/Form/Form.stories.tsx index eb922d4f..5233f9fe 100644 --- a/packages/design/src/Form/Form.stories.tsx +++ b/packages/design/src/Form/Form.stories.tsx @@ -1,8 +1,8 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import Form from '.'; -import { createTestFormContext, createTestSession } from '../test-form'; +import Form from './index.js'; +import { createTestFormContext, createTestSession } from '../test-form.js'; import { MemoryRouter } from 'react-router-dom'; const meta: Meta = { diff --git a/packages/design/src/Form/Form.test.tsx b/packages/design/src/Form/Form.test.tsx index 86e0c821..34b9e356 100644 --- a/packages/design/src/Form/Form.test.tsx +++ b/packages/design/src/Form/Form.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../test-helper'; -import meta, * as stories from './Form.stories'; +import { describeStories } from '../test-helper.js'; +import meta, * as stories from './Form.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/Form/components/Address/index.tsx b/packages/design/src/Form/components/Address/index.tsx index 1da1b47c..e05df83f 100644 --- a/packages/design/src/Form/components/Address/index.tsx +++ b/packages/design/src/Form/components/Address/index.tsx @@ -2,9 +2,9 @@ import classNames from 'classnames'; import React from 'react'; import { useFormContext } from 'react-hook-form'; -import { AddressComponentProps } from '@atj/forms/src/patterns/address'; +import { type AddressComponentProps } from '@atj/forms'; -import { type PatternComponent } from '../..'; +import { type PatternComponent } from '../../index.js'; const Address: PatternComponent = props => { const { register } = useFormContext(); diff --git a/packages/design/src/Form/components/Checkbox/Checkbox.stories.tsx b/packages/design/src/Form/components/Checkbox/Checkbox.stories.tsx index 04655759..40953130 100644 --- a/packages/design/src/Form/components/Checkbox/Checkbox.stories.tsx +++ b/packages/design/src/Form/components/Checkbox/Checkbox.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { type Meta, type StoryObj } from '@storybook/react'; -import { CheckboxPattern } from './Checkbox'; +import { CheckboxPattern } from './Checkbox.js'; import { CheckboxProps } from '@atj/forms'; const meta: Meta = { diff --git a/packages/design/src/Form/components/Checkbox/Checkbox.test.tsx b/packages/design/src/Form/components/Checkbox/Checkbox.test.tsx index 31494615..028c92ed 100644 --- a/packages/design/src/Form/components/Checkbox/Checkbox.test.tsx +++ b/packages/design/src/Form/components/Checkbox/Checkbox.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../../test-helper'; -import meta, * as stories from './Checkbox.stories'; +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './Checkbox.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/Form/components/Checkbox/Checkbox.tsx b/packages/design/src/Form/components/Checkbox/Checkbox.tsx index 07d14077..ae2f1004 100644 --- a/packages/design/src/Form/components/Checkbox/Checkbox.tsx +++ b/packages/design/src/Form/components/Checkbox/Checkbox.tsx @@ -3,7 +3,7 @@ import { useFormContext } from 'react-hook-form'; import { type CheckboxProps } from '@atj/forms'; -import { type PatternComponent } from '../../../Form'; +import { type PatternComponent } from '../../../Form/index.js'; export const CheckboxPattern: PatternComponent = props => { const { register } = useFormContext(); diff --git a/packages/design/src/Form/components/Checkbox/index.tsx b/packages/design/src/Form/components/Checkbox/index.tsx index 26aa0abb..8cb1c614 100644 --- a/packages/design/src/Form/components/Checkbox/index.tsx +++ b/packages/design/src/Form/components/Checkbox/index.tsx @@ -1,2 +1,2 @@ -import { CheckboxPattern } from './Checkbox'; +import { CheckboxPattern } from './Checkbox.js'; export default CheckboxPattern; diff --git a/packages/design/src/Form/components/Fieldset/Fieldset.stories.tsx b/packages/design/src/Form/components/Fieldset/Fieldset.stories.tsx index 6a5cd8be..55f3f558 100644 --- a/packages/design/src/Form/components/Fieldset/Fieldset.stories.tsx +++ b/packages/design/src/Form/components/Fieldset/Fieldset.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; -import Fieldset from '.'; +import Fieldset from './index.js'; export default { title: 'patterns/Fieldset', diff --git a/packages/design/src/Form/components/Fieldset/Fieldset.test.tsx b/packages/design/src/Form/components/Fieldset/Fieldset.test.tsx index e7e82414..e5cf4067 100644 --- a/packages/design/src/Form/components/Fieldset/Fieldset.test.tsx +++ b/packages/design/src/Form/components/Fieldset/Fieldset.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../../test-helper'; -import meta, * as stories from './Fieldset.stories'; +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './Fieldset.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/Form/components/Fieldset/index.tsx b/packages/design/src/Form/components/Fieldset/index.tsx index 54e925f7..d1f4f431 100644 --- a/packages/design/src/Form/components/Fieldset/index.tsx +++ b/packages/design/src/Form/components/Fieldset/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { type FieldsetProps } from '@atj/forms'; -import { type PatternComponent } from '../../../Form'; +import { type PatternComponent } from '../../../Form/index.js'; const Fieldset: PatternComponent = props => { return ( diff --git a/packages/design/src/Form/components/FormSummary/FormSummary.stories.tsx b/packages/design/src/Form/components/FormSummary/FormSummary.stories.tsx index d667ba54..c0887d1a 100644 --- a/packages/design/src/Form/components/FormSummary/FormSummary.stories.tsx +++ b/packages/design/src/Form/components/FormSummary/FormSummary.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; -import FormSummary from '.'; +import FormSummary from './index.js'; export default { title: 'patterns/FormSummary', diff --git a/packages/design/src/Form/components/FormSummary/FormSummary.test.ts b/packages/design/src/Form/components/FormSummary/FormSummary.test.ts index 118a0ec6..9efa9802 100644 --- a/packages/design/src/Form/components/FormSummary/FormSummary.test.ts +++ b/packages/design/src/Form/components/FormSummary/FormSummary.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../../test-helper'; -import meta, * as stories from './FormSummary.stories'; +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './FormSummary.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/Form/components/FormSummary/index.tsx b/packages/design/src/Form/components/FormSummary/index.tsx index 32186b5e..d9a009f6 100644 --- a/packages/design/src/Form/components/FormSummary/index.tsx +++ b/packages/design/src/Form/components/FormSummary/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { type FormSummaryProps } from '@atj/forms'; -import { type PatternComponent } from '../../../Form'; +import { type PatternComponent } from '../../../Form/index.js'; const FormSummary: PatternComponent = props => { return ( diff --git a/packages/design/src/Form/components/Page/index.tsx b/packages/design/src/Form/components/Page/index.tsx index 7ae23ba5..bdb16004 100644 --- a/packages/design/src/Form/components/Page/index.tsx +++ b/packages/design/src/Form/components/Page/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { type PageProps } from '@atj/forms'; -import { type PatternComponent } from '../..'; +import { type PatternComponent } from '../../index.js'; const Page: PatternComponent = props => { return <>{props.children}; diff --git a/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.stories.tsx b/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.stories.tsx index 53b33341..c02560f5 100644 --- a/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.stories.tsx +++ b/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { PageMenu } from './PageMenu'; +import { PageMenu } from './PageMenu.js'; const meta: Meta = { title: 'patterns/PageSet/PageMenu', diff --git a/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.test.tsx b/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.test.tsx index f7a53a61..d3064785 100644 --- a/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.test.tsx +++ b/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../../../test-helper'; -import meta, * as stories from './PageMenu.stories'; +import { describeStories } from '../../../../test-helper.js'; +import meta, * as stories from './PageMenu.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.tsx b/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.tsx index ffa2d399..b054e33a 100644 --- a/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.tsx +++ b/packages/design/src/Form/components/PageSet/PageMenu/PageMenu.tsx @@ -17,13 +17,9 @@ export const PageMenu = ({ pages }: PageMenuProps) => { {pages.map((page, index) => (
  • {page.title} diff --git a/packages/design/src/Form/components/PageSet/PageMenu/index.ts b/packages/design/src/Form/components/PageSet/PageMenu/index.ts index 97d1cfed..76c88e50 100644 --- a/packages/design/src/Form/components/PageSet/PageMenu/index.ts +++ b/packages/design/src/Form/components/PageSet/PageMenu/index.ts @@ -1 +1 @@ -export { PageMenu } from './PageMenu'; +export { PageMenu } from './PageMenu.js'; diff --git a/packages/design/src/Form/components/PageSet/PageSet.stories.tsx b/packages/design/src/Form/components/PageSet/PageSet.stories.tsx index c9f00228..a9ca1c17 100644 --- a/packages/design/src/Form/components/PageSet/PageSet.stories.tsx +++ b/packages/design/src/Form/components/PageSet/PageSet.stories.tsx @@ -4,14 +4,14 @@ import type { Meta, StoryObj } from '@storybook/react'; import { type PageSetProps } from '@atj/forms'; -import { FormManagerProvider } from '../../../FormManager/store'; +import { FormManagerProvider } from '../../../FormManager/store.js'; import { createTestFormManagerContext, createTestSession, createTwoPatternTestForm, -} from '../../../test-form'; +} from '../../../test-form.js'; -import PageSet from './PageSet'; +import PageSet from './PageSet.js'; const meta: Meta = { title: 'patterns/PageSet', diff --git a/packages/design/src/Form/components/PageSet/PageSet.test.tsx b/packages/design/src/Form/components/PageSet/PageSet.test.tsx index f6e5d8f1..a528eecf 100644 --- a/packages/design/src/Form/components/PageSet/PageSet.test.tsx +++ b/packages/design/src/Form/components/PageSet/PageSet.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../../test-helper'; -import meta, * as stories from './PageSet.stories'; +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './PageSet.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/Form/components/PageSet/PageSet.tsx b/packages/design/src/Form/components/PageSet/PageSet.tsx index ba7ac18c..205a7f48 100644 --- a/packages/design/src/Form/components/PageSet/PageSet.tsx +++ b/packages/design/src/Form/components/PageSet/PageSet.tsx @@ -2,17 +2,17 @@ import React from 'react'; import { type PageSetProps } from '@atj/forms'; -import { type PatternComponent } from '../..'; -import ActionBar from '../../../Form/ActionBar'; -import { useRouteParams } from '../../../FormRouter/hooks'; +import { type PatternComponent } from '../../index.js'; +import ActionBar from '../../../Form/ActionBar/index.js'; +import { useRouteParams } from '../../../FormRouter/hooks.js'; -import { PageMenu } from './PageMenu'; +import { PageMenu } from './PageMenu/index.js'; const PageSet: PatternComponent = props => { const { routeParams, pathname } = useRouteParams(); return (
    -
  • ); }; diff --git a/packages/design/src/FormManager/FormEdit/components/PreviewSequencePattern/index.tsx b/packages/design/src/FormManager/FormEdit/components/PreviewSequencePattern/index.tsx index 6dfe7144..4e6610f4 100644 --- a/packages/design/src/FormManager/FormEdit/components/PreviewSequencePattern/index.tsx +++ b/packages/design/src/FormManager/FormEdit/components/PreviewSequencePattern/index.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { type SequenceProps, getPattern } from '@atj/forms'; -import { DraggableList } from './DraggableList'; -import { useFormManagerStore } from '../../../store'; -import { PatternEditComponent } from '../../types'; +import { DraggableList } from './DraggableList.js'; +import { useFormManagerStore } from '../../../store.js'; +import { PatternEditComponent } from '../../types.js'; // TODO: consider merging this component with DraggableList, to clean up // sematics around how its children are handled. diff --git a/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.stories.tsx b/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.stories.tsx index 7c4b2ddf..6dd1a81d 100644 --- a/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.stories.tsx +++ b/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.stories.tsx @@ -1,11 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { type RadioGroupPattern } from '@atj/forms/src/patterns/radio-group'; +import { type RadioGroupPattern } from '@atj/forms'; -import { createPatternEditStoryMeta } from './common/story-helper'; -import FormEdit from '..'; -import CheckboxPatternEdit from './CheckboxPatternEdit'; -import { en as message } from '@atj/common/src/locales/en/app'; +import { createPatternEditStoryMeta } from './common/story-helper.js'; +import FormEdit from '../index.js'; +import CheckboxPatternEdit from './CheckboxPatternEdit.js'; +import { enLocale as message } from '@atj/common'; import { expect, userEvent } from '@storybook/test'; import { within } from '@testing-library/react'; @@ -39,14 +39,11 @@ export const Basic: StoryObj = { canvas.getByText(message.patterns.radioGroup.displayName) ); const input = canvas.getByLabelText(message.patterns.radioGroup.fieldLabel); - const optionId = canvas.getByLabelText('Option 1 id'); const optionLabel = canvas.getByLabelText('Option 1 label'); // Enter new text for the field await userEvent.clear(input); await userEvent.type(input, updatedLabel); - await userEvent.clear(optionId); - await userEvent.type(optionId, 'yes'); await userEvent.clear(optionLabel); await userEvent.type(optionLabel, 'Yes'); @@ -59,7 +56,7 @@ export const Basic: StoryObj = { form?.requestSubmit(); await expect(await canvas.findByText(updatedLabel)).toBeInTheDocument(); - await expect(await canvas.findByText('Yes')).toBeInTheDocument(); + await expect(await canvas.findByText('Yes')).toBeVisible(); }, }; @@ -94,7 +91,6 @@ export const Error: StoryObj = { ); const input = canvas.getByLabelText(message.patterns.radioGroup.fieldLabel); - const optionId = canvas.getByLabelText('Option 1 id'); const optionLabel = canvas.getByLabelText('Option 1 label'); // Clear input, remove focus, and wait for error @@ -113,13 +109,6 @@ export const Error: StoryObj = { */ await userEvent.type(input, message.patterns.radioGroup.fieldLabel); - await userEvent.clear(optionId); - optionId.blur(); - - await expect( - await canvas.findByText('Invalid Option ID') - ).toBeInTheDocument(); - await userEvent.clear(optionLabel); optionLabel.blur(); diff --git a/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.test.tsx b/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.test.tsx index 7b30c4f5..eca7c04f 100644 --- a/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.test.tsx +++ b/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../../test-helper'; -import meta, * as stories from './RadioGroupPatternEdit.stories'; +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './RadioGroupPatternEdit.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.tsx b/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.tsx index fed9d87f..54bb5e62 100644 --- a/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/RadioGroupPatternEdit.tsx @@ -2,15 +2,15 @@ import classnames from 'classnames'; import React, { useState } from 'react'; import { type RadioGroupProps } from '@atj/forms'; -import { type RadioGroupPattern } from '@atj/forms/src/patterns/radio-group'; +import { type RadioGroupPattern } from '@atj/forms'; -import RadioGroup from '../../../Form/components/RadioGroup'; -import { PatternEditComponent } from '../types'; +import RadioGroup from '../../../Form/components/RadioGroup/index.js'; +import { PatternEditComponent } from '../types.js'; -import { PatternEditActions } from './common/PatternEditActions'; -import { PatternEditForm } from './common/PatternEditForm'; -import { usePatternEditFormContext } from './common/hooks'; -import { en as message } from '@atj/common/src/locales/en/app'; +import { PatternEditActions } from './common/PatternEditActions.js'; +import { PatternEditForm } from './common/PatternEditForm.js'; +import { usePatternEditFormContext } from './common/hooks.js'; +import { enLocale as message } from '@atj/common'; import styles from '../formEditStyles.module.css'; const RadioGroupPatternEdit: PatternEditComponent = ({ diff --git a/packages/design/src/FormManager/FormEdit/components/SubmissionConfirmationEdit.tsx b/packages/design/src/FormManager/FormEdit/components/SubmissionConfirmationEdit.tsx index e6d9fcd5..ed26c835 100644 --- a/packages/design/src/FormManager/FormEdit/components/SubmissionConfirmationEdit.tsx +++ b/packages/design/src/FormManager/FormEdit/components/SubmissionConfirmationEdit.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { type PatternId, SubmissionConfirmationProps } from '@atj/forms'; -import SubmissionConfirmation from '../../../Form/components/SubmissionConfirmation'; -import { PatternEditComponent } from '../types'; +import SubmissionConfirmation from '../../../Form/components/SubmissionConfirmation/index.js'; +import { PatternEditComponent } from '../types.js'; -import { PatternEditForm } from './common/PatternEditForm'; -import { usePatternEditFormContext } from './common/hooks'; +import { PatternEditForm } from './common/PatternEditForm.js'; +import { usePatternEditFormContext } from './common/hooks.js'; const SubmissionConfirmationEdit: PatternEditComponent< SubmissionConfirmationProps diff --git a/packages/design/src/FormManager/FormEdit/components/common/MovePatternDropdown.tsx b/packages/design/src/FormManager/FormEdit/components/common/MovePatternDropdown.tsx new file mode 100644 index 00000000..f52a6a95 --- /dev/null +++ b/packages/design/src/FormManager/FormEdit/components/common/MovePatternDropdown.tsx @@ -0,0 +1,210 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useFormManagerStore } from '../../../store.js'; +import styles from '../../formEditStyles.module.css'; + +interface MovePatternDropdownProps { + isFieldset: boolean; +} + +// Define the extended type for pages +interface PageWithLabel { + id: string; + type: string; + data: { + title: string; + patterns: string[]; + }; + specialLabel?: string; +} + +const MovePatternDropdown: React.FC = ({ + isFieldset, +}) => { + const context = useFormManagerStore(state => state.context); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [targetPage, setTargetPage] = useState(''); + const [moveToPosition, setMoveToPosition] = useState(''); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + const pages = useFormManagerStore(state => + Object.values(state.session.form.patterns).filter(p => p.type === 'page') + ); + const movePatternToPage = useFormManagerStore(state => state.movePattern); + const focusPatternId = useFormManagerStore(state => state.focus?.pattern.id); + const useAvailablePages = () => { + const currentPageIndex = pages.findIndex(page => + page.data.patterns.includes(focusPatternId || '') + ); + const page1Count = pages.reduce( + (count, page) => count + (page.data.title === 'Page 1' ? 1 : 0), + 0 + ); + const availablePages: PageWithLabel[] = + page1Count > 1 + ? pages.slice(1).map((page, index) => { + if (index + 1 === currentPageIndex) { + return { ...page, specialLabel: 'Current page' }; + } + return page; + }) + : pages.map((page, index) => { + if (index === currentPageIndex) { + return { ...page, specialLabel: 'Current page' }; + } + return page; + }); + + return availablePages; + }; + const availablePages = useAvailablePages(); + const currentPageIndex = pages.findIndex(page => + page.data.patterns.includes(focusPatternId || '') + ); + const sourcePage = pages[currentPageIndex]?.id; + const handleMovePattern = () => { + if (focusPatternId && targetPage) { + movePatternToPage(sourcePage, targetPage, focusPatternId, moveToPosition); + } + setDropdownOpen(false); + }; + const toggleDropdown = () => { + setDropdownOpen(!dropdownOpen); + }; + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setDropdownOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setDropdownOpen(false); + buttonRef.current?.focus(); + } + }; + + useEffect(() => { + if (dropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + dropdownRef.current?.addEventListener('keydown', handleKeyDown); + } else { + document.removeEventListener('mousedown', handleClickOutside); + dropdownRef.current?.removeEventListener('keydown', handleKeyDown); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + dropdownRef.current?.removeEventListener('keydown', handleKeyDown); + }; + }, [dropdownOpen]); + + return ( +
    +

    + +

    + {dropdownOpen && ( +
    +
    + + +
    +
    + + +
    +

    + +

    +
    + )} +
    + ); +}; + +export default MovePatternDropdown; diff --git a/packages/design/src/FormManager/FormEdit/components/common/PatternEditActions.tsx b/packages/design/src/FormManager/FormEdit/components/common/PatternEditActions.tsx index dcc63fe7..bf343d7c 100644 --- a/packages/design/src/FormManager/FormEdit/components/common/PatternEditActions.tsx +++ b/packages/design/src/FormManager/FormEdit/components/common/PatternEditActions.tsx @@ -1,7 +1,9 @@ -import React, { PropsWithChildren, ReactElement } from 'react'; +import React, { PropsWithChildren, ReactElement, useMemo } from 'react'; import classNames from 'classnames'; -import { useFormManagerStore } from '../../../store'; +import { useFormManagerStore } from '../../../store.js'; +import MovePatternDropdown from './MovePatternDropdown.js'; +import styles from '../../formEditStyles.module.css'; type PatternEditActionsProps = PropsWithChildren<{ children?: ReactElement; @@ -13,16 +15,70 @@ export const PatternEditActions = ({ children }: PatternEditActionsProps) => { const { deleteSelectedPattern } = useFormManagerStore(state => ({ deleteSelectedPattern: state.deleteSelectedPattern, })); + const focusPatternType = useFormManagerStore( + state => state.focus?.pattern.type + ); + const patterns = useFormManagerStore(state => + Object.values(state.session.form.patterns) + ); + const focusPatternId = useFormManagerStore(state => state.focus?.pattern.id); + const isPatternInFieldset = useMemo(() => { + if (!focusPatternId) return false; + return patterns.some( + p => p.type === 'fieldset' && p.data.patterns.includes(focusPatternId) + ); + }, [focusPatternId, patterns]); + const isFieldset = focusPatternType === 'fieldset'; + const isPagePattern = focusPatternType === 'page'; + const { copyPattern } = useFormManagerStore(state => ({ + copyPattern: state.copyPattern, + })); + const pages = useFormManagerStore(state => + Object.values(state.session.form.patterns).filter(p => p.type === 'page') + ); + const fieldsets = useFormManagerStore(state => + Object.values(state.session.form.patterns).filter( + p => p.type === 'fieldset' + ) + ); + const handleCopyPattern = () => { + const currentPageIndex = pages.findIndex(page => + page.data.patterns.includes(focusPatternId || '') + ); + const currentFieldsetIndex = fieldsets.findIndex(fieldset => + fieldset.data.patterns.includes(focusPatternId) + ); + const sourcePagePatternId = pages[currentPageIndex]?.id; + const sourceFieldsetPatternId = fieldsets[currentFieldsetIndex]?.id; + + if (focusPatternId) { + if (sourcePagePatternId) { + copyPattern(sourcePagePatternId, focusPatternId); + } else { + copyPattern(sourceFieldsetPatternId, focusPatternId); + } + } + }; return ( - <> -
    - +
    + {!isPatternInFieldset && !isPagePattern && ( + + )} + - - - {children ? ( - - {children} - + {children} ) : null}
    - +
    + +
    +
    ); }; diff --git a/packages/design/src/FormManager/FormEdit/components/common/PatternEditForm.tsx b/packages/design/src/FormManager/FormEdit/components/common/PatternEditForm.tsx index 2ef7d431..b174d31a 100644 --- a/packages/design/src/FormManager/FormEdit/components/common/PatternEditForm.tsx +++ b/packages/design/src/FormManager/FormEdit/components/common/PatternEditForm.tsx @@ -3,7 +3,7 @@ import { type ErrorOption, FormProvider, useForm } from 'react-hook-form'; import { type FormError, type Pattern, type PatternMap } from '@atj/forms'; -import { useFormManagerStore } from '../../../store'; +import { useFormManagerStore } from '../../../store.js'; type PatternEditFormProps = { pattern: Pattern; diff --git a/packages/design/src/FormManager/FormEdit/components/common/story-helper.tsx b/packages/design/src/FormManager/FormEdit/components/common/story-helper.tsx index 4a308ad6..9d50536b 100644 --- a/packages/design/src/FormManager/FormEdit/components/common/story-helper.tsx +++ b/packages/design/src/FormManager/FormEdit/components/common/story-helper.tsx @@ -7,10 +7,10 @@ import { createSimpleTestBlueprint, createTestFormManagerContext, createTestSession, -} from '../../../../test-form'; +} from '../../../../test-form.js'; -import FormEdit from '../../../FormEdit'; -import { FormManagerProvider } from '../../../store'; +import FormEdit from '../../../FormEdit/index.js'; +import { FormManagerProvider } from '../../../store.js'; import { expect, userEvent } from '@storybook/test'; import { within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; diff --git a/packages/design/src/FormManager/FormEdit/components/index.ts b/packages/design/src/FormManager/FormEdit/components/index.ts index dd3bf6b3..c38aa66c 100644 --- a/packages/design/src/FormManager/FormEdit/components/index.ts +++ b/packages/design/src/FormManager/FormEdit/components/index.ts @@ -1,18 +1,18 @@ import { type PatternEditComponent, type EditComponentForPattern, -} from '../types'; +} from '../types.js'; -import CheckboxPatternEdit from './CheckboxPatternEdit'; -import FieldsetEdit from './FieldsetEdit'; -import FormSummaryEdit from './FormSummaryEdit'; -import InputPatternEdit from './InputPatternEdit'; -import { PageEdit } from './PageEdit'; -import PageSetEdit from './PageSetEdit'; -import ParagraphPatternEdit from './ParagraphPatternEdit'; -import { PatternPreviewSequence } from './PreviewSequencePattern'; -import RadioGroupPatternEdit from './RadioGroupPatternEdit'; -import SubmissionConfirmationEdit from './SubmissionConfirmationEdit'; +import CheckboxPatternEdit from './CheckboxPatternEdit.js'; +import FieldsetEdit from './FieldsetEdit.js'; +import FormSummaryEdit from './FormSummaryEdit.js'; +import InputPatternEdit from './InputPatternEdit.js'; +import { PageEdit } from './PageEdit.js'; +import PageSetEdit from './PageSetEdit.js'; +import ParagraphPatternEdit from './ParagraphPatternEdit.js'; +import { PatternPreviewSequence } from './PreviewSequencePattern/index.js'; +import RadioGroupPatternEdit from './RadioGroupPatternEdit.js'; +import SubmissionConfirmationEdit from './SubmissionConfirmationEdit.js'; export const defaultPatternEditComponents: EditComponentForPattern = { checkbox: CheckboxPatternEdit as PatternEditComponent, diff --git a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css index fbec8d0d..d0ffe7b8 100644 --- a/packages/design/src/FormManager/FormEdit/formEditStyles.module.css +++ b/packages/design/src/FormManager/FormEdit/formEditStyles.module.css @@ -47,34 +47,39 @@ /* Draggable List */ -.draggableListWrapper legend { +.draggableListItemWrapper legend { padding-left: 1.5rem; } -.draggableListWrapper .radioFormPattern legend { +.draggableListItemWrapper .radioFormPattern legend { padding-left: 0; } -.draggableListWrapper button { +.draggableListItemWrapper button { cursor: pointer; } -.draggableListWrapper:focus-within, -.draggableListWrapper button:not([disabled]):focus { +.draggableListItemWrapper:focus-within, +.draggableListItemWrapper button:not([disabled]):focus { outline: 0.25rem solid #783cb9; } -.draggableListWrapper [tabindex]:focus, -.draggableListWrapper:has(input:focus), -.draggableListWrapper:has(button:focus), -.draggableListWrapper:has(textarea:focus) { +.draggableListItemWrapper [tabindex]:focus, +.draggableListItemWrapper:has(input:focus), +.draggableListItemWrapper:has(button:focus), +.draggableListItemWrapper:has(textarea:focus) { outline: none; } -.draggableListWrapper .dropdownMenu { +.draggableListItemWrapper .dropdownMenu { margin-top: 5px; } +.draggableListItemWrapper p { + margin: 0; + padding: 1em 0; +} + /* Configure and Publish Pages */ .progressPage { min-height: 60vh; @@ -89,15 +94,6 @@ background: none; } -@media (max-width: 40em) { - .dropdownMenu { - bottom: 4.5rem; - max-height: 40vh; - overflow: auto; - left: 32px; - } -} - .dottedLine { display: flex; align-items: center; @@ -118,3 +114,85 @@ .dottedLine::after { margin: 0 0 0 1em; } + +/* Move to Page */ + +.moveToPage, +.questionPosition { + max-width: 21rem; +} + +.draggableListWrapper:has(.patternActionWrapper .dropDown select:focus), +.draggableListWrapper:has(.patternActionWrapper .dropDown:focus ) { + outline: 0; +} + +.patternActionWrapper .dropdownMenu div { + padding: 8px; + cursor: pointer; +} + +.patternActionWrapper .dropdownMenu div:hover { + background: #f0f0f0; +} + +.patternActionWrapper .dropDown { + position: absolute; + top: 3rem; + left: 0; + background-color: white; + border: 1px solid #ccc; + z-index: 100; + width: 16rem; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.patternActionWrapper .dropDown select { + max-width: 9.375rem; +} + +.patternActionWrapper .dropDown button { + width: 9.375rem; +} + +.patternActionButtons { + max-width: 15.625rem; +} + +.moveToPageWrapper p { + padding-top: 0.5rem; +} + +.movePatternButton { + color: #005ea2; +} + +.movePatternButton:focus { + outline: 0.25rem solid red; +} + +.movePatternButton span { + font-size: 1.25rem; +} + +.movePatternButton svg { + font-size: 1.5rem; +} + +@media (min-width: 64.5em) { + + + .moveToPage, + .questionPosition { + min-width: 18.75rem; + } +} + +@media (max-width: 40em) { + .dropdownMenu { + bottom: 4.5rem; + max-height: 40vh; + overflow: auto; + left: 32px; + } +} \ No newline at end of file diff --git a/packages/design/src/FormManager/FormEdit/index.tsx b/packages/design/src/FormManager/FormEdit/index.tsx index 1e46f108..1fd3fdb4 100644 --- a/packages/design/src/FormManager/FormEdit/index.tsx +++ b/packages/design/src/FormManager/FormEdit/index.tsx @@ -1,10 +1,11 @@ import React, { useEffect } from 'react'; -import Form, { type ComponentForPattern } from '../../Form'; -import { AddPatternMenu } from './AddPatternDropdown'; -import { PreviewPattern } from './PreviewPattern'; -import { useFormManagerStore } from '../store'; import { useLocation } from 'react-router-dom'; +import Form, { type ComponentForPattern } from '../../Form/index.js'; +import { AddPatternMenu } from './AddPatternDropdown.js'; +import { PreviewPattern } from './PreviewPattern.js'; +import { useFormManagerStore } from '../store.js'; + const EditForm = () => { const session = useFormManagerStore(state => state.session); const { context, setRouteParams } = useFormManagerStore(state => ({ diff --git a/packages/design/src/FormManager/FormEdit/store.ts b/packages/design/src/FormManager/FormEdit/store.ts index 7db549c4..b2da2573 100644 --- a/packages/design/src/FormManager/FormEdit/store.ts +++ b/packages/design/src/FormManager/FormEdit/store.ts @@ -1,22 +1,22 @@ import { StateCreator } from 'zustand'; import { + type FormSession, type Pattern, type PatternId, type PatternMap, - type FormSession, BlueprintBuilder, getPattern, getSessionPage, mergeSession, } from '@atj/forms'; -import { type FormManagerContext } from '..'; -import { type PatternFocus } from './types'; +import { type FormManagerContext } from '../index.js'; +import { type PatternFocus } from './types.js'; import { type NotificationSlice, createNotificationsSlice, -} from '../Notifications'; -import { getRouteDataFromQueryString } from '@atj/forms/src/route-data'; +} from '../Notifications/index.js'; +import { getRouteDataFromQueryString } from '@atj/forms'; export type FormEditSlice = { context: FormManagerContext; @@ -27,8 +27,15 @@ export type FormEditSlice = { addPattern: (patternType: string) => void; addPatternToFieldset: (patternType: string, targetPattern: PatternId) => void; clearFocus: () => void; + copyPattern: (parentPatternId: PatternId, patternId: PatternId) => void; deletePattern: (id: PatternId) => void; deleteSelectedPattern: () => void; + movePattern: ( + sourcePage: PatternId, + targetPage: PatternId, + patternId: PatternId, + position: string + ) => void; setFocus: (patternId: PatternId) => boolean; setRouteParams: (routeParams: string) => void; updatePattern: (data: Pattern) => void; @@ -69,12 +76,49 @@ export const createFormEditSlice = ); const page = getSessionPage(state.session); const newPattern = builder.addPatternToPage(patternType, page); + set({ session: mergeSession(state.session, { form: builder.form }), focus: { pattern: newPattern }, }); state.addNotification('success', 'Element added successfully.'); }, + movePattern: (sourcePage, targetPage, patternId, position) => { + const state = get(); + const builder = new BlueprintBuilder( + state.context.config, + state.session.form + ); + + const movePatternBetweenPages = builder.movePatternBetweenPages( + sourcePage, + targetPage, + patternId, + position + ); + + set({ + session: mergeSession(state.session, { form: builder.form }), + focus: { pattern: movePatternBetweenPages }, + }); + state.addNotification('success', 'Element moved successfully.'); + }, + + copyPattern: (parentPatternId, patternId) => { + const state = get(); + const builder = new BlueprintBuilder( + state.context.config, + state.session.form + ); + + const copyPattern = builder.copyPattern(parentPatternId, patternId); + set({ + session: mergeSession(state.session, { form: builder.form }), + focus: { pattern: copyPattern }, + }); + state.addNotification('success', 'Element copied successfully.'); + }, + addPatternToFieldset: (patternType, targetPattern) => { const state = get(); const builder = new BlueprintBuilder( diff --git a/packages/design/src/FormManager/FormEdit/types.ts b/packages/design/src/FormManager/FormEdit/types.ts index 8316a9b7..007525aa 100644 --- a/packages/design/src/FormManager/FormEdit/types.ts +++ b/packages/design/src/FormManager/FormEdit/types.ts @@ -1,7 +1,7 @@ import { type PropsWithChildren } from 'react'; import { type FormErrors, type Pattern, type PatternProps } from '@atj/forms'; -import { FormManagerContext } from '..'; +import { FormManagerContext } from '../index.js'; export type PatternFocus = { pattern: Pattern; diff --git a/packages/design/src/FormManager/FormInspect/FormInspect.tsx b/packages/design/src/FormManager/FormInspect/FormInspect.tsx index a80d3b91..f5057d44 100644 --- a/packages/design/src/FormManager/FormInspect/FormInspect.tsx +++ b/packages/design/src/FormManager/FormInspect/FormInspect.tsx @@ -1,5 +1,5 @@ -import { useFormManagerStore } from '../store'; import React from 'react'; +import { useFormManagerStore } from '../store.js'; export const FormInspect = () => { const form = useFormManagerStore(state => state.session.form); diff --git a/packages/design/src/FormManager/FormInspect/index.ts b/packages/design/src/FormManager/FormInspect/index.ts index 0419710a..fffce3dd 100644 --- a/packages/design/src/FormManager/FormInspect/index.ts +++ b/packages/design/src/FormManager/FormInspect/index.ts @@ -1 +1 @@ -export { FormInspect } from './FormInspect'; +export { FormInspect } from './FormInspect.js'; diff --git a/packages/design/src/FormManager/FormList/CreateNew/PDFFileSelect.stories.tsx b/packages/design/src/FormManager/FormList/CreateNew/PDFFileSelect.stories.tsx index c30fd10c..c85f1446 100644 --- a/packages/design/src/FormManager/FormList/CreateNew/PDFFileSelect.stories.tsx +++ b/packages/design/src/FormManager/FormList/CreateNew/PDFFileSelect.stories.tsx @@ -4,11 +4,11 @@ import type { Meta, StoryObj } from '@storybook/react'; import { createTwoPatternTestForm, - createTestFormManagerContext, createTestSession, -} from '../../../test-form'; -import { FormManagerProvider } from '../../store'; -import CreateNew from '.'; + createTestFormManagerContext, +} from '../../../test-form.js'; +import { FormManagerProvider } from '../../store.js'; +import CreateNew from './index.js'; const meta: Meta = { title: 'FormManager/FormList/CreateNew', diff --git a/packages/design/src/FormManager/FormList/CreateNew/PDFFileSelect.test.tsx b/packages/design/src/FormManager/FormList/CreateNew/PDFFileSelect.test.tsx index 5810470f..58604a52 100644 --- a/packages/design/src/FormManager/FormList/CreateNew/PDFFileSelect.test.tsx +++ b/packages/design/src/FormManager/FormList/CreateNew/PDFFileSelect.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../../test-helper'; -import meta, * as stories from './PDFFileSelect.stories'; +import { describeStories } from '../../../test-helper.js'; +import meta, * as stories from './PDFFileSelect.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormList/CreateNew/file-input.test.ts b/packages/design/src/FormManager/FormList/CreateNew/file-input.test.ts index 5f8276f1..03814034 100644 --- a/packages/design/src/FormManager/FormList/CreateNew/file-input.test.ts +++ b/packages/design/src/FormManager/FormList/CreateNew/file-input.test.ts @@ -1,7 +1,7 @@ -import { it, describe, expect, vi } from 'vitest'; import type { ChangeEvent } from 'react'; +import { it, describe, expect, vi } from 'vitest'; -import { onFileInputChangeGetFile } from './file-input'; +import { onFileInputChangeGetFile } from './file-input.js'; /** * @vitest-environment jsdom diff --git a/packages/design/src/FormManager/FormList/CreateNew/index.tsx b/packages/design/src/FormManager/FormList/CreateNew/index.tsx index f4415c17..e3956cb6 100644 --- a/packages/design/src/FormManager/FormList/CreateNew/index.tsx +++ b/packages/design/src/FormManager/FormList/CreateNew/index.tsx @@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom'; import { SAMPLE_DOCUMENTS } from '@atj/forms'; -import { useFormManagerStore } from '../../store'; -import { onFileInputChangeGetFile } from './file-input'; -import { Notifications } from '../../Notifications'; +import { useFormManagerStore } from '../../store.js'; +import { onFileInputChangeGetFile } from './file-input.js'; +import { Notifications } from '../../Notifications/index.js'; export default function CreateNew() { const navigate = useNavigate(); diff --git a/packages/design/src/FormManager/FormList/FormList.stories.tsx b/packages/design/src/FormManager/FormList/FormList.stories.tsx index 3399777f..311a02ea 100644 --- a/packages/design/src/FormManager/FormList/FormList.stories.tsx +++ b/packages/design/src/FormManager/FormList/FormList.stories.tsx @@ -2,15 +2,15 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; -import { service } from '@atj/forms'; +import { createTestBrowserFormService } from '@atj/forms/context'; -import FormList from '.'; +import FormList from './index.js'; import { createTwoPatternTestForm, - createTestFormManagerContext, createTestSession, -} from '../../test-form'; -import { FormManagerProvider } from '../store'; + createTestFormManagerContext, +} from '../../test-form.js'; +import { FormManagerProvider } from '../store.js'; const meta: Meta = { title: 'FormManager/FormList', @@ -28,7 +28,7 @@ const meta: Meta = { ), ], args: { - formService: service.createTestFormService({ + formService: createTestBrowserFormService({ 'test-form': createTwoPatternTestForm(), }), }, diff --git a/packages/design/src/FormManager/FormList/FormList.test.ts b/packages/design/src/FormManager/FormList/FormList.test.ts index 7a7014dd..984fc392 100644 --- a/packages/design/src/FormManager/FormList/FormList.test.ts +++ b/packages/design/src/FormManager/FormList/FormList.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../test-helper'; -import meta, * as stories from './FormList.stories'; +import { describeStories } from '../../test-helper.js'; +import meta, * as stories from './FormList.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormList/ManageFormsTable.tsx b/packages/design/src/FormManager/FormList/ManageFormsTable.tsx index 58c93f8f..90c50dc1 100644 --- a/packages/design/src/FormManager/FormList/ManageFormsTable.tsx +++ b/packages/design/src/FormManager/FormList/ManageFormsTable.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { type FormListItem } from '@atj/forms/src/service/operations/get-form-list'; - type ManageFormsTableProps = { - formListItems: FormListItem[]; + formListItems: { + id: string; + title: string; + description: string; + }[]; }; export const ManageFormsTable = ({ formListItems }: ManageFormsTableProps) => { diff --git a/packages/design/src/FormManager/FormList/index.tsx b/packages/design/src/FormManager/FormList/index.tsx index 2023dd12..d97c74e5 100644 --- a/packages/design/src/FormManager/FormList/index.tsx +++ b/packages/design/src/FormManager/FormList/index.tsx @@ -1,17 +1,8 @@ import React from 'react'; -import { service } from '@atj/forms'; -import CreateNew from './CreateNew'; +import CreateNew from './CreateNew/index.js'; -export default function FormList({ - formService, -}: { - formService: service.FormService; -}) { - const result = formService.getFormList(); - if (!result.success) { - return
    Error loading form list
    ; - } +export default function FormList() { return (
    diff --git a/packages/design/src/FormManager/FormList/store.ts b/packages/design/src/FormManager/FormList/store.ts index d6a8191c..bb545437 100644 --- a/packages/design/src/FormManager/FormList/store.ts +++ b/packages/design/src/FormManager/FormList/store.ts @@ -1,8 +1,8 @@ import { type StateCreator } from 'zustand'; import { BlueprintBuilder } from '@atj/forms'; -import { type FormManagerContext } from '../../FormManager'; -import { type Result } from '@atj/common'; +import { type FormManagerContext } from '../../FormManager/index.js'; +import { type Result, failure } from '@atj/common'; type StoreContext = { context: FormManagerContext; @@ -41,10 +41,7 @@ export const createFormListSlice = data: result.data.id, }; } else { - return { - success: false, - error: result.error, - }; + return failure(result.error.message); } }, createNewFormByPDFUpload: async fileDetails => { @@ -61,10 +58,7 @@ export const createFormListSlice = data: result.data.id, }; } else { - return { - success: false, - error: result.error, - }; + return failure(result.error.message); } }, }); diff --git a/packages/design/src/FormManager/FormManager.stories.ts b/packages/design/src/FormManager/FormManager.stories.ts index 538433e1..4980e55b 100644 --- a/packages/design/src/FormManager/FormManager.stories.ts +++ b/packages/design/src/FormManager/FormManager.stories.ts @@ -1,8 +1,8 @@ // Replace your-framework with the name of your framework import type { Meta, StoryObj } from '@storybook/react'; -import FormManager from '.'; -import { createTestFormManagerContext } from '../test-form'; +import FormManager from './index.js'; +import { createTestFormManagerContext } from '../test-form.js'; export default { title: 'form/FormManager', diff --git a/packages/design/src/FormManager/FormManager.test.ts b/packages/design/src/FormManager/FormManager.test.ts index f573850a..ff2811de 100644 --- a/packages/design/src/FormManager/FormManager.test.ts +++ b/packages/design/src/FormManager/FormManager.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../test-helper'; -import meta, * as stories from './FormManager.stories'; +import { describeStories } from '../test-helper.js'; +import meta, * as stories from './FormManager.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.stories.tsx b/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.stories.tsx index 7df9d63f..e600e8b6 100644 --- a/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.stories.tsx +++ b/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.stories.tsx @@ -2,14 +2,14 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; -import { FormManagerLayout } from '.'; +import { FormManagerLayout } from './index.js'; import { createTwoPatternTestForm, - createTestFormManagerContext, createTestSession, -} from '../../test-form'; -import { FormManagerProvider } from '../store'; -import { NavPage } from './TopNavigation'; + createTestFormManagerContext, +} from '../../test-form.js'; +import { FormManagerProvider } from '../store.js'; +import { NavPage } from './TopNavigation.js'; const meta: Meta = { title: 'FormManagerLayout', diff --git a/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.test.tsx b/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.test.tsx index 4c2928ad..ba612770 100644 --- a/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.test.tsx +++ b/packages/design/src/FormManager/FormManagerLayout/FormManagerLayout.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../test-helper'; -import meta, * as stories from './FormManagerLayout.stories'; +import { describeStories } from '../../test-helper.js'; +import meta, * as stories from './FormManagerLayout.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormManagerLayout/TopNavigation.tsx b/packages/design/src/FormManager/FormManagerLayout/TopNavigation.tsx index 6a6f62fd..ffa4e352 100644 --- a/packages/design/src/FormManager/FormManagerLayout/TopNavigation.tsx +++ b/packages/design/src/FormManager/FormManagerLayout/TopNavigation.tsx @@ -1,8 +1,8 @@ import React from 'react'; import classNames from 'classnames'; -import { MyForms } from '../routes'; -import { useFormManagerStore } from '../store'; +import { MyForms } from '../routes.js'; +import { useFormManagerStore } from '../store.js'; import styles from './formManagerStyles.module.css'; export enum NavPage { diff --git a/packages/design/src/FormManager/FormManagerLayout/index.tsx b/packages/design/src/FormManager/FormManagerLayout/index.tsx index 6c8d7f9c..5fc567cf 100644 --- a/packages/design/src/FormManager/FormManagerLayout/index.tsx +++ b/packages/design/src/FormManager/FormManagerLayout/index.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { Notifications } from '../Notifications'; -import { type NavPage, TopNavigation } from './TopNavigation'; -import { BottomNavigation } from './BottomNavigation'; +import { Notifications } from '../Notifications/index.js'; +import { type NavPage, TopNavigation } from './TopNavigation.js'; +import { BottomNavigation } from './BottomNavigation.js'; import { useLocation } from 'react-router-dom'; import { useEffect } from 'react'; -import { useFormManagerStore } from '../store'; +import { useFormManagerStore } from '../store.js'; import styles from './formManagerStyles.module.css'; type FormManagerLayoutProps = { diff --git a/packages/design/src/FormManager/FormPreview/FormPreview.stories.tsx b/packages/design/src/FormManager/FormPreview/FormPreview.stories.tsx index 6861008c..4eb71ad9 100644 --- a/packages/design/src/FormManager/FormPreview/FormPreview.stories.tsx +++ b/packages/design/src/FormManager/FormPreview/FormPreview.stories.tsx @@ -2,14 +2,14 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; -import { FormPreview } from '.'; +import { FormPreview } from './index.js'; import { createTwoPatternTestForm, createTestFormContext, - createTestFormManagerContext, createTestSession, -} from '../../test-form'; -import { FormManagerProvider } from '../store'; + createTestFormManagerContext, +} from '../../test-form.js'; +import { FormManagerProvider } from '../store.js'; const meta: Meta = { title: 'FormManager/FormPreview', diff --git a/packages/design/src/FormManager/FormPreview/FormPreview.test.ts b/packages/design/src/FormManager/FormPreview/FormPreview.test.ts index 8724ac54..f0546ebb 100644 --- a/packages/design/src/FormManager/FormPreview/FormPreview.test.ts +++ b/packages/design/src/FormManager/FormPreview/FormPreview.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../../test-helper'; -import meta, * as stories from './FormPreview.stories'; +import { describeStories } from '../../test-helper.js'; +import meta, * as stories from './FormPreview.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/FormManager/FormPreview/index.tsx b/packages/design/src/FormManager/FormPreview/index.tsx index 244e5e5c..45dbdc89 100644 --- a/packages/design/src/FormManager/FormPreview/index.tsx +++ b/packages/design/src/FormManager/FormPreview/index.tsx @@ -2,9 +2,9 @@ import React, { useEffect } from 'react'; import { mergeSession } from '@atj/forms'; -import Form from '../../Form'; -import { useFormManagerStore } from '../store'; -import { useRouteParams } from '../../FormRouter/hooks'; +import Form from '../../Form/index.js'; +import { useFormManagerStore } from '../store.js'; +import { useRouteParams } from '../../FormRouter/hooks.js'; export const FormPreview = () => { const { context, setSession } = useFormManagerStore(state => ({ diff --git a/packages/design/src/FormManager/Notifications/NotificationAlert.stories.tsx b/packages/design/src/FormManager/Notifications/NotificationAlert.stories.tsx index eb0375f9..a131fa38 100644 --- a/packages/design/src/FormManager/Notifications/NotificationAlert.stories.tsx +++ b/packages/design/src/FormManager/Notifications/NotificationAlert.stories.tsx @@ -1,6 +1,6 @@ import { Meta, StoryObj } from '@storybook/react'; -import { NotificationAlert } from './NotificationAlert'; +import { NotificationAlert } from './NotificationAlert.js'; export default { title: 'FormManager/Notifications/NotificationAlert', diff --git a/packages/design/src/FormManager/Notifications/NotificationAlert.tsx b/packages/design/src/FormManager/Notifications/NotificationAlert.tsx index 67444c07..6aa89ae4 100644 --- a/packages/design/src/FormManager/Notifications/NotificationAlert.tsx +++ b/packages/design/src/FormManager/Notifications/NotificationAlert.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React from 'react'; -import { type Notification } from './store'; +import { type Notification } from './store.js'; type NotificationAlertProps = { type: Notification['type']; diff --git a/packages/design/src/FormManager/Notifications/Notifications.stories.tsx b/packages/design/src/FormManager/Notifications/Notifications.stories.tsx index 45aeab64..bef757e5 100644 --- a/packages/design/src/FormManager/Notifications/Notifications.stories.tsx +++ b/packages/design/src/FormManager/Notifications/Notifications.stories.tsx @@ -1,13 +1,13 @@ import { Meta, StoryObj } from '@storybook/react'; import React from 'react'; -import { Notifications } from './Notifications'; -import { FormManagerProvider, useFormManagerStore } from '../store'; +import { Notifications } from './Notifications.js'; +import { FormManagerProvider, useFormManagerStore } from '../store.js'; import { createTestFormManagerContext, createTestSession, createTwoPatternTestForm, -} from '../../test-form'; +} from '../../test-form.js'; const StoryImpl = () => { const { addNotification } = useFormManagerStore(); diff --git a/packages/design/src/FormManager/Notifications/Notifications.tsx b/packages/design/src/FormManager/Notifications/Notifications.tsx index a1777a3a..543421ce 100644 --- a/packages/design/src/FormManager/Notifications/Notifications.tsx +++ b/packages/design/src/FormManager/Notifications/Notifications.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { useFormManagerStore } from '../store'; -import { NotificationAlert } from './NotificationAlert'; +import { useFormManagerStore } from '../store.js'; +import { NotificationAlert } from './NotificationAlert.js'; export const Notifications = () => { const { notifications } = useFormManagerStore(); diff --git a/packages/design/src/FormManager/Notifications/index.ts b/packages/design/src/FormManager/Notifications/index.ts index 24a6047a..d1e17911 100644 --- a/packages/design/src/FormManager/Notifications/index.ts +++ b/packages/design/src/FormManager/Notifications/index.ts @@ -1,2 +1,2 @@ -export { Notifications } from './Notifications'; -export { type NotificationSlice, createNotificationsSlice } from './store'; +export { Notifications } from './Notifications.js'; +export { type NotificationSlice, createNotificationsSlice } from './store.js'; diff --git a/packages/design/src/FormManager/Notifications/store.test.ts b/packages/design/src/FormManager/Notifications/store.test.ts index e7cc7ed7..b2f080c3 100644 --- a/packages/design/src/FormManager/Notifications/store.test.ts +++ b/packages/design/src/FormManager/Notifications/store.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { create } from 'zustand'; -import { createNotificationsSlice } from './store'; +import { createNotificationsSlice } from './store.js'; const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/packages/design/src/FormManager/index.tsx b/packages/design/src/FormManager/index.tsx index 4145535a..f5511dd6 100644 --- a/packages/design/src/FormManager/index.tsx +++ b/packages/design/src/FormManager/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useParams, HashRouter, @@ -12,32 +12,36 @@ import { createFormSession, nullSession, defaultFormConfig, + Blueprint, } from '@atj/forms'; -import { service } from '@atj/forms'; +import { type FormService } from '@atj/forms'; -import { type ComponentForPattern } from '../Form'; +import { type ComponentForPattern } from '../Form/index.js'; -import FormDelete from './FormDelete'; -import FormEdit from './FormEdit'; -import { type EditComponentForPattern } from './FormEdit/types'; -import { FormInspect } from './FormInspect'; -import FormList from './FormList'; -import { FormManagerLayout } from './FormManagerLayout'; -import { NavPage } from './FormManagerLayout/TopNavigation'; -import { FormPreview } from './FormPreview'; -import * as AppRoutes from './routes'; -import { FormManagerProvider } from './store'; +import FormDelete from './FormDelete/index.js'; +import FormEdit from './FormEdit/index.js'; +import { type EditComponentForPattern } from './FormEdit/types.js'; +import { FormInspect } from './FormInspect/index.js'; +import FormList from './FormList/index.js'; +import { FormManagerLayout } from './FormManagerLayout/index.js'; +import { NavPage } from './FormManagerLayout/TopNavigation.js'; +import { FormPreview } from './FormPreview/index.js'; +import * as AppRoutes from './routes.js'; +import { FormManagerProvider } from './store.js'; import AvailableFormList, { UrlForForm, UrlForFormManager, -} from '../AvailableFormList'; +} from '../AvailableFormList/index.js'; import styles from './FormEdit/formEditStyles.module.css'; -import { defaultPatternComponents, defaultPatternEditComponents } from '..'; +import { + defaultPatternComponents, + defaultPatternEditComponents, +} from '../index.js'; export type PatternLessFormManagerContext = { baseUrl: `${string}/`; - formService: service.FormService; + formService: FormService; uswdsRoot: `${string}/`; urlForForm: UrlForForm; urlForFormManager: UrlForFormManager; @@ -89,7 +93,7 @@ export default function FormManager(props: FormManagerProps) { return ( - + ); @@ -99,18 +103,15 @@ export default function FormManager(props: FormManagerProps) { path={AppRoutes.Inspect.path} Component={() => { const { formId } = useParams(); - if (formId === undefined) { - return
    formId is undefined
    ; - } - const formResult = context.formService.getForm(formId); - if (!formResult.success) { - return
    Error loading form preview
    ; + const form = useBlueprint(formId, context.formService); + if (form === null) { + return
    Loading...
    ; } return ( @@ -126,15 +127,15 @@ export default function FormManager(props: FormManagerProps) { if (formId === undefined) { return
    formId is undefined
    ; } - const formResult = context.formService.getForm(formId); - if (!formResult.success) { - return
    Error loading form preview
    ; + const form = useBlueprint(formId, context.formService); + if (form === null) { + return
    Loading...
    ; } return ( formId is undefined
    ; } - const formResult = context.formService.getForm(formId); - if (!formResult.success) { - return
    Form not found
    ; + const form = useBlueprint(formId, context.formService); + if (form === null) { + return
    Loading...
    ; } return ( formId is undefined
    ; } - const formResult = context.formService.getForm(formId); - if (!formResult.success) { - return 'Form not found'; + const form = useBlueprint(formId, context.formService); + if (form === null) { + return
    Loading...
    ; } return ( formId is undefined
    ; } - const result = context.formService.getForm(formId); - if (!result.success) { - return 'Form not found'; + const form = useBlueprint(formId, context.formService); + if (form === null) { + return
    Loading...
    ; } - const session = createFormSession(result.data); + const session = createFormSession(form); return ( ); } + +const useBlueprint = (formId: string | undefined, formService: FormService) => { + if (formId === undefined) { + console.error('formId is undefined'); + return null; + } + const [form, setForm] = useState(null); + useEffect(() => { + formService.getForm(formId).then(result => { + if (result.success) { + setForm(result.data); + } else { + console.error('Error loading form', result.error); + } + }); + }, []); + return form; +}; diff --git a/packages/design/src/FormManager/store.tsx b/packages/design/src/FormManager/store.tsx index 52e62b38..6479ad4f 100644 --- a/packages/design/src/FormManager/store.tsx +++ b/packages/design/src/FormManager/store.tsx @@ -7,12 +7,12 @@ import { } from 'zustand'; import { createContext } from 'zustand-utils'; -import { type Result } from '@atj/common'; +import { type Result, failure } from '@atj/common'; import { type FormSession, type Blueprint, BlueprintBuilder } from '@atj/forms'; -import { type FormEditSlice, createFormEditSlice } from './FormEdit/store'; -import { type FormListSlice, createFormListSlice } from './FormList/store'; -import { type FormManagerContext } from '.'; +import { type FormListSlice, createFormListSlice } from './FormList/store.js'; +import { type FormEditSlice, createFormEditSlice } from './FormEdit/store.js'; +import { type FormManagerContext } from './index.js'; type StoreContext = { context: FormManagerContext; @@ -86,7 +86,7 @@ const createFormManagerSlice = }); const result = await context.formService.addForm(builder.form); if (!result.success) { - return result; + return failure(result.error.message); } return { success: true, @@ -99,21 +99,30 @@ const createFormManagerSlice = return; } set({ - saveStatus: { inProgress: true, lastSaved: saveStatus.lastSaved }, + saveStatus: { + inProgress: true, + lastSaved: saveStatus.lastSaved, + }, }); if (formId === undefined) { const result = await context.formService.addForm(blueprint); if (result.success) { set({ formId: result.data.id, - saveStatus: { inProgress: false, lastSaved: result.data.timestamp }, + saveStatus: { + inProgress: false, + lastSaved: new Date(result.data.timestamp), + }, }); } } else { const result = await context.formService.saveForm(formId, blueprint); if (result.success) { set({ - saveStatus: { inProgress: false, lastSaved: result.data.timestamp }, + saveStatus: { + inProgress: false, + lastSaved: new Date(result.data.timestamp), + }, }); } } diff --git a/packages/design/src/FormRouter/FormRouter.stories.ts b/packages/design/src/FormRouter/FormRouter.stories.ts index c16b8ae5..2c84ee6b 100644 --- a/packages/design/src/FormRouter/FormRouter.stories.ts +++ b/packages/design/src/FormRouter/FormRouter.stories.ts @@ -1,14 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { service } from '@atj/forms'; - -import FormRouter from '.'; +import { type FormService } from '@atj/forms'; +import { createTestBrowserFormService } from '@atj/forms/context'; +import FormRouter from './index.js'; export default { title: 'Form', component: FormRouter, args: { - formService: service.createTestFormService(), + formService: createTestBrowserFormService() as FormService, }, tags: ['autodocs'], } satisfies Meta; diff --git a/packages/design/src/FormRouter/FormRouter.test.ts b/packages/design/src/FormRouter/FormRouter.test.ts index fec5f0fc..7bee94da 100644 --- a/packages/design/src/FormRouter/FormRouter.test.ts +++ b/packages/design/src/FormRouter/FormRouter.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { describeStories } from '../test-helper'; -import meta, * as stories from './FormRouter.stories'; +import { describeStories } from '../test-helper.js'; +import meta, * as stories from './FormRouter.stories.js'; describeStories(meta, stories); diff --git a/packages/design/src/FormRouter/hooks.ts b/packages/design/src/FormRouter/hooks.ts index 42f56295..36a606f2 100644 --- a/packages/design/src/FormRouter/hooks.ts +++ b/packages/design/src/FormRouter/hooks.ts @@ -1,9 +1,6 @@ import { useLocation } from 'react-router-dom'; -import { - type RouteData, - getRouteDataFromQueryString, -} from '@atj/forms/src/route-data'; +import { type RouteData, getRouteDataFromQueryString } from '@atj/forms'; export const useRouteParams = (): { routeParams: RouteData; diff --git a/packages/design/src/FormRouter/index.tsx b/packages/design/src/FormRouter/index.tsx index 1ece77e0..f1583c1b 100644 --- a/packages/design/src/FormRouter/index.tsx +++ b/packages/design/src/FormRouter/index.tsx @@ -1,12 +1,17 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useParams, HashRouter, Route, Routes } from 'react-router-dom'; -import { defaultFormConfig, service } from '@atj/forms'; +import { type Result } from '@atj/common'; +import { + type Blueprint, + type FormService, + defaultFormConfig, +} from '@atj/forms'; import { createFormSession } from '@atj/forms'; -import { useQueryString } from './hooks'; -import { defaultPatternComponents } from '..'; -import Form, { FormUIContext } from '../Form'; +import { useQueryString } from './hooks.js'; +import { defaultPatternComponents } from '../index.js'; +import Form, { FormUIContext } from '../Form/index.js'; // Wrapper around Form that includes a client-side router for loading forms. export default function FormRouter({ @@ -14,7 +19,7 @@ export default function FormRouter({ formService, }: { uswdsRoot: `${string}/`; - formService: service.FormService; + formService: FormService; }) { // For now, hardcode the pattern configuration. // If these are user-configurable, we'll likely need to, in some manner, @@ -35,19 +40,34 @@ export default function FormRouter({ if (formId === undefined) { return
    formId is undefined
    ; } - const result = formService.getForm(formId); - if (!result.success) { + + const [formResult, setFormResult] = useState | null>(null); + useEffect(() => { + formService.getForm(formId).then(result => { + setFormResult(result); + }); + }, []); + + if (formResult === null) { + return; + } + if (formResult.success === false) { return (

    Error loading form

    -

    {result.error}

    +

    + {formResult.error.message} +

    ); } - const session = createFormSession(result.data, queryString); + const session = createFormSession(formResult.data, queryString); return (
    { return createForm( @@ -209,7 +209,7 @@ export const createTestFormManagerContext = (): FormManagerContext => { components: defaultPatternComponents, config: defaultFormConfig, editComponents: defaultPatternEditComponents, - formService: service.createTestFormService(), + formService: createTestBrowserFormService(), uswdsRoot: `/static/uswds/`, urlForForm: mockGetUrl, urlForFormManager: mockGetUrl, diff --git a/packages/design/src/test1.test.ts b/packages/design/src/test1.test.ts new file mode 100644 index 00000000..91c6de65 --- /dev/null +++ b/packages/design/src/test1.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest'; + +describe('test1', () => { + it('should work', () => { + expect(1).toBe(1); + }); +}); diff --git a/packages/design/tsconfig.build.json b/packages/design/tsconfig.build.json index 7d7c65ec..6739a56a 100644 --- a/packages/design/tsconfig.build.json +++ b/packages/design/tsconfig.build.json @@ -1,11 +1,10 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": "./src", "emitDeclarationOnly": true, "jsx": "react", "outDir": "./dist", - "module": "esnext", "types": ["vite/client", "node"] }, "include": ["./src"], diff --git a/packages/design/tsconfig.json b/packages/design/tsconfig.json index db466585..b51a35aa 100644 --- a/packages/design/tsconfig.json +++ b/packages/design/tsconfig.json @@ -1,11 +1,13 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": "./src", "emitDeclarationOnly": true, "jsx": "react", - "module": "esnext", + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "./dist", + "rootDir": "./src", "types": ["vite/client", "node"] }, "include": ["./src"], diff --git a/packages/design/vite.config.ts b/packages/design/vite.config.ts index e8207771..e84a94b3 100644 --- a/packages/design/vite.config.ts +++ b/packages/design/vite.config.ts @@ -20,5 +20,5 @@ export default defineConfig({ entryFileNames: '[name].js', }, }, - } + }, }); diff --git a/packages/design/vitest.config.ts b/packages/design/vitest.config.ts index c58ffabd..95d4723e 100644 --- a/packages/design/vitest.config.ts +++ b/packages/design/vitest.config.ts @@ -10,6 +10,7 @@ export default mergeConfig( test: { ...sharedTestConfig.test, environment: 'jsdom', + setupFiles: './vitest.setup.ts', }, }) ); diff --git a/packages/design/vitest.setup.ts b/packages/design/vitest.setup.ts new file mode 100644 index 00000000..bb02c60c --- /dev/null +++ b/packages/design/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/packages/docassemble/package.json b/packages/docassemble/package.json index ada1ad12..f8de56be 100644 --- a/packages/docassemble/package.json +++ b/packages/docassemble/package.json @@ -4,17 +4,15 @@ "description": "10x ATJ docassemble adapter", "type": "module", "license": "CC0", - "main": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "build": "tsup src/* --env.NODE_ENV production", - "dev": "tsup src/* --watch", + "build": "tsc", + "clean": "rimraf dist tsconfig.tsbuildinfo coverage", + "dev": "tsc --watch", "test:integration": "vitest run --coverage" }, "dependencies": { "@atj/forms": "workspace:*" - }, - "devDependencies": { - "@vitest/coverage-v8": "^0.34.6", - "vitest": "^1.6.0" } } diff --git a/packages/docassemble/src/client.test.ts b/packages/docassemble/src/client.test.ts index c68a8eef..d45ef453 100644 --- a/packages/docassemble/src/client.test.ts +++ b/packages/docassemble/src/client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { createTestDocassembleClient } from './context/test'; +import { createTestDocassembleClient } from './context/test.js'; // These are system integration tests with Docassemble, but we currently do // not have anything configured to autoprovision a docassemble instance. As a diff --git a/packages/docassemble/src/client.ts b/packages/docassemble/src/client.ts index db40d37d..6ede070c 100644 --- a/packages/docassemble/src/client.ts +++ b/packages/docassemble/src/client.ts @@ -1,4 +1,4 @@ -import { type DocassembleInterview } from './types'; +import { type DocassembleInterview } from './types.js'; export const createDocassembleInterview = (interviewId: string) => {}; diff --git a/packages/docassemble/src/context/index.ts b/packages/docassemble/src/context/index.ts index a06ff16d..f4c1b5a9 100644 --- a/packages/docassemble/src/context/index.ts +++ b/packages/docassemble/src/context/index.ts @@ -1,4 +1,4 @@ -import { DocassembleClient, type DocassembleClientContext } from '../client'; +import { DocassembleClient, type DocassembleClientContext } from '../client.js'; export const createDocassembleClient = (ctx: DocassembleClientContext) => { return new DocassembleClient(ctx); diff --git a/packages/docassemble/src/context/test.ts b/packages/docassemble/src/context/test.ts index 82c8655c..57742aea 100644 --- a/packages/docassemble/src/context/test.ts +++ b/packages/docassemble/src/context/test.ts @@ -1,4 +1,4 @@ -import { createDocassembleClient } from '.'; +import { createDocassembleClient } from './index.js'; export const createTestDocassembleClient = () => { return createDocassembleClient({ diff --git a/packages/docassemble/src/index.ts b/packages/docassemble/src/index.ts index 6b7c8c6e..9a53f276 100644 --- a/packages/docassemble/src/index.ts +++ b/packages/docassemble/src/index.ts @@ -1 +1 @@ -export { type DocassembleClientContext, DocassembleClient } from './client'; +export { type DocassembleClientContext, DocassembleClient } from './client.js'; diff --git a/packages/docassemble/tsconfig.json b/packages/docassemble/tsconfig.json index ea22b13a..7fad7f24 100644 --- a/packages/docassemble/tsconfig.json +++ b/packages/docassemble/tsconfig.json @@ -1,9 +1,9 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "ESNext", + "emitDeclarationOnly": false, "outDir": "./dist", - "emitDeclarationOnly": true + "rootDir": "./src" }, "include": ["./src"], "references": [] diff --git a/packages/forms/package.json b/packages/forms/package.json index 9a5fc9e2..90c54c9c 100644 --- a/packages/forms/package.json +++ b/packages/forms/package.json @@ -4,17 +4,33 @@ "description": "10x ATJ form handling", "type": "module", "license": "CC0", - "main": "src/index.ts", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "types": "./dist/types/index.d.ts" + }, + "./context": { + "import": "./dist/esm/context.js", + "require": "./dist/cjs/context.js", + "types": "./dist/types/context/index.d.ts" + } + }, "scripts": { - "build": "tsup src/* --env.NODE_ENV production", - "dev": "tsup src/* --watch", + "build": "rollup -c", + "clean": "rimraf dist tsconfig.tsbuildinfo coverage", + "dev": "rollup -c -w", "test": "vitest run --coverage" }, "dependencies": { "@atj/common": "workspace:*", + "@atj/database": "workspace:*", "pdf-lib": "^1.17.1", - "qs": "^6.12.1", - "zod": "^3.22.4" + "qs": "^6.13.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/qs": "^6.9.15" diff --git a/packages/forms/rollup.config.js b/packages/forms/rollup.config.js new file mode 100644 index 00000000..626b023d --- /dev/null +++ b/packages/forms/rollup.config.js @@ -0,0 +1,54 @@ +import { builtinModules } from 'module'; + +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import typescript from 'rollup-plugin-typescript2'; + +import packageJson from './package.json' assert { type: 'json' }; +import workspacePackageJson from '../../package.json' assert { type: 'json' }; + +export default { + //input: ['src/index.ts', 'src/context/index.ts', 'src/testing.ts'], + input: { + index: 'src/index.ts', + context: 'src/context/index.ts', + }, + output: [ + { + dir: 'dist/esm', + format: 'esm', + sourcemap: true, + entryFileNames: '[name].js', + chunkFileNames: '[name]-[hash].js', + }, + { + dir: 'dist/cjs', + format: 'cjs', + sourcemap: true, + entryFileNames: '[name].js', + chunkFileNames: '[name]-[hash].js', + }, + ], + plugins: [ + nodeResolve(), + commonjs(), + json(), + typescript({ + tsconfig: './tsconfig.json', + useTsconfigDeclarationDir: true, + }), + ], + external: (() => { + // Externalize all the things + return [ + ...new Set([ + ...Object.keys(packageJson.dependencies || {}), + ...Object.keys(packageJson.devDependencies || {}), + ...Object.keys(workspacePackageJson.dependencies || {}), + ...Object.keys(workspacePackageJson.devDependencies || {}), + ...builtinModules, + ]), + ]; + })(), +}; diff --git a/packages/forms/src/builder/builder.test.ts b/packages/forms/src/builder/builder.test.ts index 96f4037b..4f677e25 100644 --- a/packages/forms/src/builder/builder.test.ts +++ b/packages/forms/src/builder/builder.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { BlueprintBuilder } from '.'; -import { createForm, getPattern } from '..'; -import { defaultFormConfig } from '../patterns'; -import { type InputPattern } from '../patterns/input'; -import { PageSetPattern } from '../patterns/page-set/config'; -import { PagePattern } from '../patterns/page/config'; +import { type Pattern, createForm, getPattern } from '../index.js'; +import { defaultFormConfig } from '../patterns/index.js'; +import { type FieldsetPattern } from '../patterns/fieldset/index.js'; +import { type FormSummaryPattern } from '../patterns/form-summary.js'; +import { type InputPattern } from '../patterns/input/index.js'; +import { type PagePattern } from '../patterns/page/config.js'; +import { type PageSetPattern } from '../patterns/page-set/config.js'; +import { type RadioGroupPattern } from '../patterns/radio-group.js'; + +import { BlueprintBuilder } from './index.js'; describe('form builder', () => { it('addPattern adds initial pattern of given type', () => { @@ -22,12 +26,548 @@ describe('form builder', () => { expect(builder.form.patterns[newPattern.id]).toEqual(newPattern); const oldPage = getPattern(initial, 'page-1'); const newPage = getPattern(builder.form, 'page-1'); + expect(newPage.data).toEqual({ ...oldPage.data, patterns: [...oldPage.data.patterns, newPattern.id], }); }); + it('movePattern on the currentpage', () => { + const initial = createTwoPageThreePatternTestForm(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const pattern = getPattern(builder.form, 'element-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const oldPage = getPattern(initial, 'page-1'); + const newPage = getPattern(builder.form, 'page-1'); + builder.movePatternBetweenPages( + oldPage.id, + newPage.id, + pattern.id, + 'bottom' + ); + + expect(builder.form.patterns).toEqual({ + root: { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2'], + }, + } satisfies PageSetPattern, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-2', 'element-1'], + }, + } satisfies PagePattern, + 'page-2': { + type: 'page', + id: 'page-2', + data: { + title: 'Page 2', + patterns: ['element-3'], + }, + } satisfies PagePattern, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-2': { + type: 'input', + id: 'element-2', + data: { + label: 'Pattern 2', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-3': { + type: 'input', + id: 'element-3', + data: { + label: 'Pattern 3', + initial: '', + required: true, + maxLength: 128, + }, + }, + }); + }); + + it('movePattern to top of a different page', () => { + const initial = createTwoPageThreePatternTestForm(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const pattern = getPattern(builder.form, 'element-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const oldPage = getPattern(initial, 'page-1'); + const newPage = getPattern(builder.form, 'page-2'); + builder.movePatternBetweenPages(oldPage.id, newPage.id, pattern.id, 'top'); + expect(builder.form.patterns).toEqual({ + root: { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2'], + }, + } satisfies PageSetPattern, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-2'], + }, + } satisfies PagePattern, + 'page-2': { + type: 'page', + id: 'page-2', + data: { + title: 'Page 2', + patterns: ['element-1', 'element-3'], + }, + } satisfies PagePattern, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-2': { + type: 'input', + id: 'element-2', + data: { + label: 'Pattern 2', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-3': { + type: 'input', + id: 'element-3', + data: { + label: 'Pattern 3', + initial: '', + required: true, + maxLength: 128, + }, + }, + }); + }); + + it('movePattern to bottom of a different page', () => { + const initial = createTwoPageThreePatternTestForm(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const pattern = getPattern(builder.form, 'element-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const oldPage = getPattern(initial, 'page-1'); + const newPage = getPattern(builder.form, 'page-2'); + builder.movePatternBetweenPages( + oldPage.id, + newPage.id, + pattern.id, + 'bottom' + ); + expect(builder.form.patterns).toEqual({ + root: { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2'], + }, + } satisfies PageSetPattern, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-2'], + }, + } satisfies PagePattern, + 'page-2': { + type: 'page', + id: 'page-2', + data: { + title: 'Page 2', + patterns: ['element-3', 'element-1'], + }, + } satisfies PagePattern, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-2': { + type: 'input', + id: 'element-2', + data: { + label: 'Pattern 2', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'element-3': { + type: 'input', + id: 'element-3', + data: { + label: 'Pattern 3', + initial: '', + required: true, + maxLength: 128, + }, + }, + }); + }); + + it('copy input pattern', () => { + const initial = createTestBlueprintMultipleFieldsets(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const parentPattern = getPattern(initial, 'page-1'); + const updatedParentPattern = getPattern( + builder.form, + 'page-1' + ); + const pattern = getPattern(builder.form, 'element-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const newPattern = builder.copyPattern(parentPattern.id, pattern.id); + + expect(builder.form).toEqual({ + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { + root: { type: 'page-set', id: 'root', data: { pages: ['page-1'] } }, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: [ + 'element-1', + newPattern.id, + 'form-summary-1', + 'fieldset-1', + 'radio-group-1', + ], + }, + }, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Input Pattern', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'form-summary-1': { + type: 'form-summary', + id: 'form-summary-1', + data: { + description: 'Form extended description', + title: 'Form title', + }, + }, + 'fieldset-1': { + type: 'fieldset', + id: 'fieldset-1', + data: { + legend: 'Fieldset pattern description', + patterns: ['element-2'], + }, + }, + 'radio-group-1': { + type: 'radio-group', + id: 'radio-group-1', + data: { + label: 'Radio group label', + options: [ + { id: 'option-1', label: 'Option 1' }, + { id: 'option-2', label: 'Option 2' }, + ], + }, + }, + [newPattern.id]: { + type: 'input', + id: newPattern.id, + data: { + label: expect.stringMatching( + /^\(\s*Copy\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M\)\s*Input Pattern/ + ), + initial: '', + required: true, + maxLength: 128, + }, + }, + }, + outputs: [], + }); + }); + + it('copy form summary pattern', () => { + const initial = createTestBlueprintMultipleFieldsets(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const parentPattern = getPattern(initial, 'page-1'); + const pattern = getPattern(builder.form, 'form-summary-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const newPattern = builder.copyPattern(parentPattern.id, pattern.id); + + expect(builder.form).toEqual({ + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { + root: { type: 'page-set', id: 'root', data: { pages: ['page-1'] } }, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: [ + 'element-1', + 'form-summary-1', + newPattern.id, + 'fieldset-1', + 'radio-group-1', + ], + }, + }, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Input Pattern', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'form-summary-1': { + type: 'form-summary', + id: 'form-summary-1', + data: { + description: 'Form extended description', + title: 'Form title', + }, + }, + 'fieldset-1': { + type: 'fieldset', + id: 'fieldset-1', + data: { + legend: 'Fieldset pattern description', + patterns: ['element-2'], + }, + }, + 'radio-group-1': { + type: 'radio-group', + id: 'radio-group-1', + data: { + label: 'Radio group label', + options: [ + { id: 'option-1', label: 'Option 1' }, + { id: 'option-2', label: 'Option 2' }, + ], + }, + }, + [newPattern.id]: { + type: 'form-summary', + id: newPattern.id, + data: { + description: 'Form extended description', + title: expect.stringMatching( + /^\(\s*Copy\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M\)\s*Form title/ + ), + }, + }, + }, + outputs: [], + }); + }); + + it('copy fieldset pattern', () => { + const initial = createTestBlueprintMultipleFieldsets(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const parentPattern = getPattern(initial, 'page-1'); + const updatedParentPattern = getPattern( + builder.form, + 'page-1' + ); + const pattern = getPattern(builder.form, 'fieldset-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const newPattern = builder.copyPattern(parentPattern.id, pattern.id); + + expect(builder.form).toEqual({ + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { + root: { type: 'page-set', id: 'root', data: { pages: ['page-1'] } }, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: [ + 'element-1', + 'form-summary-1', + 'fieldset-1', + newPattern.id, + 'radio-group-1', + ], + }, + }, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Input Pattern', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'form-summary-1': { + type: 'form-summary', + id: 'form-summary-1', + data: { + description: 'Form extended description', + title: 'Form title', + }, + }, + 'fieldset-1': { + type: 'fieldset', + id: 'fieldset-1', + data: { + legend: 'Fieldset pattern description', + patterns: ['element-2'], + }, + }, + 'radio-group-1': { + type: 'radio-group', + id: 'radio-group-1', + data: { + label: 'Radio group label', + options: [ + { id: 'option-1', label: 'Option 1' }, + { id: 'option-2', label: 'Option 2' }, + ], + }, + }, + [newPattern.id]: { + type: 'fieldset', + id: newPattern.id, + data: { + legend: expect.stringMatching( + /^\(\s*Copy\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M\)\s*Fieldset pattern description/ + ), + patterns: ['element-2'], + }, + }, + }, + outputs: [], + }); + }); + + it('copy radio group pattern', () => { + const initial = createTestBlueprintMultipleFieldsets(); + const builder = new BlueprintBuilder(defaultFormConfig, initial); + const parentPattern = getPattern(initial, 'page-1'); + const pattern = getPattern(builder.form, 'radio-group-1'); + expect(builder.form.patterns[pattern.id]).toEqual(pattern); + const newPattern = builder.copyPattern(parentPattern.id, pattern.id); + + expect(builder.form).toEqual({ + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { + root: { type: 'page-set', id: 'root', data: { pages: ['page-1'] } }, + 'page-1': { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: [ + 'element-1', + 'form-summary-1', + 'fieldset-1', + 'radio-group-1', + newPattern.id, + ], + }, + }, + 'element-1': { + type: 'input', + id: 'element-1', + data: { + label: 'Input Pattern', + initial: '', + required: true, + maxLength: 128, + }, + }, + 'form-summary-1': { + type: 'form-summary', + id: 'form-summary-1', + data: { + description: 'Form extended description', + title: 'Form title', + }, + }, + 'fieldset-1': { + type: 'fieldset', + id: 'fieldset-1', + data: { + legend: 'Fieldset pattern description', + patterns: ['element-2'], + }, + }, + 'radio-group-1': { + type: 'radio-group', + id: 'radio-group-1', + data: { + label: 'Radio group label', + options: [ + { id: 'option-1', label: 'Option 1' }, + { id: 'option-2', label: 'Option 2' }, + ], + }, + }, + [newPattern.id]: { + type: 'radio-group', + id: newPattern.id, + data: { + label: expect.stringMatching( + /^\(\s*Copy\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M\)\s*Radio group label/ + ), + options: [ + { id: 'option-1', label: 'Option 1' }, + { id: 'option-2', label: 'Option 2' }, + ], + }, + }, + }, + outputs: [], + }); + }); + it('removePattern removes pattern and sequence reference', () => { const initial = createTestBlueprint(); const builder = new BlueprintBuilder(defaultFormConfig, initial); @@ -108,3 +648,141 @@ export const createTestBlueprint = () => { } ); }; + +export const createTwoPageThreePatternTestForm = () => { + return createForm( + { + title: 'Test form', + description: 'Test description', + }, + { + root: 'root', + patterns: [ + { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1', 'page-2'], + }, + } satisfies PageSetPattern, + { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: ['element-1', 'element-2'], + }, + } satisfies PagePattern, + { + type: 'page', + id: 'page-2', + data: { + title: 'Page 2', + patterns: ['element-3'], + }, + } satisfies PagePattern, + { + type: 'input', + id: 'element-1', + data: { + label: 'Pattern 1', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + { + type: 'input', + id: 'element-2', + data: { + label: 'Pattern 2', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + { + type: 'input', + id: 'element-3', + data: { + label: 'Pattern 3', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + ], + } + ); +}; + +export const createTestBlueprintMultipleFieldsets = () => { + return createForm( + { + title: 'Test form', + description: 'Test description', + }, + { + root: 'root', + patterns: [ + { + type: 'page-set', + id: 'root', + data: { + pages: ['page-1'], + }, + } satisfies PageSetPattern, + { + type: 'page', + id: 'page-1', + data: { + title: 'Page 1', + patterns: [ + 'element-1', + 'form-summary-1', + 'fieldset-1', + 'radio-group-1', + ], + }, + } satisfies PagePattern, + { + type: 'input', + id: 'element-1', + data: { + label: 'Input Pattern', + initial: '', + required: true, + maxLength: 128, + }, + } satisfies InputPattern, + { + type: 'form-summary', + id: 'form-summary-1', + data: { + description: 'Form extended description', + title: 'Form title', + }, + } satisfies FormSummaryPattern, + { + type: 'fieldset', + id: 'fieldset-1', + data: { + legend: 'Fieldset pattern description', + patterns: ['element-2'], + }, + } satisfies FieldsetPattern, + { + type: 'radio-group', + id: 'radio-group-1', + data: { + label: 'Radio group label', + options: [ + { id: 'option-1', label: 'Option 1' }, + { id: 'option-2', label: 'Option 2' }, + ], + }, + } satisfies RadioGroupPattern, + ], + } + ); +}; diff --git a/packages/forms/src/builder/index.ts b/packages/forms/src/builder/index.ts index 12c297c5..09e49e7e 100644 --- a/packages/forms/src/builder/index.ts +++ b/packages/forms/src/builder/index.ts @@ -9,17 +9,19 @@ import { type PatternMap, addDocument, addPageToPageSet, + addPatternToFieldset, addPatternToPage, + copyPattern, createDefaultPattern, + createOnePageBlueprint, getPattern, + movePatternBetweenPages, removePatternFromBlueprint, updateFormSummary, updatePatternFromFormData, - createOnePageBlueprint, - addPatternToFieldset, -} from '..'; -import { type PageSetPattern } from '../patterns/page-set/config'; -import { FieldsetPattern } from '../patterns/fieldset'; +} from '../index.js'; +import { type PageSetPattern } from '../patterns/page-set/config.js'; +import { type FieldsetPattern } from '../patterns/fieldset/index.js'; export class BlueprintBuilder { bp: Blueprint; @@ -58,9 +60,47 @@ export class BlueprintBuilder { } const pagePatternId = root.data.pages[pageNum]; this.bp = addPatternToPage(this.form, pagePatternId, pattern); + + return pattern; + } + + movePatternBetweenPages( + sourcePageId: PatternId, + targetPageId: PatternId, + patternId: PatternId, + position: string + ) { + const pattern = getPattern(this.form, patternId); + if (!pattern) { + throw new Error(`Pattern with id ${patternId} not found.`); + } + const root = this.form.patterns[this.form.root] as PageSetPattern; + if (root.type !== 'page-set') { + throw new Error('expected root to be a page-set'); + } + + this.bp = movePatternBetweenPages( + this.form, + sourcePageId, + targetPageId, + patternId, + position + ); + return pattern; } + copyPattern(parentPatternId: PatternId, patternId: PatternId) { + const pattern = getPattern(this.form, patternId); + const root = this.form.patterns[this.form.root] as PageSetPattern; + if (root.type !== 'page-set') { + throw new Error('expected root to be a page-set'); + } + const results = copyPattern(this.form, parentPatternId, patternId); + this.bp = results.bp; + return results.pattern; + } + addPatternToFieldset(patternType: string, fieldsetPatternId: PatternId) { const pattern = createDefaultPattern(this.config, patternType); const root = this.form.patterns[fieldsetPatternId] as FieldsetPattern; diff --git a/packages/forms/src/components.ts b/packages/forms/src/components.ts index 02321fdc..345a1045 100644 --- a/packages/forms/src/components.ts +++ b/packages/forms/src/components.ts @@ -1,11 +1,11 @@ -import { type FormError, getRootPattern } from '.'; +import { type FormError, getRootPattern } from './index.js'; import { type FormConfig, type Pattern, type PatternId, getPatternConfig, -} from './pattern'; -import { type FormSession, nullSession, sessionIsComplete } from './session'; +} from './pattern.js'; +import { type FormSession, nullSession, sessionIsComplete } from './session.js'; export type TextInputProps = PatternProps<{ type: 'input'; diff --git a/packages/forms/src/config.ts b/packages/forms/src/config.ts deleted file mode 100644 index f77b1adc..00000000 --- a/packages/forms/src/config.ts +++ /dev/null @@ -1 +0,0 @@ -export { defaultFormConfig } from './patterns'; diff --git a/packages/forms/src/context/browser/form-repo.ts b/packages/forms/src/context/browser/form-repo.ts new file mode 100644 index 00000000..cdfbfcea --- /dev/null +++ b/packages/forms/src/context/browser/form-repo.ts @@ -0,0 +1,141 @@ +import { type Result, type VoidResult, failure } from '@atj/common'; + +import { type Blueprint } from '../../index.js'; +import { FormRepository } from '../../repository/index.js'; + +export class BrowserFormRepository implements FormRepository { + constructor(private storage: Storage) {} + + async addForm( + form: Blueprint + ): Promise> { + const uuid = crypto.randomUUID(); + + const result = await this.saveForm(uuid, form); + if (!result.success) { + return result; + } + + return { + success: true, + data: { + timestamp: new Date().toISOString(), + id: uuid, + }, + }; + } + + async deleteForm(formId: string): Promise { + this.storage.removeItem(formId); + return { success: true }; + } + + async getForm(id?: string): Promise { + if (!this.storage || !id) { + return null; + } + const formString = this.storage.getItem(id); + if (!formString) { + return null; + } + return parseStringForm(formString); + } + + async getFormList(): Promise< + { id: string; title: string; description: string }[] | null + > { + const forms = await getFormList(this.storage); + if (forms === null) { + return null; + } + return Promise.all( + forms.map(async key => { + const form = await this.getForm(key); + if (form === null) { + throw new Error('key mismatch'); + } + return { + id: key, + title: form.summary.title, + description: form.summary.description, + }; + }) + ); + } + + async saveForm(formId: string, form: Blueprint): Promise { + try { + this.storage.setItem(formId, stringifyForm(form)); + } catch { + return failure(`error saving '${formId}' to storage`); + } + return { success: true }; + } +} + +export const getFormList = (storage: Storage) => { + const keys = []; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key === null) { + return null; + } + keys.push(key); + } + return keys; +}; + +export const saveForm = (storage: Storage, formId: string, form: Blueprint) => { + try { + storage.setItem(formId, stringifyForm(form)); + } catch { + return { + success: false as const, + error: `error saving '${formId}' to storage`, + }; + } + return { + success: true as const, + }; +}; + +const stringifyForm = (form: Blueprint) => { + return JSON.stringify({ + ...form, + outputs: form.outputs.map(output => ({ + ...output, + // TODO: we probably want to do this somewhere in the documents module + data: uint8ArrayToBase64(output.data), + })), + }); +}; + +const parseStringForm = (formString: string): Blueprint => { + const form = JSON.parse(formString) as Blueprint; + return { + ...form, + outputs: form.outputs.map(output => ({ + ...output, + data: base64ToUint8Array((output as any).data), + })), + }; +}; + +const uint8ArrayToBase64 = (buffer: Uint8Array): string => { + let binary = ''; + const len = buffer.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary); +}; + +const base64ToUint8Array = (base64: string): Uint8Array => { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; diff --git a/packages/forms/src/service/context/browser/session-repo.ts b/packages/forms/src/context/browser/session-repo.ts similarity index 55% rename from packages/forms/src/service/context/browser/session-repo.ts rename to packages/forms/src/context/browser/session-repo.ts index 5c505ccc..cff4590f 100644 --- a/packages/forms/src/service/context/browser/session-repo.ts +++ b/packages/forms/src/context/browser/session-repo.ts @@ -1,19 +1,5 @@ import { type Result, type VoidResult } from '@atj/common'; -import { type FormSession } from '../../..'; - -export const getSessionFromStorage = ( - storage: Storage, - id?: string -): FormSession | null => { - if (!storage || !id) { - return null; - } - const sessionString = storage.getItem(id); - if (!sessionString) { - return null; - } - return parseStringSession(sessionString); -}; +import { type FormSession } from '../../index.js'; export const addFormToStorage = ( storage: Storage, @@ -32,7 +18,7 @@ export const addFormToStorage = ( }; }; -export const saveSessionToStorage = ( +const saveSessionToStorage = ( storage: Storage, sessionId: string, session: FormSession @@ -50,17 +36,6 @@ export const saveSessionToStorage = ( }; }; -export const deleteSessionFromStorage = ( - storage: Storage, - sessionId: string -) => { - storage.removeItem(sessionId); -}; - const stringifySession = (session: FormSession) => { return JSON.stringify(session); }; - -const parseStringSession = (sessionString: string): FormSession => { - return JSON.parse(sessionString) as FormSession; -}; diff --git a/packages/forms/src/context/index.ts b/packages/forms/src/context/index.ts new file mode 100644 index 00000000..910f3f6e --- /dev/null +++ b/packages/forms/src/context/index.ts @@ -0,0 +1,10 @@ +import { type FormConfig } from '../pattern.js'; +import { type FormRepository } from '../repository/index.js'; + +export { createTestBrowserFormService } from './test/index.js'; +export { BrowserFormRepository } from './browser/form-repo.js'; +export type FormServiceContext = { + repository: FormRepository; + config: FormConfig; + isUserLoggedIn: () => boolean; +}; diff --git a/packages/forms/src/context/test/index.ts b/packages/forms/src/context/test/index.ts new file mode 100644 index 00000000..6e4cc835 --- /dev/null +++ b/packages/forms/src/context/test/index.ts @@ -0,0 +1,24 @@ +import { BrowserFormRepository } from '../browser/form-repo.js'; +import { defaultFormConfig } from '../../patterns/index.js'; +import { type FormService, createFormService } from '../../services/index.js'; + +import { createTestStorage, type TestData } from './storage.js'; + +// In tests, use the browser form service with fakes injected. +export const createTestBrowserFormService = ( + testData: TestData = {} +): FormService => { + const storage = createTestStorage(testData); + const repository = new BrowserFormRepository(storage); + const formService = createFormService({ + repository, + config: defaultFormConfig, + isUserLoggedIn: () => true, + }); + if (testData) { + Object.entries(testData).forEach(([id, blueprint]) => { + formService.saveForm(id, blueprint); + }); + } + return formService; +}; diff --git a/packages/forms/src/service/context/test/storage.ts b/packages/forms/src/context/test/storage.ts similarity index 84% rename from packages/forms/src/service/context/test/storage.ts rename to packages/forms/src/context/test/storage.ts index 40a6238f..532c6c6f 100644 --- a/packages/forms/src/service/context/test/storage.ts +++ b/packages/forms/src/context/test/storage.ts @@ -1,5 +1,5 @@ -import { type Blueprint } from '../../..'; -import { saveFormToStorage } from '../browser/form-repo'; +import { type Blueprint } from '../../index.js'; +import { saveForm } from '../browser/form-repo.js'; export type TestData = Record; @@ -30,6 +30,6 @@ export const createTestStorage = (testData: TestData): Storage => { const populateStorage = (storage: Storage, testData: TestData) => { Object.entries(testData).forEach(([formId, form]) => { - saveFormToStorage(storage, formId, form); + saveForm(storage, formId, form); }); }; diff --git a/packages/forms/src/documents/__tests__/document.test.ts b/packages/forms/src/documents/__tests__/document.test.ts index 59612310..50ff3d44 100644 --- a/packages/forms/src/documents/__tests__/document.test.ts +++ b/packages/forms/src/documents/__tests__/document.test.ts @@ -3,14 +3,14 @@ */ import { describe, expect, it } from 'vitest'; -import { getPattern } from '../..'; -import { BlueprintBuilder } from '../../builder'; -import { defaultFormConfig } from '../../patterns'; -import { type PageSetPattern } from '../../patterns/page-set/config'; -import { type PagePattern } from '../../patterns/page/config'; +import { getPattern } from '../../index.js'; +import { BlueprintBuilder } from '../../builder/index.js'; +import { defaultFormConfig } from '../../patterns/index.js'; +import { type PageSetPattern } from '../../patterns/page-set/config.js'; +import { type PagePattern } from '../../patterns/page/config.js'; -import { addDocument } from '../document'; -import { loadSamplePDF } from './sample-data'; +import { addDocument } from '../document.js'; +import { loadSamplePDF } from './sample-data.js'; describe('addDocument document processing', () => { it('creates expected blueprint', async () => { @@ -26,7 +26,7 @@ describe('addDocument document processing', () => { }, { fetchPdfApiResponse: async () => { - const { mockResponse } = await import('../pdf/mock-response'); + const { mockResponse } = await import('../pdf/mock-response.js'); return mockResponse; }, } diff --git a/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts b/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts index b6e90f8f..1b828d4a 100644 --- a/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts +++ b/packages/forms/src/documents/__tests__/doj-pardon-marijuana.test.ts @@ -2,15 +2,15 @@ import { describe, expect, test } from 'vitest'; import { Success } from '@atj/common'; -import { type DocumentFieldMap } from '..'; -import { fillPDF, getDocumentFieldData } from '../pdf'; +import { type DocumentFieldMap } from '../index.js'; +import { fillPDF, getDocumentFieldData } from '../pdf/index.js'; -import { loadSamplePDF } from './sample-data'; +import { loadSamplePDF } from './sample-data.js'; describe('DOJ Pardon Attorney Office - Marijuana pardon application form', () => { /* test('works end-to-end', async () => { - const formService = createTestFormService(); + const formService = createTestBrowserFormService(); const builder = new BlueprintBuilder( defaultFormConfig, createForm({ diff --git a/packages/forms/src/documents/__tests__/dummy.test.ts b/packages/forms/src/documents/__tests__/dummy.test.ts index c0b46c91..bc2ba093 100644 --- a/packages/forms/src/documents/__tests__/dummy.test.ts +++ b/packages/forms/src/documents/__tests__/dummy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { generateDummyPDF } from '..'; +import { generateDummyPDF } from '../index.js'; describe('PDF document generation', () => { it('can produce a dummy PDF with a JSON dump of data', async () => { diff --git a/packages/forms/src/documents/__tests__/extract.test.ts b/packages/forms/src/documents/__tests__/extract.test.ts index 0ef62ed0..ccdaef1f 100644 --- a/packages/forms/src/documents/__tests__/extract.test.ts +++ b/packages/forms/src/documents/__tests__/extract.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { getDocumentFieldData } from '..'; -import { loadSamplePDF } from './sample-data'; +import { getDocumentFieldData } from '../index.js'; +import { loadSamplePDF } from './sample-data.js'; describe('PDF form field extraction', () => { it('extracts data from California UD-105 form', async () => { diff --git a/packages/forms/src/documents/__tests__/fill-pdf.test.ts b/packages/forms/src/documents/__tests__/fill-pdf.test.ts index 3c8da891..8fbc5068 100644 --- a/packages/forms/src/documents/__tests__/fill-pdf.test.ts +++ b/packages/forms/src/documents/__tests__/fill-pdf.test.ts @@ -2,8 +2,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { type Failure, type Success } from '@atj/common'; -import { getDocumentFieldData, fillPDF } from '..'; -import { loadSamplePDF } from './sample-data'; +import { getDocumentFieldData, fillPDF } from '../index.js'; +import { loadSamplePDF } from './sample-data.js'; describe('PDF form filler', () => { let pdfBytes: Uint8Array; diff --git a/packages/forms/src/documents/__tests__/sample-data.ts b/packages/forms/src/documents/__tests__/sample-data.ts index e122d9d8..cd21b7bb 100644 --- a/packages/forms/src/documents/__tests__/sample-data.ts +++ b/packages/forms/src/documents/__tests__/sample-data.ts @@ -1,7 +1,11 @@ -import path from 'path'; import { promises as fs } from 'fs'; +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; -const samplesDirectory = path.resolve(__dirname, '../../../sample-documents'); +const samplesDirectory = path.resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../sample-documents' +); export const loadSamplePDF = async (fileName: `${string}.pdf`) => { const samplePdfPath = path.join(samplesDirectory, fileName); diff --git a/packages/forms/src/documents/document.ts b/packages/forms/src/documents/document.ts index 351bbb1a..03fcba03 100644 --- a/packages/forms/src/documents/document.ts +++ b/packages/forms/src/documents/document.ts @@ -5,16 +5,16 @@ import { addPatterns, addPatternMap, updateFormSummary, -} from '..'; -import { InputPattern } from '../patterns/input'; -import { SequencePattern } from '../patterns/sequence'; -import { PDFDocument, getDocumentFieldData } from './pdf'; +} from '../index.js'; +import { InputPattern } from '../patterns/input/index.js'; +import { SequencePattern } from '../patterns/sequence.js'; +import { PDFDocument, getDocumentFieldData } from './pdf/index.js'; import { type FetchPdfApiResponse, processApiResponse, fetchPdfApiResponse, -} from './pdf/parsing-api'; -import { DocumentFieldMap } from './types'; +} from './pdf/parsing-api.js'; +import { DocumentFieldMap } from './types.js'; export type DocumentTemplate = PDFDocument; diff --git a/packages/forms/src/documents/index.ts b/packages/forms/src/documents/index.ts index dfc68596..659458de 100644 --- a/packages/forms/src/documents/index.ts +++ b/packages/forms/src/documents/index.ts @@ -1,6 +1,6 @@ -export * from './document'; -export * from './pdf'; -export * from './types'; +export * from './document.js'; +export * from './pdf/index.js'; +export * from './types.js'; export const SAMPLE_DOCUMENTS = [ { diff --git a/packages/forms/src/documents/pdf/extract.ts b/packages/forms/src/documents/pdf/extract.ts index a0f0ad9d..6b619fe7 100644 --- a/packages/forms/src/documents/pdf/extract.ts +++ b/packages/forms/src/documents/pdf/extract.ts @@ -10,8 +10,8 @@ import { PDFRadioGroup, } from 'pdf-lib'; -import { stringToBase64 } from '../util'; -import type { DocumentFieldValue, DocumentFieldMap } from '../types'; +import { stringToBase64 } from '../util.js'; +import type { DocumentFieldValue, DocumentFieldMap } from '../types.js'; // TODO: copied from pdf-lib acrofield internals, check if it's already exposed outside of acroform somewhere export const getWidgets = async (pdfDoc: PDFDocument): Promise => { diff --git a/packages/forms/src/documents/pdf/generate.ts b/packages/forms/src/documents/pdf/generate.ts index dabfa55a..c563ddbe 100644 --- a/packages/forms/src/documents/pdf/generate.ts +++ b/packages/forms/src/documents/pdf/generate.ts @@ -1,8 +1,8 @@ import { PDFDocument, type PDFForm } from 'pdf-lib'; import { Result } from '@atj/common'; -import { type FormOutput } from '../..'; -import { type PDFFieldType } from '.'; +import { type FormOutput } from '../../index.js'; +import { type PDFFieldType } from './index.js'; export const createFormOutputFieldData = ( output: FormOutput, diff --git a/packages/forms/src/documents/pdf/index.ts b/packages/forms/src/documents/pdf/index.ts index b2b6f5c8..e0dc44e7 100644 --- a/packages/forms/src/documents/pdf/index.ts +++ b/packages/forms/src/documents/pdf/index.ts @@ -1,6 +1,6 @@ -export { getDocumentFieldData } from './extract'; -export * from './generate'; -export { generateDummyPDF } from './generate-dummy'; +export { getDocumentFieldData } from './extract.js'; +export * from './generate.js'; +export { generateDummyPDF } from './generate-dummy.js'; export type PDFDocument = { type: 'pdf'; diff --git a/packages/forms/src/documents/pdf/parsing-api.ts b/packages/forms/src/documents/pdf/parsing-api.ts index 7d886220..f8329e87 100644 --- a/packages/forms/src/documents/pdf/parsing-api.ts +++ b/packages/forms/src/documents/pdf/parsing-api.ts @@ -8,19 +8,18 @@ import { type PatternMap, createPattern, defaultFormConfig, -} from '../..'; +} from '../../index.js'; -import { type FieldsetPattern } from '../../patterns/fieldset'; -import { type InputPattern } from '../../patterns/input'; -import { type ParagraphPattern } from '../../patterns/paragraph'; -import { type CheckboxPattern } from '../../patterns/checkbox'; -import { type RadioGroupPattern } from '../../patterns/radio-group'; -import { type FormSummary } from '../../patterns/form-summary'; +import { type FieldsetPattern } from '../../patterns/fieldset/index.js'; +import { type InputPattern } from '../../patterns/input/index.js'; +import { type ParagraphPattern } from '../../patterns/paragraph.js'; +import { type CheckboxPattern } from '../../patterns/checkbox.js'; +import { type RadioGroupPattern } from '../../patterns/radio-group.js'; -import { uint8ArrayToBase64 } from '../util'; -import { type DocumentFieldMap } from '../types'; -import { PagePattern } from '../../patterns/page/config'; -import { PageSetPattern } from '../../patterns/page-set/config'; +import { uint8ArrayToBase64 } from '../util.js'; +import { type DocumentFieldMap } from '../types.js'; +import { PagePattern } from '../../patterns/page/config.js'; +import { PageSetPattern } from '../../patterns/page-set/config.js'; /** API v1 response format * // formSummary json diff --git a/packages/forms/src/index.ts b/packages/forms/src/index.ts index 68edf48e..a090c080 100644 --- a/packages/forms/src/index.ts +++ b/packages/forms/src/index.ts @@ -1,5 +1,4 @@ -import { type SequencePattern } from './patterns/sequence'; -import { type DocumentFieldMap } from './documents'; +import { type DocumentFieldMap } from './documents/index.js'; import { type FormConfig, type Pattern, @@ -8,20 +7,27 @@ import { getPatternMap, removeChildPattern, generatePatternId, -} from './pattern'; -import { type PagePattern } from './patterns/page/config'; -import { type PageSetPattern } from './patterns/page-set/config'; -import { FieldsetPattern } from './patterns/fieldset'; - -export * from './builder'; -export * from './components'; -export * from './config'; -export * from './documents'; -export * from './error'; -export * from './pattern'; -export * from './response'; -export * from './session'; -export * as service from './service'; +} from './pattern.js'; + +export * from './patterns/index.js'; +export * from './builder/index.js'; +export * from './components.js'; +export * from './documents/index.js'; +export * from './error.js'; +export * from './pattern.js'; +export * from './response.js'; +export * from './session.js'; +export { type FormService, createFormService } from './services/index.js'; +export { defaultFormConfig } from './patterns/index.js'; +import { type PagePattern } from './patterns/page/config.js'; +import { type PageSetPattern } from './patterns/page-set/config.js'; +import { FieldsetPattern } from './patterns/fieldset/index.js'; +import { type SequencePattern } from './patterns/sequence.js'; +export { + type FormRepository, + createFormsRepository, +} from './repository/index.js'; +export { type RouteData, getRouteDataFromQueryString } from './route-data.js'; export type Blueprint = { summary: FormSummary; @@ -190,12 +196,26 @@ export const addPatterns = ( export const addPatternToPage = ( bp: Blueprint, pagePatternId: PatternId, - pattern: Pattern + pattern: Pattern, + index?: number ): Blueprint => { const pagePattern = bp.patterns[pagePatternId] as PagePattern; if (pagePattern.type !== 'page') { throw new Error('Pattern is not a page.'); } + + let updatedPagePattern: PatternId[]; + + if (index !== undefined) { + updatedPagePattern = [ + ...pagePattern.data.patterns.slice(0, index + 1), + pattern.id, + ...pagePattern.data.patterns.slice(index + 1), + ]; + } else { + updatedPagePattern = [...pagePattern.data.patterns, pattern.id]; + } + return { ...bp, patterns: { @@ -204,7 +224,7 @@ export const addPatternToPage = ( ...pagePattern, data: { ...pagePattern.data, - patterns: [...pagePattern.data.patterns, pattern.id], + patterns: updatedPagePattern, }, } satisfies SequencePattern, [pattern.id]: pattern, @@ -212,15 +232,273 @@ export const addPatternToPage = ( }; }; +export const movePatternBetweenPages = ( + bp: Blueprint, + sourcePageId: PatternId, + targetPageId: PatternId, + patternId: PatternId, + position: string +): Blueprint => { + const sourcePage = bp.patterns[sourcePageId] as PagePattern; + const targetPage = bp.patterns[targetPageId] as PagePattern; + + if (!sourcePage || !targetPage) { + throw new Error('Source or target page not found.'); + } + + if (sourcePage.type !== 'page' || targetPage.type !== 'page') { + throw new Error('Pattern is not a page.'); + } + + let updatedSourcePatterns: PatternId[]; + let updatedTargetPatterns: PatternId[]; + + if (sourcePageId === targetPageId) { + const sourcePagePatterns = sourcePage.data.patterns; + const indexToRemove = sourcePagePatterns.indexOf(patternId); + + if (indexToRemove === -1) { + throw new Error(`Pattern ID ${patternId} not found in the source page.`); + } + + updatedSourcePatterns = [ + ...sourcePagePatterns.slice(0, indexToRemove), + ...sourcePagePatterns.slice(indexToRemove + 1), + ]; + + updatedTargetPatterns = + position === 'top' + ? [patternId, ...updatedSourcePatterns] + : [...updatedSourcePatterns, patternId]; + } else { + const indexToRemove = sourcePage.data.patterns.indexOf(patternId); + + if (indexToRemove === -1) { + throw new Error(`Pattern ID ${patternId} not found in the source page.`); + } + + updatedSourcePatterns = [ + ...sourcePage.data.patterns.slice(0, indexToRemove), + ...sourcePage.data.patterns.slice(indexToRemove + 1), + ]; + + updatedTargetPatterns = + position === 'top' + ? [patternId, ...targetPage.data.patterns] + : [...targetPage.data.patterns, patternId]; + } + + return { + ...bp, + patterns: { + ...bp.patterns, + [sourcePageId]: { + ...sourcePage, + data: { + ...sourcePage.data, + patterns: updatedSourcePatterns, + }, + } satisfies PagePattern, + [targetPageId]: { + ...targetPage, + data: { + ...targetPage.data, + patterns: updatedTargetPatterns, + }, + } satisfies PagePattern, + }, + }; +}; + +export const copyPattern = ( + bp: Blueprint, + parentPatternId: PatternId, + patternId: PatternId +): { bp: Blueprint; pattern: Pattern } => { + const pattern = bp.patterns[patternId]; + if (!pattern) { + throw new Error(`Pattern with id ${patternId} not found`); + } + + const copySimplePattern = (pattern: Pattern): Pattern => { + const newId = generatePatternId(); + const currentDate = new Date(); + const dateString = currentDate.toLocaleString(); + const newPattern: Pattern = { + ...pattern, + id: newId, + data: { ...pattern.data }, + }; + + if (newPattern.type === 'form-summary') { + newPattern.data.title = `(Copy ${dateString}) ${newPattern.data.title || ''}`; + } else if ( + newPattern.type === 'input' || + newPattern.type === 'radio-group' || + newPattern.type === 'checkbox' + ) { + newPattern.data.label = `(Copy ${dateString}) ${newPattern.data.label || ''}`; + } else { + newPattern.data.text = `(Copy ${dateString}) ${newPattern.data.text || ''}`; + } + + return newPattern; + }; + + const copyFieldsetPattern = (pattern: Pattern): Pattern => { + const newId = generatePatternId(); + const currentDate = new Date(); + const dateString = currentDate.toLocaleString(); + const newPattern: Pattern = { + ...pattern, + id: newId, + data: { ...pattern.data }, + }; + + if (newPattern.type === 'fieldset') { + newPattern.data.legend = `(Copy ${dateString}) ${newPattern.data.legend || ''}`; + } + + return newPattern; + }; + + const findParentFieldset = ( + bp: Blueprint, + childId: PatternId + ): PatternId | null => { + for (const [id, pattern] of Object.entries(bp.patterns)) { + if ( + pattern.type === 'fieldset' && + pattern.data.patterns.includes(childId) + ) { + return id as PatternId; + } + } + return null; + }; + + const copyFieldsetContents = ( + bp: Blueprint, + originalFieldsetId: PatternId, + newFieldsetId: PatternId + ): Blueprint => { + const originalFieldset = bp.patterns[originalFieldsetId] as FieldsetPattern; + const newFieldset = bp.patterns[newFieldsetId] as FieldsetPattern; + let updatedBp = { ...bp }; + + const idMap = new Map(); + + for (const childId of originalFieldset.data.patterns) { + const childPattern = updatedBp.patterns[childId]; + if (childPattern) { + const newChildPattern = copyFieldsetPattern(childPattern); + idMap.set(childId, newChildPattern.id); + + updatedBp = { + ...updatedBp, + patterns: { + ...updatedBp.patterns, + [newChildPattern.id]: newChildPattern, + }, + }; + + if (childPattern.type === 'fieldset') { + updatedBp = copyFieldsetContents( + updatedBp, + childId, + newChildPattern.id + ); + } + } + } + + newFieldset.data.patterns = originalFieldset.data.patterns.map( + id => idMap.get(id) || id + ); + + updatedBp = { + ...updatedBp, + patterns: { + ...updatedBp.patterns, + [newFieldsetId]: newFieldset, + }, + }; + + return updatedBp; + }; + + let updatedBp = { ...bp }; + let newPattern = pattern; + + if (pattern.type === 'fieldset') { + newPattern = copyFieldsetPattern(pattern); + } else { + newPattern = copySimplePattern(pattern); + } + + const actualParentId = findParentFieldset(bp, patternId) || parentPatternId; + const actualParent = updatedBp.patterns[actualParentId]; + + if ( + !actualParent || + !actualParent.data || + !Array.isArray(actualParent.data.patterns) + ) { + throw new Error(`Invalid parent pattern with id ${actualParentId}`); + } + + const originalIndex = actualParent.data.patterns.indexOf(patternId); + if (originalIndex === -1) { + throw new Error( + `Pattern with id ${patternId} not found in parent's patterns` + ); + } + + actualParent.data.patterns = [ + ...actualParent.data.patterns.slice(0, originalIndex + 1), + newPattern.id, + ...actualParent.data.patterns.slice(originalIndex + 1), + ]; + + updatedBp = { + ...updatedBp, + patterns: { + ...updatedBp.patterns, + [newPattern.id]: newPattern, + [actualParentId]: actualParent, + }, + }; + + if (pattern.type === 'fieldset') { + updatedBp = copyFieldsetContents(updatedBp, patternId, newPattern.id); + } + + return { bp: updatedBp, pattern: newPattern }; +}; + export const addPatternToFieldset = ( bp: Blueprint, fieldsetPatternId: PatternId, - pattern: Pattern + pattern: Pattern, + index?: number ): Blueprint => { const fieldsetPattern = bp.patterns[fieldsetPatternId] as FieldsetPattern; if (fieldsetPattern.type !== 'fieldset') { throw new Error('Pattern is not a page.'); } + + let updatedPagePattern: PatternId[]; + + if (index !== undefined) { + updatedPagePattern = [ + ...fieldsetPattern.data.patterns.slice(0, index + 1), + pattern.id, + ...fieldsetPattern.data.patterns.slice(index + 1), + ]; + } else { + updatedPagePattern = [...fieldsetPattern.data.patterns, pattern.id]; + } + return { ...bp, patterns: { @@ -229,7 +507,7 @@ export const addPatternToFieldset = ( ...fieldsetPattern, data: { ...fieldsetPattern.data, - patterns: [...fieldsetPattern.data.patterns, pattern.id], + patterns: updatedPagePattern, }, } satisfies FieldsetPattern, [pattern.id]: pattern, @@ -346,6 +624,7 @@ export const removePatternFromBlueprint = ( }, {} as PatternMap ); + // Remove the pattern itself delete patterns[id]; return { diff --git a/packages/forms/src/pattern.ts b/packages/forms/src/pattern.ts index b4dbf08a..dcd1cb64 100644 --- a/packages/forms/src/pattern.ts +++ b/packages/forms/src/pattern.ts @@ -4,9 +4,9 @@ import { type FormError, type FormErrors, updatePattern, -} from '.'; +} from './index.js'; -import { type CreatePrompt } from './components'; +import { type CreatePrompt } from './components.js'; export type Pattern = { type: string; diff --git a/packages/forms/src/patterns/address/index.ts b/packages/forms/src/patterns/address/index.ts index afb06958..05d72b39 100644 --- a/packages/forms/src/patterns/address/index.ts +++ b/packages/forms/src/patterns/address/index.ts @@ -1,16 +1,16 @@ import * as z from 'zod'; -import { type Pattern, type PatternConfig } from '../../pattern'; -import { PatternProps } from '../../components'; +import { type Pattern, type PatternConfig } from '../../pattern.js'; +import { PatternProps } from '../../components.js'; import { safeZodParseFormErrors, safeZodParseToFormError, -} from '../../util/zod'; +} from '../../util/zod.js'; import { stateTerritoryOrMilitaryPostAbbreviations, stateTerritoryOrMilitaryPostList, -} from './jurisdictions'; -import { getFormSessionValue } from '../../session'; +} from './jurisdictions.js'; +import { getFormSessionValue } from '../../session.js'; export type AddressPattern = Pattern<{}>; diff --git a/packages/forms/src/patterns/checkbox.ts b/packages/forms/src/patterns/checkbox.ts index 48926154..db6788fb 100644 --- a/packages/forms/src/patterns/checkbox.ts +++ b/packages/forms/src/patterns/checkbox.ts @@ -1,9 +1,16 @@ import * as z from 'zod'; -import { type Pattern, type PatternConfig, validatePattern } from '../pattern'; -import { type CheckboxProps } from '../components'; -import { getFormSessionError, getFormSessionValue } from '../session'; -import { safeZodParseFormErrors, safeZodParseToFormError } from '../util/zod'; +import { + type Pattern, + type PatternConfig, + validatePattern, +} from '../pattern.js'; +import { type CheckboxProps } from '../components.js'; +import { getFormSessionValue } from '../session.js'; +import { + safeZodParseFormErrors, + safeZodParseToFormError, +} from '../util/zod.js'; const configSchema = z.object({ label: z.string().min(1), diff --git a/packages/forms/src/patterns/fieldset/config.ts b/packages/forms/src/patterns/fieldset/config.ts index 00d852dc..cf09c85a 100644 --- a/packages/forms/src/patterns/fieldset/config.ts +++ b/packages/forms/src/patterns/fieldset/config.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { safeZodParseFormErrors } from '../../util/zod'; -import { ParsePatternConfigData } from '../../pattern'; +import { safeZodParseFormErrors } from '../../util/zod.js'; +import { ParsePatternConfigData } from '../../pattern.js'; const configSchema = z.object({ legend: z.string().min(1), diff --git a/packages/forms/src/patterns/fieldset/index.ts b/packages/forms/src/patterns/fieldset/index.ts index a70692b7..c095046c 100644 --- a/packages/forms/src/patterns/fieldset/index.ts +++ b/packages/forms/src/patterns/fieldset/index.ts @@ -2,9 +2,9 @@ import { type Pattern, type PatternConfig, type PatternId, -} from '../../pattern'; -import { parseConfigData } from './config'; -import { createPrompt } from './prompt'; +} from '../../pattern.js'; +import { parseConfigData } from './config.js'; +import { createPrompt } from './prompt.js'; export type FieldsetPattern = Pattern<{ legend?: string; diff --git a/packages/forms/src/patterns/fieldset/prompt.ts b/packages/forms/src/patterns/fieldset/prompt.ts index 7b33d364..977cb56d 100644 --- a/packages/forms/src/patterns/fieldset/prompt.ts +++ b/packages/forms/src/patterns/fieldset/prompt.ts @@ -1,10 +1,10 @@ -import { type FieldsetPattern } from '.'; +import { type FieldsetPattern } from './index.js'; import { type CreatePrompt, type FieldsetProps, createPromptForPattern, getPattern, -} from '../..'; +} from '../../index.js'; export const createPrompt: CreatePrompt = ( config, diff --git a/packages/forms/src/patterns/form-summary.ts b/packages/forms/src/patterns/form-summary.ts index 7f696fe5..8c551639 100644 --- a/packages/forms/src/patterns/form-summary.ts +++ b/packages/forms/src/patterns/form-summary.ts @@ -1,16 +1,16 @@ import * as z from 'zod'; -import { type Pattern, type PatternConfig } from '../pattern'; -import { type FormSummaryProps } from '../components'; -import { safeZodParseFormErrors } from '../util/zod'; +import { type Pattern, type PatternConfig } from '../pattern.js'; +import { type FormSummaryProps } from '../components.js'; +import { safeZodParseFormErrors } from '../util/zod.js'; const configSchema = z.object({ title: z.string().max(128).min(1, 'Title is required'), description: z.string().max(2024), }); -export type FormSummary = Pattern>; +export type FormSummaryPattern = Pattern>; -export const formSummaryConfig: PatternConfig = { +export const formSummaryConfig: PatternConfig = { displayName: 'Form summary', iconPath: 'block-icon.svg', initial: { diff --git a/packages/forms/src/patterns/index.ts b/packages/forms/src/patterns/index.ts index 60accbbc..3ffdaa8b 100644 --- a/packages/forms/src/patterns/index.ts +++ b/packages/forms/src/patterns/index.ts @@ -1,15 +1,15 @@ -import { type FormConfig } from '../pattern'; +import { type FormConfig } from '../pattern.js'; -import { addressConfig } from './address'; -import { checkboxConfig } from './checkbox'; -import { fieldsetConfig } from './fieldset'; -import { formSummaryConfig } from './form-summary'; -import { inputConfig } from './input'; -import { pageConfig } from './page'; -import { pageSetConfig } from './page-set'; -import { paragraphConfig } from './paragraph'; -import { radioGroupConfig } from './radio-group'; -import { sequenceConfig } from './sequence'; +import { addressConfig } from './address/index.js'; +import { checkboxConfig } from './checkbox.js'; +import { fieldsetConfig } from './fieldset/index.js'; +import { formSummaryConfig } from './form-summary.js'; +import { inputConfig } from './input/index.js'; +import { pageConfig } from './page/index.js'; +import { pageSetConfig } from './page-set/index.js'; +import { paragraphConfig } from './paragraph.js'; +import { radioGroupConfig } from './radio-group.js'; +import { sequenceConfig } from './sequence.js'; // This configuration reflects what a user of this library would provide for // their usage scenarios. For now, keep here in the form service until we @@ -28,3 +28,16 @@ export const defaultFormConfig: FormConfig = { sequence: sequenceConfig, }, } as const; + +export * from './address/index.js'; +export * from './fieldset/index.js'; +export * from './input/index.js'; +export * from './page/index.js'; +export { type PagePattern } from './page/config.js'; +export * from './page-set/index.js'; +export { type PageSetPattern } from './page-set/config.js'; +export * from './checkbox.js'; +export * from './form-summary.js'; +export * from './paragraph.js'; +export * from './radio-group.js'; +export * from './sequence.js'; diff --git a/packages/forms/src/patterns/input/config.ts b/packages/forms/src/patterns/input/config.ts index 882613c0..a64c820a 100644 --- a/packages/forms/src/patterns/input/config.ts +++ b/packages/forms/src/patterns/input/config.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; -import { en as message } from '@atj/common/src/locales/en/app'; +import { enLocale as message } from '@atj/common'; -import { ParsePatternConfigData } from '../../pattern'; -import { safeZodParseFormErrors } from '../../util/zod'; +import { ParsePatternConfigData } from '../../pattern.js'; +import { safeZodParseFormErrors } from '../../util/zod.js'; const configSchema = z.object({ label: z.string().min(1, message.patterns.input.fieldLabelRequired), diff --git a/packages/forms/src/patterns/input/index.ts b/packages/forms/src/patterns/input/index.ts index d2d3d702..1ed4f046 100644 --- a/packages/forms/src/patterns/input/index.ts +++ b/packages/forms/src/patterns/input/index.ts @@ -1,10 +1,10 @@ -import { en as message } from '@atj/common/src/locales/en/app'; +import { enLocale as message } from '@atj/common'; -import { Pattern, type PatternConfig } from '../../pattern'; +import { Pattern, type PatternConfig } from '../../pattern.js'; -import { parseConfigData, type InputConfigSchema } from './config'; -import { createPrompt } from './prompt'; -import { type InputPatternOutput, parseUserInput } from './response'; +import { parseConfigData, type InputConfigSchema } from './config.js'; +import { createPrompt } from './prompt.js'; +import { type InputPatternOutput, parseUserInput } from './response.js'; export type InputPattern = Pattern; diff --git a/packages/forms/src/patterns/input/prompt.ts b/packages/forms/src/patterns/input/prompt.ts index 696acc8e..ff92755e 100644 --- a/packages/forms/src/patterns/input/prompt.ts +++ b/packages/forms/src/patterns/input/prompt.ts @@ -1,10 +1,10 @@ -import { type InputPattern, inputConfig } from '.'; +import { type InputPattern, inputConfig } from './index.js'; import { type CreatePrompt, type TextInputProps, getFormSessionValue, validatePattern, -} from '../..'; +} from '../../index.js'; export const createPrompt: CreatePrompt = ( _, diff --git a/packages/forms/src/patterns/input/response.ts b/packages/forms/src/patterns/input/response.ts index 99c96852..30ac0b64 100644 --- a/packages/forms/src/patterns/input/response.ts +++ b/packages/forms/src/patterns/input/response.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; -import { ParseUserInput } from '../../pattern'; -import { safeZodParseToFormError } from '../../util/zod'; +import { ParseUserInput } from '../../pattern.js'; +import { safeZodParseToFormError } from '../../util/zod.js'; -import { type InputPattern } from '.'; +import { type InputPattern } from './index.js'; const createSchema = (data: InputPattern['data']) => { const schema = z.string().max(data.maxLength); diff --git a/packages/forms/src/patterns/page-set/config.ts b/packages/forms/src/patterns/page-set/config.ts index 5e9df08e..0e8a7423 100644 --- a/packages/forms/src/patterns/page-set/config.ts +++ b/packages/forms/src/patterns/page-set/config.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { type Pattern, type ParsePatternConfigData } from '../../pattern'; -import { safeZodParseFormErrors } from '../../util/zod'; +import { type Pattern, type ParsePatternConfigData } from '../../pattern.js'; +import { safeZodParseFormErrors } from '../../util/zod.js'; const configSchema = z.object({ pages: z.array(z.string()), diff --git a/packages/forms/src/patterns/page-set/index.ts b/packages/forms/src/patterns/page-set/index.ts index 7908a42c..556926ac 100644 --- a/packages/forms/src/patterns/page-set/index.ts +++ b/packages/forms/src/patterns/page-set/index.ts @@ -1,7 +1,7 @@ -import { type PatternConfig } from '../../pattern'; +import { type PatternConfig } from '../../pattern.js'; -import { type PageSetPattern, parseConfigData } from './config'; -import { createPrompt } from './prompt'; +import { type PageSetPattern, parseConfigData } from './config.js'; +import { createPrompt } from './prompt.js'; export const pageSetConfig: PatternConfig = { displayName: 'Page set', diff --git a/packages/forms/src/patterns/page-set/prompt.ts b/packages/forms/src/patterns/page-set/prompt.ts index 81fa4d6d..b19ef2d9 100644 --- a/packages/forms/src/patterns/page-set/prompt.ts +++ b/packages/forms/src/patterns/page-set/prompt.ts @@ -6,12 +6,12 @@ import { type PromptAction, createPromptForPattern, getPattern, -} from '../..'; -import { type RouteData } from '../../route-data'; -import { safeZodParseFormErrors } from '../../util/zod'; +} from '../../index.js'; +import { type RouteData } from '../../route-data.js'; +import { safeZodParseFormErrors } from '../../util/zod.js'; -import { type PageSetPattern } from './config'; -import { type PagePattern } from '../page/config'; +import { type PageSetPattern } from './config.js'; +import { type PagePattern } from '../page/config.js'; export const createPrompt: CreatePrompt = ( config, diff --git a/packages/forms/src/patterns/page/config.ts b/packages/forms/src/patterns/page/config.ts index a9e5782c..335737d5 100644 --- a/packages/forms/src/patterns/page/config.ts +++ b/packages/forms/src/patterns/page/config.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { type Pattern, type ParsePatternConfigData } from '../../pattern'; -import { safeZodParseFormErrors } from '../../util/zod'; +import { type Pattern, type ParsePatternConfigData } from '../../pattern.js'; +import { safeZodParseFormErrors } from '../../util/zod.js'; const configSchema = z.object({ title: z.string(), diff --git a/packages/forms/src/patterns/page/index.ts b/packages/forms/src/patterns/page/index.ts index 7bdbad44..36421231 100644 --- a/packages/forms/src/patterns/page/index.ts +++ b/packages/forms/src/patterns/page/index.ts @@ -1,7 +1,7 @@ -import { type PatternConfig } from '../../pattern'; +import { type PatternConfig } from '../../pattern.js'; -import { type PagePattern, parseConfigData } from './config'; -import { createPrompt } from './prompt'; +import { type PagePattern, parseConfigData } from './config.js'; +import { createPrompt } from './prompt.js'; export const pageConfig: PatternConfig = { displayName: 'Page', diff --git a/packages/forms/src/patterns/page/prompt.ts b/packages/forms/src/patterns/page/prompt.ts index 0af8930b..ee0fb996 100644 --- a/packages/forms/src/patterns/page/prompt.ts +++ b/packages/forms/src/patterns/page/prompt.ts @@ -3,9 +3,9 @@ import { type PageProps, createPromptForPattern, getPattern, -} from '../..'; +} from '../../index.js'; -import { type PagePattern } from './config'; +import { type PagePattern } from './config.js'; export const createPrompt: CreatePrompt = ( config, diff --git a/packages/forms/src/patterns/paragraph.ts b/packages/forms/src/patterns/paragraph.ts index 2311e12b..103aa765 100644 --- a/packages/forms/src/patterns/paragraph.ts +++ b/packages/forms/src/patterns/paragraph.ts @@ -1,8 +1,8 @@ import * as z from 'zod'; -import { type Pattern, type PatternConfig } from '../pattern'; -import { type ParagraphProps } from '../components'; -import { safeZodParseFormErrors } from '../util/zod'; +import { type Pattern, type PatternConfig } from '../pattern.js'; +import { type ParagraphProps } from '../components.js'; +import { safeZodParseFormErrors } from '../util/zod.js'; const configSchema = z.object({ text: z.string().min(1), diff --git a/packages/forms/src/patterns/radio-group.ts b/packages/forms/src/patterns/radio-group.ts index c0721199..e66af39f 100644 --- a/packages/forms/src/patterns/radio-group.ts +++ b/packages/forms/src/patterns/radio-group.ts @@ -2,11 +2,15 @@ import * as z from 'zod'; import { Result } from '@atj/common'; -import { type RadioGroupProps } from '../components'; -import { type FormError } from '../error'; -import { type Pattern, type PatternConfig, validatePattern } from '../pattern'; -import { getFormSessionValue } from '../session'; -import { safeZodParseFormErrors } from '../util/zod'; +import { type RadioGroupProps } from '../components.js'; +import { type FormError } from '../error.js'; +import { + type Pattern, + type PatternConfig, + validatePattern, +} from '../pattern.js'; +import { getFormSessionValue } from '../session.js'; +import { safeZodParseFormErrors } from '../util/zod.js'; const configSchema = z.object({ label: z.string().min(1), diff --git a/packages/forms/src/patterns/sequence.ts b/packages/forms/src/patterns/sequence.ts index 6254958f..7222629a 100644 --- a/packages/forms/src/patterns/sequence.ts +++ b/packages/forms/src/patterns/sequence.ts @@ -5,9 +5,9 @@ import { type PatternConfig, type PatternId, getPattern, -} from '../pattern'; -import { type SequenceProps, createPromptForPattern } from '../components'; -import { safeZodParseFormErrors } from '../util/zod'; +} from '../pattern.js'; +import { type SequenceProps, createPromptForPattern } from '../components.js'; +import { safeZodParseFormErrors } from '../util/zod.js'; export type SequencePattern = Pattern<{ patterns: PatternId[]; diff --git a/packages/forms/src/repository/add-form.test.ts b/packages/forms/src/repository/add-form.test.ts new file mode 100644 index 00000000..17f8b5c7 --- /dev/null +++ b/packages/forms/src/repository/add-form.test.ts @@ -0,0 +1,41 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; +import { addForm } from './add-form.js'; + +describeDatabase('add form', () => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('works', async ({ db }) => { + const result = await addForm(db.ctx, testForm); + if (result.success === false) { + expect.fail('addForm failed'); + } + expect(result.data.timestamp).toEqual(today.toISOString()); + + const kysely = await db.ctx.getKysely(); + const insertedForm = await kysely + .selectFrom('forms') + .select(['data', 'id']) + .where('id', '=', result.data.id) + .executeTakeFirst(); + if (insertedForm === undefined) { + expect.fail('getting inserted form failed'); + } + expect(insertedForm).toEqual({ + id: result.data.id, + data: '{"summary":{"title":"Test form","description":"Test description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[]}', + }); + }); +}); + +const testForm = { + summary: { title: 'Test form', description: 'Test description' }, + root: 'root', + patterns: { root: { type: 'sequence', id: 'root', data: { patterns: [] } } }, + outputs: [], +}; diff --git a/packages/forms/src/repository/add-form.ts b/packages/forms/src/repository/add-form.ts new file mode 100644 index 00000000..93ddfeed --- /dev/null +++ b/packages/forms/src/repository/add-form.ts @@ -0,0 +1,26 @@ +import { type Result, failure, success } from '@atj/common'; + +import { type DatabaseContext } from '@atj/database'; +import { stringifyForm } from './serialize.js'; + +export const addForm = async ( + ctx: DatabaseContext, + form: any // Blueprint +): Promise> => { + const uuid = crypto.randomUUID(); + const db = await ctx.getKysely(); + return db + .insertInto('forms') + .values({ + id: uuid, + data: stringifyForm(form), + }) + .execute() + .then(() => + success({ + timestamp: new Date().toISOString(), + id: uuid, + }) + ) + .catch(err => failure(err.message)); +}; diff --git a/packages/forms/src/repository/delete-form.test.ts b/packages/forms/src/repository/delete-form.test.ts new file mode 100644 index 00000000..5b104bb6 --- /dev/null +++ b/packages/forms/src/repository/delete-form.test.ts @@ -0,0 +1,48 @@ +import { beforeAll, expect, it, vi } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; +import { deleteForm } from './delete-form.js'; + +describeDatabase('delete form', () => { + const today = new Date(2000, 1, 1); + + beforeAll(async () => { + vi.setSystemTime(today); + }); + + it('works with valid form ID', async ({ db }) => { + const kysely = await db.ctx.getKysely(); + await kysely + .insertInto('forms') + .values({ + id: '45c66187-64e2-4d75-a45a-e80f1d035bc5', + data: '{"summary":{"title":"Test form","description":"Test description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[]}', + }) + .execute(); + + const result = await deleteForm( + db.ctx, + '45c66187-64e2-4d75-a45a-e80f1d035bc5' + ); + if (result.success === false) { + expect.fail('addForm failed'); + } + + const selectResult = await kysely + .selectFrom('forms') + .select(kysely.fn.count('id').as('count')) + .executeTakeFirst(); + if (selectResult === undefined) { + expect.fail('getting forms count failed'); + } + expect(Number(selectResult?.count)).toEqual(0); + }); + + it('fails with invalid form ID', async ({ db }) => { + const result = await deleteForm( + db.ctx, + '45c66187-64e2-4d75-a45a-e80f1d035bc5' + ); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/forms/src/repository/delete-form.ts b/packages/forms/src/repository/delete-form.ts new file mode 100644 index 00000000..6ac2f2fe --- /dev/null +++ b/packages/forms/src/repository/delete-form.ts @@ -0,0 +1,21 @@ +import { type VoidResult, failure } from '@atj/common'; + +import { type DatabaseContext } from '@atj/database'; + +export const deleteForm = async ( + ctx: DatabaseContext, + formId: string +): Promise => { + const db = await ctx.getKysely(); + + const deleteResult = await db + .deleteFrom('forms') + .where('id', '=', formId) + .execute(); + + if (!deleteResult[0].numDeletedRows) { + return failure('form not found'); + } + + return { success: true }; +}; diff --git a/packages/forms/src/repository/get-form-list.test.ts b/packages/forms/src/repository/get-form-list.test.ts new file mode 100644 index 00000000..33841208 --- /dev/null +++ b/packages/forms/src/repository/get-form-list.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; +import { getForm } from './get-form.js'; +import { getFormList } from './get-form-list.js'; + +describeDatabase('getFormList', () => { + it('retrieves form list successfully', async ({ db }) => { + const kysely = await db.ctx.getKysely(); + await kysely + .insertInto('forms') + .values([ + { + id: '45c66187-64e2-4d75-a45a-e80f1d035bc5', + data: '{"summary":{"title":"Test form","description":"Test description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[]}', + }, + { + id: '85c66187-64e2-4d75-a45a-e80f1d035bc5', + data: '{"summary":{"title":"Test form 2","description":"Test description 2"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[]}', + }, + ]) + .execute(); + + const result = await getFormList(db.ctx); + expect(result).toEqual([ + { + id: '45c66187-64e2-4d75-a45a-e80f1d035bc5', + title: 'Test form', + description: 'Test description', + }, + { + id: '85c66187-64e2-4d75-a45a-e80f1d035bc5', + title: 'Test form 2', + description: 'Test description 2', + }, + ]); + }); + + it('return null with non-existent form', async ({ db }) => { + const result = await getForm( + db.ctx, + '45c66187-64e2-4d75-a45a-e80f1d035bc5' + ); + expect(result).toBeNull(); + }); +}); diff --git a/packages/forms/src/repository/get-form-list.ts b/packages/forms/src/repository/get-form-list.ts new file mode 100644 index 00000000..745beda3 --- /dev/null +++ b/packages/forms/src/repository/get-form-list.ts @@ -0,0 +1,15 @@ +import { type DatabaseContext } from '@atj/database'; + +export const getFormList = async (ctx: DatabaseContext) => { + const db = await ctx.getKysely(); + const rows = await db.selectFrom('forms').select(['id', 'data']).execute(); + + return rows.map(row => { + const form = JSON.parse(row.data); + return { + id: row.id, + title: form.summary.title, + description: form.summary.description, + }; + }); +}; diff --git a/packages/forms/src/repository/get-form.test.ts b/packages/forms/src/repository/get-form.test.ts new file mode 100644 index 00000000..b9d943b9 --- /dev/null +++ b/packages/forms/src/repository/get-form.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; +import { getForm } from './get-form.js'; + +describeDatabase('getForm', () => { + it('retrieves form successfully', async ({ db }) => { + const kysely = await db.ctx.getKysely(); + await kysely + .insertInto('forms') + .values({ + id: '45c66187-64e2-4d75-a45a-e80f1d035bc5', + data: '{"summary":{"title":"Test form","description":"Test description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[]}', + }) + .execute(); + + const result = await getForm( + db.ctx, + '45c66187-64e2-4d75-a45a-e80f1d035bc5' + ); + expect(result).toEqual({ + outputs: [], + patterns: { + root: { + data: { + patterns: [], + }, + id: 'root', + type: 'sequence', + }, + }, + root: 'root', + summary: { + description: 'Test description', + title: 'Test form', + }, + }); + }); + + it('return null with non-existent form', async ({ db }) => { + const result = await getForm( + db.ctx, + '45c66187-64e2-4d75-a45a-e80f1d035bc5' + ); + expect(result).toBeNull(); + }); +}); diff --git a/packages/forms/src/repository/get-form.ts b/packages/forms/src/repository/get-form.ts new file mode 100644 index 00000000..42c751b2 --- /dev/null +++ b/packages/forms/src/repository/get-form.ts @@ -0,0 +1,42 @@ +import { type DatabaseContext } from '@atj/database'; + +import { type Blueprint } from '../index.js'; + +export const getForm = async ( + ctx: DatabaseContext, + formId: string +): Promise => { + const db = await ctx.getKysely(); + const selectResult = await db + .selectFrom('forms') + .select(['data']) + .where('id', '=', formId) + .executeTakeFirst(); + + if (selectResult === undefined) { + return null; + } + + return parseStringForm(selectResult.data); +}; + +const parseStringForm = (formString: string): Blueprint => { + const form = JSON.parse(formString) as Blueprint; + return { + ...form, + outputs: form.outputs.map((output: any) => ({ + ...output, + data: base64ToUint8Array((output as any).data), + })), + }; +}; + +const base64ToUint8Array = (base64: string): Uint8Array => { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; diff --git a/packages/forms/src/repository/index.ts b/packages/forms/src/repository/index.ts new file mode 100644 index 00000000..b4e3eb74 --- /dev/null +++ b/packages/forms/src/repository/index.ts @@ -0,0 +1,34 @@ +import { type Result, type VoidResult, createService } from '@atj/common'; +import { type DatabaseContext } from '@atj/database'; + +import { type Blueprint } from '../index.js'; + +import { addForm } from './add-form.js'; +import { deleteForm } from './delete-form.js'; +import { getFormList } from './get-form-list.js'; +import { getForm } from './get-form.js'; +import { saveForm } from './save-form.js'; + +export interface FormRepository { + addForm(form: Blueprint): Promise>; + deleteForm(formId: string): Promise; + getForm(id?: string): Promise; + getFormList(): Promise< + | { + id: string; + title: string; + description: string; + }[] + | null + >; + saveForm(formId: string, form: Blueprint): Promise; +} + +export const createFormsRepository = (ctx: DatabaseContext): FormRepository => + createService(ctx, { + addForm, + deleteForm, + getFormList, + getForm, + saveForm, + }); diff --git a/packages/forms/src/repository/save-form.test.ts b/packages/forms/src/repository/save-form.test.ts new file mode 100644 index 00000000..600a60de --- /dev/null +++ b/packages/forms/src/repository/save-form.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from 'vitest'; + +import { type DbTestContext, describeDatabase } from '@atj/database/testing'; + +import { saveForm } from './save-form.js'; +import { addForm } from './add-form.js'; + +const TEST_FORM = { + summary: { + title: 'Test form', + description: 'Test description', + }, + root: 'root', + patterns: { + root: { + type: 'sequence', + id: 'root', + data: { + patterns: [], + }, + }, + }, + outputs: [], +}; + +describeDatabase('saveForm', () => { + it('saves pre-existing form successfully', async ({ db }) => { + const kysely = await db.ctx.getKysely(); + const addResult = await addForm(db.ctx, TEST_FORM); + if (!addResult.success) { + throw new Error('Failed to add form'); + } + + const saveResult = await saveForm(db.ctx, addResult.data.id, { + ...TEST_FORM, + summary: { title: 'Updated title', description: 'Updated description' }, + }); + if (!saveResult.success) { + expect.fail('Failed to save form', saveResult.error); + } + + const result = await kysely + .selectFrom('forms') + .select(['id', 'data']) + .where('id', '=', addResult.data.id) + .execute(); + + expect(result[0].id).toEqual(addResult.data.id); + expect(result[0].data).toEqual( + '{"summary":{"title":"Updated title","description":"Updated description"},"root":"root","patterns":{"root":{"type":"sequence","id":"root","data":{"patterns":[]}}},"outputs":[]}' + ); + }); +}); diff --git a/packages/forms/src/repository/save-form.ts b/packages/forms/src/repository/save-form.ts new file mode 100644 index 00000000..b84b8adc --- /dev/null +++ b/packages/forms/src/repository/save-form.ts @@ -0,0 +1,28 @@ +import { failure, success } from '@atj/common'; +import { type DatabaseContext } from '@atj/database'; + +import { type Blueprint } from '../index.js'; +import { stringifyForm } from './serialize.js'; + +export const saveForm = async ( + ctx: DatabaseContext, + id: string, + blueprint: Blueprint +) => { + const db = await ctx.getKysely(); + + return await db + .updateTable('forms') + .set({ + data: stringifyForm(blueprint), + }) + .where('id', '=', id) + .execute() + .then(() => + success({ + timestamp: new Date(), + id, + }) + ) + .catch(err => failure(err.message)); +}; diff --git a/packages/forms/src/repository/serialize.ts b/packages/forms/src/repository/serialize.ts new file mode 100644 index 00000000..d68cc077 --- /dev/null +++ b/packages/forms/src/repository/serialize.ts @@ -0,0 +1,19 @@ +export const stringifyForm = (form: /*Blueprint*/ any) => { + return JSON.stringify({ + ...form, + outputs: form.outputs.map((output: any) => ({ + ...output, + // TODO: we probably want to do this somewhere in the documents module + data: uint8ArrayToBase64(output.data), + })), + }); +}; + +const uint8ArrayToBase64 = (buffer: Uint8Array): string => { + let binary = ''; + const len = buffer.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary); +}; diff --git a/packages/forms/src/response.ts b/packages/forms/src/response.ts index a5eb3d44..7018654a 100644 --- a/packages/forms/src/response.ts +++ b/packages/forms/src/response.ts @@ -5,9 +5,13 @@ import { getPattern, getPatternConfig, validatePattern, -} from '.'; -import { type PromptAction } from './components'; -import { type FormErrorMap, type FormSession, updateSession } from './session'; +} from './index.js'; +import { type PromptAction } from './components.js'; +import { + type FormErrorMap, + type FormSession, + updateSession, +} from './session.js'; export type PromptResponse = { action: PromptAction['type']; diff --git a/packages/forms/src/route-data.ts b/packages/forms/src/route-data.ts index c3c8e524..346646c2 100644 --- a/packages/forms/src/route-data.ts +++ b/packages/forms/src/route-data.ts @@ -1,5 +1,5 @@ import qs from 'qs'; -import { PatternId } from './pattern'; +import { PatternId } from './pattern.js'; export type RouteData = qs.ParsedQs; diff --git a/packages/forms/src/service/context/browser/form-repo.ts b/packages/forms/src/service/context/browser/form-repo.ts deleted file mode 100644 index 9b29c21a..00000000 --- a/packages/forms/src/service/context/browser/form-repo.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Result } from '@atj/common'; -import { type Blueprint } from '../../..'; - -export const getFormFromStorage = ( - storage: Storage, - id?: string -): Blueprint | null => { - if (!storage || !id) { - return null; - } - const formString = storage.getItem(id); - if (!formString) { - return null; - } - return parseStringForm(formString); -}; - -export const getFormListFromStorage = (storage: Storage) => { - const keys = []; - for (let i = 0; i < storage.length; i++) { - const key = storage.key(i); - if (key === null) { - return null; - } - keys.push(key); - } - return keys; -}; - -export const getFormSummaryListFromStorage = (storage: Storage) => { - const forms = getFormListFromStorage(storage); - if (forms === null) { - return null; - } - return forms.map(key => { - const form = getFormFromStorage(storage, key) as Blueprint; - if (form === null) { - throw new Error('key mismatch'); - } - return { - id: key, - title: form.summary.title, - description: form.summary.description, - }; - }); -}; - -export const addFormToStorage = ( - storage: Storage, - form: Blueprint -): Result<{ timestamp: Date; id: string }> => { - const uuid = crypto.randomUUID(); - - const result = saveFormToStorage(storage, uuid, form); - if (!result.success) { - return result; - } - - return { - success: true, - data: { - timestamp: new Date(), - id: uuid, - }, - }; -}; - -export const saveFormToStorage = ( - storage: Storage, - formId: string, - form: Blueprint -) => { - try { - storage.setItem(formId, stringifyForm(form)); - } catch { - return { - success: false as const, - error: `error saving '${formId}' to storage`, - }; - } - return { - success: true as const, - }; -}; - -export const deleteFormFromStorage = (storage: Storage, formId: string) => { - storage.removeItem(formId); -}; - -const stringifyForm = (form: Blueprint) => { - return JSON.stringify({ - ...form, - outputs: form.outputs.map(output => ({ - ...output, - // TODO: we probably want to do this somewhere in the documents module - data: uint8ArrayToBase64(output.data), - })), - }); -}; - -const parseStringForm = (formString: string): Blueprint => { - const form = JSON.parse(formString) as Blueprint; - return { - ...form, - outputs: form.outputs.map(output => ({ - ...output, - data: base64ToUint8Array((output as any).data), - })), - }; -}; - -const uint8ArrayToBase64 = (buffer: Uint8Array): string => { - let binary = ''; - const len = buffer.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(buffer[i]); - } - return btoa(binary); -}; - -const base64ToUint8Array = (base64: string): Uint8Array => { - const binaryString = atob(base64); - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; -}; diff --git a/packages/forms/src/service/context/browser/index.ts b/packages/forms/src/service/context/browser/index.ts deleted file mode 100644 index e7aa2bae..00000000 --- a/packages/forms/src/service/context/browser/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { type FormConfig, defaultFormConfig } from '../../..'; - -import { addForm } from '../../operations/add-form'; -import { deleteForm } from '../../operations/delete-form'; -import { getForm } from '../../operations/get-form'; -import { getFormList } from '../../operations/get-form-list'; -import { saveForm } from '../../operations/save-form'; -import { submitForm } from '../../operations/submit-form'; -import type { FormService } from '../../types'; - -type BrowserContext = { - storage: Storage; - config: FormConfig; -}; - -const createDefaultBrowserContext = (): BrowserContext => ({ - storage: window.localStorage, - config: defaultFormConfig, -}); - -export const createBrowserFormService = ( - opts?: BrowserContext -): FormService => { - const ctx = opts || createDefaultBrowserContext(); - return { - addForm(form) { - return addForm(ctx, form); - }, - deleteForm(formId) { - return deleteForm(ctx, formId); - }, - getForm(formId) { - return getForm(ctx, formId); - }, - getFormList() { - return getFormList(ctx); - }, - saveForm(formId, form) { - return saveForm(ctx, formId, form); - }, - submitForm(session, formId, formData) { - return submitForm(ctx, session, formId, formData); - }, - }; -}; diff --git a/packages/forms/src/service/context/test/index.ts b/packages/forms/src/service/context/test/index.ts deleted file mode 100644 index b8e02dfe..00000000 --- a/packages/forms/src/service/context/test/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defaultFormConfig } from '../../..'; - -import { createBrowserFormService } from '../browser'; -import { type TestData, createTestStorage } from './storage'; - -// In tests, use the browser form service with fakes injected. -export const createTestFormService = (testData: TestData = {}) => { - return createBrowserFormService({ - config: defaultFormConfig, - storage: createTestStorage(testData), - }); -}; diff --git a/packages/forms/src/service/index.ts b/packages/forms/src/service/index.ts deleted file mode 100644 index bf751a25..00000000 --- a/packages/forms/src/service/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { createBrowserFormService } from './context/browser'; -export { createTestFormService } from './context/test'; -export { type FormService } from './types'; diff --git a/packages/forms/src/service/operations/add-form.ts b/packages/forms/src/service/operations/add-form.ts deleted file mode 100644 index 7222d1cd..00000000 --- a/packages/forms/src/service/operations/add-form.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Result } from '@atj/common'; -import { Blueprint } from '../../..'; - -import { addFormToStorage } from '../context/browser/form-repo'; - -export const addForm = ( - ctx: { storage: Storage }, - form: Blueprint -): Result<{ timestamp: Date; id: string }> => { - return addFormToStorage(ctx.storage, form); -}; diff --git a/packages/forms/src/service/operations/delete-form.ts b/packages/forms/src/service/operations/delete-form.ts deleted file mode 100644 index 2a758e55..00000000 --- a/packages/forms/src/service/operations/delete-form.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { VoidResult } from '@atj/common'; - -import { - deleteFormFromStorage, - getFormFromStorage, -} from '../context/browser/form-repo'; - -export const deleteForm = ( - ctx: { storage: Storage }, - formId: string -): VoidResult => { - const form = getFormFromStorage(ctx.storage, formId); - if (form === null) { - return { - success: false, - error: `form '${formId} does not exist`, - }; - } - deleteFormFromStorage(window.localStorage, formId); - return { - success: true, - }; -}; diff --git a/packages/forms/src/service/operations/get-form-list.ts b/packages/forms/src/service/operations/get-form-list.ts deleted file mode 100644 index 198fb48b..00000000 --- a/packages/forms/src/service/operations/get-form-list.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Result } from '@atj/common'; - -import { getFormSummaryListFromStorage } from '../context/browser/form-repo'; - -export type FormListItem = { - id: string; - title: string; - description: string; -}; - -export const getFormList = (ctx: { - storage: Storage; -}): Result => { - const forms = getFormSummaryListFromStorage(ctx.storage); - if (forms === null) { - return { - success: false, - error: 'error getting form list', - }; - } - return { - success: true, - data: forms, - }; -}; diff --git a/packages/forms/src/service/operations/get-form.ts b/packages/forms/src/service/operations/get-form.ts deleted file mode 100644 index f7b25de4..00000000 --- a/packages/forms/src/service/operations/get-form.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Result } from '@atj/common'; -import { type Blueprint } from '../../..'; - -import { getFormFromStorage } from '../context/browser/form-repo'; - -export const getForm = ( - ctx: { storage: Storage }, - formId: string -): Result => { - const result = getFormFromStorage(ctx.storage, formId); - if (result === null) { - return { - success: false, - error: 'not found', - }; - } - return { - success: true, - data: result, - }; -}; diff --git a/packages/forms/src/service/operations/save-form.ts b/packages/forms/src/service/operations/save-form.ts deleted file mode 100644 index 61d9ff7c..00000000 --- a/packages/forms/src/service/operations/save-form.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Result } from '@atj/common'; -import { Blueprint } from '../../..'; - -import { saveFormToStorage } from '../context/browser/form-repo'; - -export const saveForm = ( - ctx: { storage: Storage }, - formId: string, - form: Blueprint -): Result<{ timestamp: Date }> => { - const result = saveFormToStorage(ctx.storage, formId, form); - if (result.success === false) { - return { - success: false, - error: result.error, - }; - } - return { - success: true, - data: { - timestamp: new Date(), - }, - }; -}; diff --git a/packages/forms/src/service/operations/submit-form.test.ts b/packages/forms/src/service/operations/submit-form.test.ts deleted file mode 100644 index 15f13b25..00000000 --- a/packages/forms/src/service/operations/submit-form.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createForm, createFormSession } from '../../..'; -import { createTestFormService } from '../context/test'; - -describe('submitForm', () => { - it('succeeds with empty form', async () => { - const service = createTestFormService({ - 'test-form': createForm({ title: 'test', description: 'description' }), - }); - const formResult = service.getForm('test-form'); - if (!formResult.success) { - throw new Error('form not found'); - } - const session = createFormSession(formResult.data); - const result = await service.submitForm(session, 'test-form', {}); - expect(result).toEqual({ - success: true, - data: [], - }); - }); -}); diff --git a/packages/forms/src/service/types.ts b/packages/forms/src/service/types.ts deleted file mode 100644 index 965873c0..00000000 --- a/packages/forms/src/service/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type Result, type VoidResult } from '@atj/common'; - -import { type FormListItem } from './operations/get-form-list'; -import { type Blueprint, type FormSession } from '..'; - -export type FormService = { - addForm: (form: Blueprint) => Result<{ timestamp: Date; id: string }>; - deleteForm: (formId: string) => VoidResult; - getForm: (formId: string) => Result; - getFormList: () => Result; - saveForm: (formId: string, form: Blueprint) => Result<{ timestamp: Date }>; - submitForm: ( - //sessionId: string, - session: FormSession, // TODO: load session from storage by ID - formId: string, - formData: Record - ) => Promise>; -}; diff --git a/packages/forms/src/services/add-form.test.ts b/packages/forms/src/services/add-form.test.ts new file mode 100644 index 00000000..ee3865db --- /dev/null +++ b/packages/forms/src/services/add-form.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { createForm } from '../index.js'; +import { createTestFormServiceContext } from '../testing.js'; + +import { addForm } from './add-form.js'; + +const TEST_FORM = createForm({ title: 'Form Title', description: '' }); + +describe('addForm', () => { + it('returns access denied (401) if user is not logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const result = await addForm(ctx, TEST_FORM); + expect(result).toEqual({ + success: false, + error: { + status: 401, + message: 'You must be logged in to add a form', + }, + }); + }); + + it('adds form successfully when user is logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + }); + const result = await addForm(ctx, TEST_FORM); + expect(result).toEqual({ + success: true, + data: { + timestamp: expect.any(String), + id: expect.any(String), + }, + }); + }); +}); diff --git a/packages/forms/src/services/add-form.ts b/packages/forms/src/services/add-form.ts new file mode 100644 index 00000000..bc7f5f89 --- /dev/null +++ b/packages/forms/src/services/add-form.ts @@ -0,0 +1,36 @@ +import { type Result, failure } from '@atj/common'; +import { Blueprint } from '../index.js'; + +import { type FormServiceContext } from '../context/index.js'; + +type AddFormError = { + status: number; + message: string; +}; +type AddFormResult = { + timestamp: string; + id: string; +}; + +type AddForm = ( + ctx: FormServiceContext, + form: Blueprint +) => Promise>; + +export const addForm: AddForm = async (ctx, form) => { + if (!ctx.isUserLoggedIn()) { + return failure({ + status: 401, + message: 'You must be logged in to add a form', + }); + } + const result = await ctx.repository.addForm(form); + if (!result.success) { + console.error('Failed to add form:', result.error); + return failure({ + status: 500, + message: result.error, + }); + } + return result; +}; diff --git a/packages/forms/src/services/delete-form.test.ts b/packages/forms/src/services/delete-form.test.ts new file mode 100644 index 00000000..6289c954 --- /dev/null +++ b/packages/forms/src/services/delete-form.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { createForm } from '../index.js'; +import { createTestFormServiceContext } from '../testing.js'; + +import { deleteForm } from './delete-form.js'; + +const TEST_FORM = createForm({ title: 'Form Title', description: '' }); + +describe('deleteForm', () => { + it('returns access denied (401) if user is not logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const result = await deleteForm(ctx, 'any-id'); + expect(result).toEqual({ + success: false, + error: { + status: 401, + message: 'You must be logged in to delete a form', + }, + }); + }); + + it('deletes form successfully when user is logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + }); + const addResult = await ctx.repository.addForm(TEST_FORM); + if (!addResult.success) { + expect.fail('Failed to add form:', addResult.error); + } + + const result = await deleteForm(ctx, addResult.data.id); + expect(result).toEqual({ + success: true, + }); + }); +}); diff --git a/packages/forms/src/services/delete-form.ts b/packages/forms/src/services/delete-form.ts new file mode 100644 index 00000000..754586a9 --- /dev/null +++ b/packages/forms/src/services/delete-form.ts @@ -0,0 +1,31 @@ +import { type VoidResult, failure } from '@atj/common'; + +import { type FormServiceContext } from '../context/index.js'; + +type DeleteFormError = { + status: number; + message: string; +}; + +type DeleteForm = ( + ctx: FormServiceContext, + formId: string +) => Promise>; + +export const deleteForm: DeleteForm = async (ctx, formId) => { + if (!ctx.isUserLoggedIn()) { + return failure({ + status: 401, + message: 'You must be logged in to delete a form', + }); + } + const form = await ctx.repository.getForm(formId); + if (form === null) { + return failure({ + status: 404, + message: `form '${formId} does not exist`, + }); + } + await ctx.repository.deleteForm(formId); + return { success: true }; +}; diff --git a/packages/forms/src/services/get-form-list.test.ts b/packages/forms/src/services/get-form-list.test.ts new file mode 100644 index 00000000..058704e7 --- /dev/null +++ b/packages/forms/src/services/get-form-list.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { createForm } from '../index.js'; +import { type FormServiceContext } from '../context/index.js'; +import { createTestFormServiceContext } from '../testing.js'; + +import { getFormList } from './get-form-list.js'; + +describe('getFormList', () => { + it('returns access denied (401) if user is not logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const result = await getFormList(ctx); + expect(result).toEqual({ + success: false, + error: { + status: 401, + message: 'You must be logged in to delete a form', + }, + }); + }); + + it('gets form list successfully when user is logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + }); + await addTestForms(ctx, 5); + + const result = await getFormList(ctx); + expect(result).toEqual({ + data: [ + { + description: '', + id: expect.any(String), + title: 'Form 0', + }, + { + description: '', + id: expect.any(String), + title: 'Form 1', + }, + { + description: '', + id: expect.any(String), + title: 'Form 2', + }, + { + description: '', + id: expect.any(String), + title: 'Form 3', + }, + { + description: '', + id: expect.any(String), + title: 'Form 4', + }, + ], + success: true, + }); + }); +}); + +const addTestForms = async (ctx: FormServiceContext, count: number) => { + const forms = []; + for (let i = 0; i < count; i++) { + const form = createForm({ title: `Form ${i}`, description: '' }); + const result = await ctx.repository.addForm(form); + if (!result.success) { + expect.fail('Failed to add form:', result.error); + } + forms.push(result.data); + } + return forms; +}; diff --git a/packages/forms/src/services/get-form-list.ts b/packages/forms/src/services/get-form-list.ts new file mode 100644 index 00000000..7beb3cee --- /dev/null +++ b/packages/forms/src/services/get-form-list.ts @@ -0,0 +1,34 @@ +import { type Result, failure, success } from '@atj/common'; + +import { type FormServiceContext } from '../context/index.js'; + +export type FormListItem = { + id: string; + title: string; + description: string; +}; +type FormListError = { + status: number; + message: string; +}; + +type GetFormList = ( + ctx: FormServiceContext +) => Promise>; + +export const getFormList: GetFormList = async ctx => { + if (!ctx.isUserLoggedIn()) { + return failure({ + status: 401, + message: 'You must be logged in to delete a form', + }); + } + const forms = await ctx.repository.getFormList(); + if (forms === null) { + return failure({ + status: 500, + message: 'error getting form list', + }); + } + return success(forms); +}; diff --git a/packages/forms/src/services/get-form.test.ts b/packages/forms/src/services/get-form.test.ts new file mode 100644 index 00000000..4be75251 --- /dev/null +++ b/packages/forms/src/services/get-form.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { createForm } from '../index.js'; +import { createTestFormServiceContext } from '../testing.js'; + +import { getForm } from './get-form.js'; + +const TEST_FORM = createForm({ title: 'Form Title', description: '' }); + +describe('getForm', () => { + it('returns access denied (401) if user is not logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const result = await getForm(ctx, 'any-id'); + expect(result).toEqual({ + success: false, + error: { + status: 401, + message: 'You must be logged in to delete a form', + }, + }); + }); + + it('gets form successfully when user is logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + }); + const addResult = await ctx.repository.addForm(TEST_FORM); + if (!addResult.success) { + expect.fail('Failed to add form:', addResult.error); + } + + const result = await getForm(ctx, addResult.data.id); + if (!result.success) { + expect.fail('Failed to add form:', result.error); + } + expect(result.data).toEqual(TEST_FORM); + }); +}); diff --git a/packages/forms/src/services/get-form.ts b/packages/forms/src/services/get-form.ts new file mode 100644 index 00000000..a58edd0b --- /dev/null +++ b/packages/forms/src/services/get-form.ts @@ -0,0 +1,31 @@ +import { type Result, failure, success } from '@atj/common'; + +import { type Blueprint } from '../index.js'; +import { type FormServiceContext } from '../context/index.js'; + +type GetFormError = { + status: number; + message: string; +}; + +type GetForm = ( + ctx: FormServiceContext, + formId: string +) => Promise>; + +export const getForm: GetForm = async (ctx, formId) => { + if (!ctx.isUserLoggedIn()) { + return failure({ + status: 401, + message: 'You must be logged in to delete a form', + }); + } + const result = await ctx.repository.getForm(formId); + if (result === null) { + return failure({ + status: 404, + message: 'Form not found', + }); + } + return success(result); +}; diff --git a/packages/forms/src/services/index.ts b/packages/forms/src/services/index.ts new file mode 100644 index 00000000..710b2f6a --- /dev/null +++ b/packages/forms/src/services/index.ts @@ -0,0 +1,27 @@ +import { createService } from '@atj/common'; + +import { type FormServiceContext } from '../context/index.js'; + +import { addForm } from './add-form.js'; +import { deleteForm } from './delete-form.js'; +import { getForm } from './get-form.js'; +import { getFormList } from './get-form-list.js'; +import { saveForm } from './save-form.js'; +import { submitForm } from './submit-form.js'; + +export const createFormService = (ctx: FormServiceContext) => + createService(ctx, { + addForm, + deleteForm, + getForm, + getFormList, + saveForm, + submitForm, + }); +/* +export type FormService = Omit< + ReturnType, + 'getContext' +>; +*/ +export type FormService = ReturnType; diff --git a/packages/forms/src/services/save-form.test.ts b/packages/forms/src/services/save-form.test.ts new file mode 100644 index 00000000..94e3f873 --- /dev/null +++ b/packages/forms/src/services/save-form.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { createForm } from '../index.js'; +import { createTestFormServiceContext } from '../testing.js'; + +import { saveForm } from './save-form.js'; + +const TEST_FORM = createForm({ title: 'Form Title', description: '' }); +const TEST_FORM_2 = { + ...TEST_FORM, + summary: { + ...TEST_FORM.summary, + title: 'New Title', + }, +}; + +describe('saveForm', () => { + it('returns access denied (401) if user is not logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const result = await saveForm(ctx, 'any-id', TEST_FORM); + expect(result).toEqual({ + success: false, + error: { + status: 401, + message: 'You must be logged in to save a form', + }, + }); + }); + + it('saves form successfully when user is logged in', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => true, + }); + const addResult = await ctx.repository.addForm(TEST_FORM); + if (!addResult.success) { + expect.fail('Failed to add form:', addResult.error); + } + + const result = await saveForm(ctx, addResult.data.id, TEST_FORM_2); + if (!result.success) { + expect.fail('Failed to add form:', result.error); + } + expect(result.data).toEqual({ timestamp: expect.any(Date) }); + + const savedForm = await ctx.repository.getForm(addResult.data.id); + expect(savedForm).toEqual(TEST_FORM_2); + }); +}); diff --git a/packages/forms/src/services/save-form.ts b/packages/forms/src/services/save-form.ts new file mode 100644 index 00000000..aeb5b5b6 --- /dev/null +++ b/packages/forms/src/services/save-form.ts @@ -0,0 +1,34 @@ +import { type Result, failure, success } from '@atj/common'; +import { Blueprint } from '../index.js'; + +import { type FormServiceContext } from '../context/index.js'; + +type SaveFormError = { + status: number; + message: string; +}; + +type SaveForm = ( + ctx: FormServiceContext, + formId: string, + form: Blueprint +) => Promise>; + +export const saveForm: SaveForm = async (ctx, formId, form) => { + if (!ctx.isUserLoggedIn()) { + return failure({ + status: 401, + message: 'You must be logged in to save a form', + }); + } + const result = await ctx.repository.saveForm(formId, form); + if (result.success === false) { + return failure({ + status: 500, + message: 'error saving form', + }); + } + return success({ + timestamp: new Date(), + }); +}; diff --git a/packages/forms/src/services/submit-form.test.ts b/packages/forms/src/services/submit-form.test.ts new file mode 100644 index 00000000..5967f3f9 --- /dev/null +++ b/packages/forms/src/services/submit-form.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { createForm, createFormSession } from '../index.js'; +import { createTestFormServiceContext } from '../testing.js'; +import { submitForm } from './submit-form.js'; + +describe('submitForm', () => { + it('succeeds with empty form', async () => { + const ctx = await createTestFormServiceContext({ + isUserLoggedIn: () => false, + }); + const testForm = createForm({ title: 'test', description: 'description' }); + const addFormResult = await ctx.repository.addForm(testForm); + if (addFormResult.success === false) { + expect.fail('addForm failed'); + } + const session = createFormSession(testForm); + + const result = await submitForm(ctx, session, addFormResult.data.id, {}); + expect(result).toEqual({ + success: true, + data: [], + }); + }); +}); diff --git a/packages/forms/src/service/operations/submit-form.ts b/packages/forms/src/services/submit-form.ts similarity index 85% rename from packages/forms/src/service/operations/submit-form.ts rename to packages/forms/src/services/submit-form.ts index 3be1037c..4015f3d7 100644 --- a/packages/forms/src/service/operations/submit-form.ts +++ b/packages/forms/src/services/submit-form.ts @@ -1,31 +1,38 @@ import { type Result } from '@atj/common'; import { - type FormConfig, type Blueprint, type FormSession, applyPromptResponse, createFormOutputFieldData, fillPDF, sessionIsComplete, -} from '../../..'; +} from '../index.js'; -import { getFormFromStorage } from '../context/browser/form-repo'; +import { FormServiceContext } from '../context/index.js'; -export const submitForm = async ( - ctx: { storage: Storage; config: FormConfig }, +type SubmitForm = ( + ctx: FormServiceContext, //sessionId: string, session: FormSession, // TODO: load session from storage by ID formId: string, formData: Record -): Promise< +) => Promise< Result< { fileName: string; data: Uint8Array; }[] > -> => { - const form = getFormFromStorage(ctx.storage, formId); +>; + +export const submitForm: SubmitForm = async ( + ctx, + //sessionId: string, + session, // TODO: load session from storage by ID + formId, + formData +) => { + const form = await ctx.repository.getForm(formId); if (form === null) { return Promise.resolve({ success: false, diff --git a/packages/forms/src/session.ts b/packages/forms/src/session.ts index 2e86ff18..84083c53 100644 --- a/packages/forms/src/session.ts +++ b/packages/forms/src/session.ts @@ -5,14 +5,14 @@ import { type Pattern, getPatternConfig, validatePattern, -} from '.'; -import { SequencePattern } from './patterns/sequence'; +} from './index.js'; +import { SequencePattern } from './patterns/sequence.js'; import { type PatternId, type PatternValue, type PatternValueMap, -} from './pattern'; -import { type RouteData, getRouteDataFromQueryString } from './route-data'; +} from './pattern.js'; +import { type RouteData, getRouteDataFromQueryString } from './route-data.js'; export type FormErrorMap = Record; diff --git a/packages/forms/src/testing.ts b/packages/forms/src/testing.ts new file mode 100644 index 00000000..35964602 --- /dev/null +++ b/packages/forms/src/testing.ts @@ -0,0 +1,17 @@ +import { createInMemoryDatabaseContext } from '@atj/database/context'; +import { createFormsRepository } from './repository'; +import { defaultFormConfig } from './patterns'; + +type Options = { + isUserLoggedIn: () => boolean; +}; + +export const createTestFormServiceContext = async (opts?: Partial) => { + const db = await createInMemoryDatabaseContext(); + const repository = createFormsRepository(db); + return { + repository, + config: defaultFormConfig, + isUserLoggedIn: opts?.isUserLoggedIn || (() => true), + }; +}; diff --git a/packages/forms/src/util/transform.ts b/packages/forms/src/util/transform.ts index d7633b62..d307f8b4 100644 --- a/packages/forms/src/util/transform.ts +++ b/packages/forms/src/util/transform.ts @@ -1,5 +1,5 @@ -import { Field } from '../field'; -import { capitalizeFirstLetter } from '../util/string-format'; +import { Field } from '../field.js'; +import { capitalizeFirstLetter } from '../util/string-format.js'; export type FieldTransformer = (field: Field) => Field; diff --git a/packages/forms/src/util/zod.ts b/packages/forms/src/util/zod.ts index 41339bfe..097b0437 100644 --- a/packages/forms/src/util/zod.ts +++ b/packages/forms/src/util/zod.ts @@ -2,7 +2,7 @@ import * as z from 'zod'; import * as r from '@atj/common'; -import { type FormError, type FormErrors, type Pattern } from '..'; +import { type FormError, type FormErrors, type Pattern } from '../index.js'; export const safeZodParse = ( schema: z.Schema, diff --git a/packages/forms/tsconfig.json b/packages/forms/tsconfig.json index a78c2b25..08afb83d 100644 --- a/packages/forms/tsconfig.json +++ b/packages/forms/tsconfig.json @@ -1,10 +1,16 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "emitDeclarationOnly": true, - "outDir": "./dist" + "declaration": true, + "declarationDir": "./dist/types", + "emitDeclarationOnly": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./src" }, - "include": ["./src/**/*"], + "include": ["src"], "exclude": ["./dist"], - "references": [] + "references": [], + "types": ["vite/client"] } diff --git a/packages/forms/vitest.config.ts b/packages/forms/vitest.config.ts index dd6c589c..e2cd62ad 100644 --- a/packages/forms/vitest.config.ts +++ b/packages/forms/vitest.config.ts @@ -1,5 +1,13 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, mergeConfig } from 'vitest/config'; +import { getVitestDatabaseContainerGlobalSetupPath } from '@atj/database'; import sharedTestConfig from '../../vitest.shared'; -export default defineConfig(sharedTestConfig); +export default mergeConfig( + sharedTestConfig, + defineConfig({ + test: { + globalSetup: [getVitestDatabaseContainerGlobalSetupPath()], + }, + }) +); diff --git a/packages/server/README.md b/packages/server/README.md index 640bfb6d..bdc53ef5 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -21,7 +21,7 @@ pnpm dev To start the provided Express server: ```typescript -import { createServer } from '@atj/server/dist/index.js'; +import { createServer } from '@atj/server'; const port = process.env.PORT || 4321; diff --git a/packages/server/astro.config.mjs b/packages/server/astro.config.mjs index ca1aac85..e8985539 100644 --- a/packages/server/astro.config.mjs +++ b/packages/server/astro.config.mjs @@ -4,13 +4,13 @@ import react from '@astrojs/react'; import { getGithubRepository } from './src/lib/github'; -const githubRepository = await getGithubRepository(process.env); +const githubRepository = await getGithubRepository(import.meta.env); // https://astro.build/config export default defineConfig({ output: 'server', trailingSlash: 'never', - base: addTrailingSlash(process.env.BASEURL || ''), + base: addTrailingSlash(import.meta.env.BASEURL || ''), adapter: node({ mode: 'middleware', }), @@ -19,6 +19,9 @@ export default defineConfig({ include: ['src/components/react/**'], }), ], + security: { + checkOrigin: true, + }, server: { port: 4322, }, diff --git a/packages/server/build-handler.js b/packages/server/build-handler.js new file mode 100644 index 00000000..df821bcb --- /dev/null +++ b/packages/server/build-handler.js @@ -0,0 +1,15 @@ +import esbuild from 'esbuild'; + +esbuild + .build({ + bundle: false, + entryPoints: ['./handler.ts'], + packages: 'external', + format: 'esm', + minify: false, + outdir: './dist', + platform: 'node', + sourcemap: true, + target: 'es2020', + }) + .catch(() => process.exit(1)); diff --git a/packages/server/src/index.ts b/packages/server/handler.ts similarity index 59% rename from packages/server/src/index.ts rename to packages/server/handler.ts index 9c07df5c..364000be 100644 --- a/packages/server/src/index.ts +++ b/packages/server/handler.ts @@ -1,11 +1,14 @@ +/** + * This is the entrypoint for the server. It provides a `createServer` factory + * that returns an Express handler, which in turn wraps the Astro web server. + * This en + */ import path, { dirname } from 'path'; import { fileURLToPath } from 'url'; import express from 'express'; -export type ServerOptions = { - title: string; -}; +import { type ServerOptions } from './src/context.js'; export const createServer = async ( serverOptions: ServerOptions @@ -15,10 +18,13 @@ export const createServer = async ( app.use((req, res, next) => { // Pass ServerOptions as request locals. handler(req, res, next, { + ctx: null, serverOptions, - } satisfies App.Locals); + session: null, + user: null, + }); }); - app.use(express.static(path.join(getDirname(), '../dist/client'))); + app.use(express.static(path.join(getDirname(), './client'))); return app; }; @@ -30,6 +36,6 @@ const getDirname = () => { const getHandler = async () => { // @ts-ignore - const { handler } = await import('../dist/server/entry.mjs'); + const { handler } = await import('./server/entry.mjs'); return handler; }; diff --git a/packages/server/package.json b/packages/server/package.json index 1bb32792..7075ecb7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,30 +2,38 @@ "name": "@atj/server", "type": "module", "version": "0.0.1", - "main": "dist/server/index.js", + "main": "dist/handler.js", + "types": "handler.ts", + "files": [ + "dist" + ], "scripts": { + "astro": "astro", + "build": "astro check && astro build && pnpm build:handler", + "build:handler": "node ./build-handler.js", + "clean": "rimraf dist tsconfig.tsbuildinfo coverage", "dev": "astro dev", - "start": "astro dev", - "build": "astro check && astro build && pnpm build:entry", - "build:entry": "tsup --dts --format esm src/index.ts", "preview": "astro preview", - "astro": "astro" + "start": "astro dev" }, "dependencies": { - "@astrojs/check": "^0.7.0", - "@astrojs/node": "^8.3.1", - "@astrojs/react": "^3.6.0", + "@astrojs/check": "^0.9.2", + "@astrojs/node": "^8.3.2", + "@astrojs/react": "^3.6.1", + "@atj/auth": "workspace:^", + "@atj/common": "workspace:*", + "@atj/database": "workspace:*", "@atj/design": "workspace:*", "@atj/forms": "workspace:*", - "astro": "^4.10.3", + "astro": "^4.13.2", "express": "^4.19.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.12", - "typescript": "^5.3.3" + "jwt-decode": "^4.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13" }, "devDependencies": { "@types/express": "^4.17.21", - "@types/react": "^18.2.62" + "@types/react": "^18.3.3" } } diff --git a/packages/server/src/components/AppAvailableFormList.tsx b/packages/server/src/components/AppAvailableFormList.tsx index 90ab5932..9621761d 100644 --- a/packages/server/src/components/AppAvailableFormList.tsx +++ b/packages/server/src/components/AppAvailableFormList.tsx @@ -2,14 +2,12 @@ import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { AvailableFormList } from '@atj/design'; -import { service } from '@atj/forms'; import { type AppContext } from '../context'; import { getFormManagerUrlById, getFormUrl } from '../routes'; import DebugTools from './DebugTools'; export default ({ ctx }: { ctx: AppContext }) => { - const formService = service.createBrowserFormService(); return ( { > getFormManagerUrlById(ctx.baseUrl, formId)} - formService={formService} + formService={ctx.formService} urlForForm={formId => getFormUrl(ctx.baseUrl, formId)} /> diff --git a/packages/server/src/components/AppFormManager.tsx b/packages/server/src/components/AppFormManager.tsx index 8039c542..849b6183 100644 --- a/packages/server/src/components/AppFormManager.tsx +++ b/packages/server/src/components/AppFormManager.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { FormManager } from '@atj/design'; -import { service } from '@atj/forms'; +import { FormServiceClient } from '../lib/api-client'; import { type AppContext } from '../context'; import { getFormManagerUrlById, getFormUrl } from '../routes'; @@ -12,7 +12,7 @@ type AppFormManagerContext = { }; export default function ({ ctx }: { ctx: AppFormManagerContext }) { - const formService = service.createBrowserFormService(); + const formService = new FormServiceClient({ baseUrl: ctx.baseUrl }); return ( ; } diff --git a/packages/server/src/components/Footer.astro b/packages/server/src/components/Footer.astro index a9e76999..6c9044c2 100644 --- a/packages/server/src/components/Footer.astro +++ b/packages/server/src/components/Footer.astro @@ -2,7 +2,7 @@ import { getBranchTreeUrl } from '../lib/github'; import { getAstroAppContext } from '../context'; -const { github } = getAstroAppContext(Astro); +const { github } = await getAstroAppContext(Astro); ---