diff --git a/.claude/skills/generate-e2e-tests/SKILL.md b/.claude/skills/generate-e2e-tests/SKILL.md new file mode 100644 index 000000000..022531c88 --- /dev/null +++ b/.claude/skills/generate-e2e-tests/SKILL.md @@ -0,0 +1,335 @@ +--- +name: generate-e2e-tests +description: Generate comprehensive Playwright e2e tests from a .netcanvas protocol file and an optional recording. Invoke with /generate-e2e-tests [recording-path] +user-invocable: true +--- + +# Generate E2E Tests from Protocol + +You are generating comprehensive Playwright e2e tests for a Fresco interview protocol. + +## Inputs + +- **Protocol path**: `$1` — path to a `.netcanvas` file (ZIP containing `protocol.json`) +- **Recording path** (optional): `$2` — path to a recording directory (contains `actions.jsonl`, `SESSION.md`, `screenshots/`) + +If no recording is provided, generate a **synthetic happy path** using the data generation strategies in STAGE_TEST_REFERENCE.md (see "Synthetic Data Generation" section). + +## Step 1: Read Reference Materials + +Read these files to understand the testing infrastructure and patterns: + +1. `tests/e2e/docs/STAGE_TEST_REFERENCE.md` — what to test for each stage type, fixture availability, validation testing patterns +2. `tests/e2e/fixtures/stage-fixture.ts` — available fixture methods and their signatures +3. `tests/e2e/fixtures/interview-fixture.ts` — interview navigation fixture +4. `tests/e2e/fixtures/protocol-fixture.ts` — protocol installation and network state inspection +5. `tests/e2e/CLAUDE.md` — full e2e testing architecture guide +6. `tests/e2e/specs/interview/silos-protocol.spec.ts` — reference test implementation to match style/structure +7. `CLAUDE.md` — project coding conventions (path aliases, TypeScript, etc.) + +## Step 2: Extract and Analyze Protocol + +Extract the protocol JSON: + +```bash +unzip -p "$1" protocol.json +``` + +From the extracted JSON, build a **stage map** — for each stage (by index), extract: + +- `type` — stage type (e.g., `NameGeneratorQuickAdd`, `EgoForm`, `Sociogram`) +- `label` — display label +- `subject` — `{ entity, type }` pointing to codebook entry (null for Information/Anonymisation) +- `introductionPanel` — title and text (if present) +- `form.fields[]` — array of `{ variable }` referencing codebook variables +- `prompts[]` — array of prompt objects (with `createEdge`, `variable`, `highlight`, etc.) +- `panels[]` — side panel configuration +- `behaviours` — `maxNodes`, `minNodes`, `freeDraw`, etc. + +For each form field, resolve the variable UUID against the codebook: + +- Look up `codebook.[entity].[type].variables.[variableId]` +- Extract: `name`, `type`, `component`, `validation`, `options` + +This gives you the field name (UUID), display name, input component type, validation rules, and available options for each form field. + +## Step 3: Analyze Recording (if provided) + +If `$2` is provided, read `$2/actions.jsonl` (one JSON object per line). + +Group actions by stage — track URL changes via the `step=N` query parameter. For each stage visited: + +- Extract the sequence of user actions (click, fill, press, select) +- Note filled values and selected options +- Note which nodes were created (names entered in quick-add or name generator forms) +- Note edge-creating interactions (sociogram clicks, dyad census selections) + +The recording represents the **happy path** — the exact user journey to replay. + +### If no recording + +Generate a synthetic happy path from the protocol alone: + +1. Walk through all stages in order (index 0 to N) +2. For each stage, use the **Synthetic Data Generation** section of STAGE_TEST_REFERENCE.md to determine what values to fill, how many nodes to create, etc. +3. For conditional/skip logic, choose the path that visits the **most stages** +4. Track synthetic state as you go — node names created on earlier stages are needed for bin/census/sociogram stages later + +## Step 4: Generate Test File + +Create `tests/e2e/specs/interview/.spec.ts` where `` is derived from the protocol name (kebab-case, lowercase). + +### File Structure + +Follow this exact pattern (from the reference implementation): + +```typescript +/** + * Tests + * + * Tests interview stage navigation using a real .netcanvas protocol file. + */ + +import path from 'node:path'; +import { expect, test } from '~/tests/e2e/fixtures/interview-test.js'; +import { expectURL } from '~/tests/e2e/helpers/expectations.js'; + +const PROTOCOL_PATH = path.resolve( + import.meta.dirname, + '../../data/.netcanvas', +); + +let sharedProtocolId: string; + +test.describe('', () => { + test.beforeAll(async ({ database, protocol }) => { + await database.restoreSnapshot(); + const { protocolId } = await protocol.install(PROTOCOL_PATH); + sharedProtocolId = protocolId; + }); + + test.describe('Happy Path', () => { + test.describe.configure({ mode: 'serial' }); + + let interviewId: string; + + test.beforeAll(async ({ protocol }) => { + interviewId = await protocol.createInterview(sharedProtocolId); + }); + + test.beforeEach(({ interview }) => { + interview.interviewId = interviewId; + }); + + test.afterEach(async ({ page, interview }) => { + const stepMatch = /step=(\d+)/.exec(page.url()); + if (stepMatch?.[1]) { + const step = stepMatch[1]; + // List stage indices with non-deterministic rendering + const highToleranceStages: string[] = [/* sociogram indices */]; + + await interview.capture(`stage-${step}-final`, { + maxDiffPixelRatio: highToleranceStages.includes(step) + ? 0.1 + : undefined, + }); + } + }); + + // One test() per stage... + }); +}); +``` + +### Per-Stage Test Generation + +For each stage in the protocol, generate a `test()` block. Use the STAGE_TEST_REFERENCE.md to determine what to test. + +#### Mapping Recording Actions to Fixture Calls + +Translate recording actions to fixture method calls using these mappings: + +| Recording Pattern | Fixture Call | +|---|---| +| Navigate to URL with `step=N` | `interview.goto(N)` | +| Click element matching next/forward button | `interview.nextButton.click()` | +| Fill input within `[data-field-name="UUID"]` | `stage.form.fillText(UUID, value)` or `fillNumber`/`fillDate` based on codebook component | +| Click radio within `[data-field-name="UUID"]` | `stage.form.selectRadio(UUID, optionLabel)` | +| Click checkbox within `[data-field-name="UUID"]` | `stage.form.selectCheckbox(UUID, optionLabel)` | +| Click toggle button within `[data-field-name="UUID"]` | `stage.form.selectToggleButton(UUID, optionLabel)` | +| Fill quick-add input + press Enter | `stage.quickAdd.addNode(value)` | +| Click "Add a person" button | `stage.nameGenerator.openAddForm()` | +| Click "Finished" button in dialog | `stage.nameGenerator.submitForm()` | +| Drag node from panel | `stage.nodePanel.dragNodeToMainList(label)` | +| Click node on sociogram (connecting) | `stage.sociogram.connectNodes(from, to)` | +| Drag node to ordinal bin | `stage.ordinalBin.dragNodeToBin(node, bin)` | +| Drag node to categorical bin | `stage.categoricalBin.dragNodeToBin(node, bin)` | + +#### Determine Form Method from Codebook + +Use the codebook variable's `component` (or `type` if no component) to pick the right form fixture method: + +| Component | Method | +|---|---| +| `Text`, `TextArea` | `fillText` | +| `Number` | `fillNumber` | +| `DatePicker` | `fillDate` | +| `RadioGroup` | `selectRadio` | +| `LikertScale` | `selectLikert` | +| `CheckboxGroup` | `selectCheckbox` | +| `ToggleButtonGroup` | `selectToggleButton` | +| `Boolean` | `selectRadio` (options are "Yes"/"No" or custom labels from codebook) | + +#### Comments + +Add a comment above each form field interaction with the field's display name and component type: + +```typescript +// 1. Date of birth (DatePicker) +await stage.form.fillDate('596c2ac2-...', '2000-06-15'); + +// 2. Gender identity (RadioGroup) +await stage.form.selectRadio('a06f06f5-...', 'Cisgender Male'); +``` + +### Validation Tests + +For each form stage (EgoForm, AlterForm, AlterEdgeForm), examine the codebook variables for targeted validation rules. Generate validation test assertions **within the happy path test** for that stage: + +1. **Before filling fields**: Try to advance, verify validation blocks: + ```typescript + // Verify validation blocks advancement + await interview.nextButton.click(); + await expectURL(page, /step=N/); // Still on same stage + + // Verify required field errors + await expect( + stage.form.getFieldError('field-uuid'), + ).toBeVisible(); + ``` + +2. **Then fill fields normally** from the recording data. + +Only test these validations (skip others): +- `required: true` — always test +- `minValue` / `maxValue` — test if present +- `minLength` / `maxLength` — test if present +- `pattern` — test if present +- `unique` — test if applicable (needs duplicate value scenario) +- `sameAs` / `differentFrom` — test if present + +### Network State Verification + +The sync middleware uses a 3-second debounce with leading+trailing edges. Each `interview.goto()` destroys the current page, killing any pending trailing-edge syncs. Stages that set data used by downstream skip logic or filtering must explicitly wait for that data to persist. + +#### Form stages (EgoForm, AlterForm) must click Next to submit + +Form data lives in React Hook Form's local state until the form is submitted. **You must click `interview.nextButton` at the end of every form stage** to flush the data to Redux. Without this, the sync middleware never sees the data. + +For **EgoForm** stages, click Next as the last interaction (replaces the `toBeEnabled` assertion): + +```typescript +// Submit form to flush data to Redux +await interview.nextButton.click(); +``` + +For **AlterForm** stages with slides, click Next after filling the **last slide** (the earlier slides already submit when you click Next to advance): + +```typescript +// Submit last slide to flush form data to Redux +await interview.nextButton.click(); +``` + +Note: clicking Next navigates to the next stage, so the `afterEach` screenshot will capture the next stage's initial state rather than the current stage's final state. + +#### Persistence waits for skip logic + +After stages that set attributes consumed by downstream skip logic or filtering, add explicit waits using the protocol fixture. Available methods: + +- `protocol.waitForNodes(interviewId, expectedCount)` — after node creation stages +- `protocol.waitForNode(interviewId, nodeName)` — when count alone is ambiguous +- `protocol.waitForNodeAttribute(interviewId, nodeName, attributeId)` — after CategoricalBin, OrdinalBin, or AlterForm stages (checks for non-null value) +- `protocol.waitForEgoAttribute(interviewId, attributeId, expectedValue)` — after EgoForm stages + +Example for a CategoricalBin stage with downstream skip logic: + +```typescript +test('Stage N: CategoricalBin', async ({ interview, stage, protocol }) => { + await interview.goto(N); + + await stage.categoricalBin.dragNodeToBin('Dan', 'Yes'); + await stage.categoricalBin.dragNodeToBin('Alice', 'No'); + + await expect(interview.nextButton).toBeEnabled(); + + // Wait for the LAST categorized node's attribute to persist + await protocol.waitForNodeAttribute( + interview.interviewId, + 'Alice', + 'variable-uuid', + ); +}); +``` + +**Always add `protocol` to the test's destructured fixtures** when using persistence waits. + +### Stages With Placeholder Fixtures + +Check the Fixture Availability Summary in STAGE_TEST_REFERENCE.md. If a stage type's fixture is marked **Placeholder**, generate a minimal test with a TODO referencing the placeholder: + +```typescript +test('Stage N: Stage Label', async ({ page, interview }) => { + await interview.goto(N); + + // TODO: stage.dyadCensus is a placeholder fixture — implement its + // interaction methods before writing full test assertions. + // See DyadCensusFixture JSDoc in stage-fixture.ts for the methods needed. + // + // Expected behavior from recording: + // - Dismiss intro panel + // - Select Yes/No for each node pair + // - Auto-advances after 350ms +}); +``` + +Always reference the `stage.` property (e.g., `stage.dyadCensus`, `stage.narrative`) so the test structure is ready — it just needs the fixture methods implemented. Never use raw Playwright selectors as a fallback. + +### Skipped Stages + +If the recording skips certain stage indices (e.g., conditional stages), add a comment: + +```typescript +// Stages N-M are skipped (conditional on ) +``` + +### Browser-Specific Skips + +Add `test.skip()` for known browser limitations: + +```typescript +// Skip geospatial on Firefox (no WebGL in Playwright's Firefox) +test.skip(browserName === 'firefox', 'Firefox lacks WebGL support in Playwright'); +``` + +## Step 5: Verify Protocol File Location + +Check if the `.netcanvas` file is already in `tests/e2e/data/`. If not, suggest copying it there and update the path constant accordingly. + +## Step 6: Output Summary + +After generating the test file, output: +1. Path to the generated test file +2. Number of stages covered +3. Number of validation tests included +4. List of stages with TODO placeholders (missing fixtures) +5. Suggested next steps (copy protocol to test data, run tests, etc.) + +## Important Rules + +- **Always use path aliases** (`~/tests/e2e/...`) for imports, never relative paths +- **Use `.js` extensions** in import paths (TypeScript with ESM) +- **Field names are UUIDs** — always use the variable UUID from the codebook, not the display name +- **Serial mode** — interview tests MUST use `test.describe.configure({ mode: 'serial' })` +- **Soft assertions for screenshots** — the `afterEach` capture pattern handles this via `interview.capture()` +- **No `console.log`** — project ESLint rule forbids it +- **Follow existing patterns** — match the style, structure, and conventions of `silos-protocol.spec.ts` exactly diff --git a/.dockerignore b/.dockerignore index 0d425cd4b..b7133f47b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,10 @@ node_modules npm-debug.log README.md .next +.pnpm-store +.e2e-assets +.worktrees +test-results .git .gitignore .env* diff --git a/.env.development b/.env.development new file mode 100644 index 000000000..09c17eadb --- /dev/null +++ b/.env.development @@ -0,0 +1,11 @@ +# Local development S3 storage — points at the MinIO container started by `pnpm dev`. +# These values are intentional test credentials and are safe to commit. +# To test the full setup wizard, comment out STORAGE_PROVIDER so the provider +# is not pinned and the settings UI stays editable. +STORAGE_PROVIDER=s3 +S3_ENDPOINT=http://localhost:9000 +S3_PUBLIC_URL=http://localhost:9000 +S3_BUCKET=fresco-dev +S3_REGION=us-east-1 +S3_ACCESS_KEY_ID=minioadmin +S3_SECRET_ACCESS_KEY=minioadmin diff --git a/.env.example b/.env.example index 3a0f1c11d..e38893d6e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,9 @@ +# ------------------- +# Required environment variables +# ------------------- +DATABASE_URL="postgres://user:password@host:5432/database?schema=public" # A pooled connection URL for Prisma. +DATABASE_URL_UNPOOLED="postgres://user:password@host:5432/database?schema=public" # A non-pooling connection URL for Prisma + # ------------------- # Optional environment variables - uncomment to use # ------------------- @@ -5,16 +11,26 @@ #DISABLE_ANALYTICS # true or false - If true, the app will not send anonymous analytics and error data. Defaults to false. #SANDBOX_MODE=false # true or false - if true, the app will use the sandbox mode, which disables resetting the database and other features #PUBLIC_URL="http://yourdomain.com" # When using advanced deployment, this is required. Set to the domain name of your app -#INSTALLATION_ID="your-app-name" # A unique identifier for your app, used for analytics. Generated automatically if not set. #USE_NEON_POSTGRES_ADAPTER=false # true or false - If true, uses Neon serverless PostgreSQL adapter instead of standard pg adapter. Required for Vercel/Netlify deployments with Neon. Defaults to false. # ------------------- -# Required environment variables +# Storage (optional) - configure here OR through the in-app setup wizard. +# Preferred for production: setting these here keeps the secret values in the +# environment instead of the database, and locks the corresponding setup fields. +# See docker-compose.external-s3.yml / docker-compose.uploadthing.yml. # ------------------- +#STORAGE_PROVIDER= # "s3" or "uploadthing" - pins the provider +#S3_ENDPOINT= # e.g. https://s3.us-east-1.amazonaws.com (must be an http(s) URL, not a private/loopback address) +#S3_PUBLIC_URL= # public base URL browsers use to reach assets +#S3_BUCKET= +#S3_REGION= +#S3_ACCESS_KEY_ID= +#S3_SECRET_ACCESS_KEY= +#UPLOADTHING_TOKEN= # use instead of the S3_* variables when using UploadThing -POSTGRES_USER="postgres" # Your PostgreSQL username -POSTGRES_PASSWORD="postgres" # Your PostgreSQL password -POSTGRES_DATABASE="postgres" # Your PostgreSQL database name -POSTGRES_HOST="postgres" # Your PostgreSQL host -DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DATABASE}?schema=public" # A pooled connection URL for Prisma. -DATABASE_URL_UNPOOLED="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DATABASE}?schema=public" # A non-pooling connection URL for Prisma \ No newline at end of file +# ------------------- +# Bundled MinIO (docker-compose.prod.yml only) - REQUIRED, no defaults. +# Choose a non-default access key and a long random secret. +# ------------------- +#MINIO_ROOT_USER= +#MINIO_ROOT_PASSWORD= \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index 8dcfe753a..000000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,69 +0,0 @@ -const path = require('path'); - -/** @type {import("eslint").Linter.Config} */ -const config = { - overrides: [ - { - extends: [ - 'plugin:@typescript-eslint/stylistic-type-checked', - 'plugin:@typescript-eslint/recommended-type-checked', - ], - files: ['*.ts', '*.tsx'], - parserOptions: { - project: path.join(__dirname, 'tsconfig.json'), - }, - }, - ], - parser: '@typescript-eslint/parser', - parserOptions: { - project: path.join(__dirname, 'tsconfig.json'), - }, - plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/stylistic', - 'plugin:@typescript-eslint/recommended', - 'next/core-web-vitals', - 'prettier', - 'plugin:storybook/recommended', - ], - ignorePatterns: [ - 'node_modules', - '*.stories.*', - '*.test.*', - 'public', - '.eslintrc.cjs', - 'lib/gb/generated', - 'storybook-static', - ], - rules: { - '@next/next/no-img-element': 'off', - 'import/no-anonymous-default-export': 'off', - '@typescript-eslint/consistent-type-definitions': ['error', 'type'], - 'no-process-env': 'error', - 'no-console': 'error', - '@typescript-eslint/consistent-type-imports': [ - 'warn', - { - prefer: 'type-imports', - fixStyle: 'inline-type-imports', - }, - ], - '@typescript-eslint/no-unused-vars': [ - 'error', - { - caughtErrors: 'none', - argsIgnorePattern: '^_', - }, - ], - '@typescript-eslint/no-misused-promises': [ - 'error', - { - checksVoidReturn: false, - }, - ], - 'no-unreachable': 'error', - }, -}; - -module.exports = config; diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9a0a68482..4a7cb67ad 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,4 +4,4 @@ updates: directory: '/' target-branch: 'next' schedule: - interval: "weekly" + interval: 'weekly' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a8e428c5..6d8d05750 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,132 +11,24 @@ on: - '*' jobs: - lint: + check: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Lint + command: pnpm lint + - name: Type check + command: pnpm typecheck + - name: Unit tests + command: pnpm test:unit + - name: Knip + command: pnpm knip + name: ${{ matrix.name }} steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Setup + uses: complexdatacollective/github-actions/setup-pnpm@4d9ebee3e57834a7ac8389a57d0fe1ef3614be14 # v2 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm lint - - typecheck: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Type check - run: pnpm typecheck - - test: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run tests - run: pnpm test - - knip: - env: - SKIP_ENV_VALIDATION: true - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run knip - run: pnpm knip + - name: ${{ matrix.name }} + run: ${{ matrix.command }} diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 000000000..105a8f385 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,116 @@ +name: 'Chromatic' +permissions: + contents: read + pull-requests: write + statuses: write + +on: + push: + workflow_dispatch: + issue_comment: + types: [created] + +jobs: + chromatic: + name: Run Chromatic + runs-on: ubuntu-latest + if: > + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/chromatic') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) + steps: + - name: Get PR details + if: github.event_name == 'issue_comment' + id: pr + uses: actions/github-script@v9 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + core.setOutput('ref', pr.data.head.ref); + core.setOutput('sha', pr.data.head.sha); + + await Promise.all([ + github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'rocket' + }), + github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: pr.data.head.sha, + state: 'pending', + context: 'Chromatic', + description: 'Visual tests running...' + }) + ]); + + - name: Setup + uses: complexdatacollective/github-actions/setup-pnpm@4d9ebee3e57834a7ac8389a57d0fe1ef3614be14 # v2 + with: + fetch-depth: '0' + cache-key-prefix: chromatic-pnpm-store + + - name: Run Chromatic + id: chromatic + uses: chromaui/action@e3eb8ec36101d8f0253c7c3ae66e5a2b4e2197ba # v16.10.0 + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + autoAcceptChanges: main + onlyChanged: true + + - name: Post links to PR + if: github.event_name == 'issue_comment' + uses: actions/github-script@v9 + env: + BRANCH: ${{ steps.pr.outputs.ref }} + with: + script: | + const branch = process.env.BRANCH; + const encodedBranch = encodeURIComponent(branch); + const body = [ + '### Chromatic', + '', + `| | |`, + `|---|---|`, + `| **Storybook** | [View on Chromatic](https://${encodedBranch}--68b1958ee9350657446b5406.chromatic.com) |`, + `| **Library** | [View on Chromatic](https://www.chromatic.com/library?appId=68b1958ee9350657446b5406&branch=${encodedBranch}) |`, + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body + }); + + - name: Update commit status + if: github.event_name == 'issue_comment' && always() + uses: actions/github-script@v9 + env: + CHROMATIC_OUTCOME: ${{ steps.chromatic.outcome }} + PR_SHA: ${{ steps.pr.outputs.sha }} + BUILD_URL: ${{ steps.chromatic.outputs.buildUrl }} + with: + script: | + const success = process.env.CHROMATIC_OUTCOME === 'success'; + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: process.env.PR_SHA, + state: success ? 'success' : 'failure', + context: 'Chromatic', + description: success + ? 'Visual tests passed' + : 'Visual tests failed', + target_url: process.env.BUILD_URL || undefined + }); diff --git a/.github/workflows/deploy-storybook.yaml b/.github/workflows/deploy-storybook.yaml deleted file mode 100644 index 145c14586..000000000 --- a/.github/workflows/deploy-storybook.yaml +++ /dev/null @@ -1,64 +0,0 @@ -name: Deploy Storybook - -on: - push: - branches: - - main - pull_request: - branches: - - main - - 'v*' - workflow_dispatch: - -permissions: - contents: read - pull-requests: write - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build Storybook - run: pnpm build-storybook - - - name: Deploy to Netlify - uses: nwtgck/actions-netlify@v3 - with: - publish-dir: './storybook-static' - production-branch: main - production-deploy: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - github-token: ${{ secrets.GITHUB_TOKEN }} - deploy-message: "Deploy from GitHub Actions - ${{ github.event.head_commit.message || github.event.pull_request.title }}" - enable-pull-request-comment: true - enable-commit-comment: false - overwrites-pull-request-comment: true - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STORYBOOK_SITE_ID }} - timeout-minutes: 5 \ No newline at end of file diff --git a/.github/workflows/docker-build-pr.yml b/.github/workflows/docker-build-pr.yml deleted file mode 100644 index 35afc748e..000000000 --- a/.github/workflows/docker-build-pr.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Build Docker Image (PR) - -on: - # Allow other workflows to call this one - workflow_call: - inputs: - disable-image-optimization: - description: 'Disable Next.js image optimization for testing' - type: boolean - default: false - outputs: - artifact-name: - description: 'Name of the uploaded Docker image artifact' - value: ${{ jobs.build.outputs.artifact-name }} - image-name: - description: 'Name to use when loading the image' - value: ${{ jobs.build.outputs.image-name }} - - # Also run directly on PRs (all branches) - pull_request: - -permissions: - contents: read - -env: - IMAGE_NAME: fresco-pr - -jobs: - build: - runs-on: ubuntu-latest - - outputs: - artifact-name: ${{ steps.artifact-info.outputs.name }} - image-name: ${{ env.IMAGE_NAME }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Generate artifact name - id: artifact-info - run: | - # Use run_id for unique artifact names (works for both PR and workflow_call) - echo "name=docker-image-${{ github.run_id }}" >> $GITHUB_OUTPUT - - - name: Build Docker image - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64 - push: false - tags: ${{ env.IMAGE_NAME }}:latest - cache-from: type=gha - cache-to: type=gha,mode=max - outputs: type=docker,dest=/tmp/image.tar - build-args: | - DISABLE_IMAGE_OPTIMIZATION=${{ inputs.disable-image-optimization || false }} - - - name: Upload Docker image artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.artifact-info.outputs.name }} - path: /tmp/image.tar - retention-days: 7 - compression-level: 1 # Minimal compression for faster upload/download diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index bbd4406fc..c96e1ccba 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,13 +16,13 @@ jobs: steps: - name: Check out the code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -36,10 +36,10 @@ jobs: echo "version=$VERSION" >> $GITHUB_ENV - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Build and Push Multi-platform Docker Image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 79be69bbc..000000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: E2E Tests - -permissions: - contents: read - -on: - push: - branches: [main, next] - pull_request: - -jobs: - # Build Docker image for PRs - calls reusable workflow - build-image-pr: - if: github.event_name == 'pull_request' - uses: ./.github/workflows/docker-build-pr.yml - with: - disable-image-optimization: true - - # Build Docker image for push events - builds inline - build-image-push: - if: github.event_name == 'push' - runs-on: ubuntu-latest - outputs: - artifact-name: docker-image-push-${{ github.run_id }} - image-name: fresco - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build Docker image - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64 - push: false - tags: fresco:test - cache-from: type=gha - cache-to: type=gha,mode=max - outputs: type=docker,dest=/tmp/image.tar - build-args: | - DISABLE_IMAGE_OPTIMIZATION=true - - - name: Upload Docker image artifact - uses: actions/upload-artifact@v4 - with: - name: docker-image-push-${{ github.run_id }} - path: /tmp/image.tar - retention-days: 1 - compression-level: 1 - - # Run E2E tests using whichever image was built - e2e: - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [build-image-pr, build-image-push] - if: always() && (needs.build-image-pr.result == 'success' || needs.build-image-push.result == 'success') - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Get pnpm store directory - id: pnpm-cache - run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Install Playwright browsers - run: pnpm playwright install --with-deps - - - name: Download Docker image (PR) - if: github.event_name == 'pull_request' - uses: actions/download-artifact@v4 - with: - name: ${{ needs.build-image-pr.outputs.artifact-name }} - path: /tmp - - - name: Download Docker image (Push) - if: github.event_name == 'push' - uses: actions/download-artifact@v4 - with: - name: ${{ needs.build-image-push.outputs.artifact-name }} - path: /tmp - - - name: Load Docker image - run: docker load --input /tmp/image.tar - - - name: Run E2E tests - run: pnpm test:e2e - env: - CI: true - TEST_IMAGE_NAME: ${{ github.event_name == 'pull_request' && format('{0}:latest', needs.build-image-pr.outputs.image-name) || 'fresco:test' }} - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: | - tests/e2e/playwright-report/ - tests/e2e/test-results/ - retention-days: 30 - - - name: Upload test videos - if: failure() - uses: actions/upload-artifact@v4 - with: - name: test-videos - path: tests/e2e/test-results/**/*.webm - retention-days: 7 diff --git a/.github/workflows/netlify-cleanup-preview.yml b/.github/workflows/netlify-cleanup-preview.yml new file mode 100644 index 000000000..ceff9802e --- /dev/null +++ b/.github/workflows/netlify-cleanup-preview.yml @@ -0,0 +1,26 @@ +name: Cleanup Deploy Preview +on: + pull_request: + types: [closed] +permissions: + contents: read +jobs: + delete-preview: + runs-on: ubuntu-latest + env: + BRANCH_NAME: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.number }} + steps: + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - name: Delete Neon Branch + env: + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + run: bunx neonctl branches delete "preview/pr-$PR_NUMBER-$BRANCH_NAME" --project-id ${{ vars.NEON_PROJECT_ID }} + + - name: Remove branch-scoped Netlify env vars + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + run: | + bunx netlify-cli env:unset DATABASE_URL --context "branch:$BRANCH_NAME" + bunx netlify-cli env:unset DATABASE_URL_UNPOOLED --context "branch:$BRANCH_NAME" diff --git a/.github/workflows/netlify-deploy-preview.yml b/.github/workflows/netlify-deploy-preview.yml new file mode 100644 index 000000000..b6ffe473d --- /dev/null +++ b/.github/workflows/netlify-deploy-preview.yml @@ -0,0 +1,134 @@ +name: Deploy Preview + +on: + pull_request: + # Only for development-track PRs (base = next). The sandbox-dev Netlify + # project is wired to this branch; PRs into main go through a separate + # production workflow / project. + branches: [next] + +permissions: + contents: read + pull-requests: write + +jobs: + deploy-preview: + runs-on: ubuntu-latest + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + steps: + - name: Create Neon Branch + id: create-branch + uses: neondatabase/create-branch-action@fb620d43d4c565abaf088b848a4e28e5c4ea4d9c # 6.3.1 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch_name: preview/pr-${{ github.event.number }}-${{ github.head_ref }} + api_key: ${{ secrets.NEON_API_KEY }} + + - name: Setup + uses: complexdatacollective/github-actions/setup-pnpm@4d9ebee3e57834a7ac8389a57d0fe1ef3614be14 # v2 + + - name: Install Netlify CLI + run: pnpm add -g netlify-cli + + - name: Set Netlify runtime environment variables + # Set the database URLs on Netlify *before* anything else so that the + # deploy preview Netlify auto-triggers from the same push has them + # available when its build container starts. If we set them after the + # migrations, the auto-deploy races ahead with stale env and the + # functions are baked with `connectionString: undefined`, which makes + # `node-postgres` fall back to PGHOST=localhost (127.0.0.1). + env: + BRANCH: ${{ github.head_ref }} + DB_URL_POOLED: ${{ steps.create-branch.outputs.db_url_pooled }} + DB_URL: ${{ steps.create-branch.outputs.db_url }} + run: | + netlify env:set DATABASE_URL "$DB_URL_POOLED" --context "branch:$BRANCH" + netlify env:set DATABASE_URL_UNPOOLED "$DB_URL" --context "branch:$BRANCH" + + - name: Get branch preview URL + id: branch-preview + env: + BRANCH: ${{ github.head_ref }} + run: | + SUBDOMAIN=$(curl -s -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \ + "https://api.netlify.com/api/v1/sites/$NETLIFY_SITE_ID" | jq -r '.name') + BRANCH_SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') + echo "url=https://${BRANCH_SLUG}--${SUBDOMAIN}.netlify.app" >> "$GITHUB_OUTPUT" + + - name: Write .env file + # Only used by the local migration/initialization scripts below to + # surface non-DB Netlify env vars (UploadThing keys, etc.). The DB URLs + # are passed to those steps directly via `env:` so the scripts don't + # depend on dotenv parsing this file correctly — `netlify env:list + # --plain` is not guaranteed to terminate with a newline, which would + # otherwise glue an appended `DATABASE_URL=...` line onto the previous + # variable's value. + run: netlify env:list --context deploy-preview --plain >> .env + + - name: Run Migrations + env: + DATABASE_URL: ${{ steps.create-branch.outputs.db_url_pooled }} + DATABASE_URL_UNPOOLED: ${{ steps.create-branch.outputs.db_url }} + run: npx tsx scripts/setup-database.ts + + - name: Run Initialization + env: + DATABASE_URL: ${{ steps.create-branch.outputs.db_url_pooled }} + DATABASE_URL_UNPOOLED: ${{ steps.create-branch.outputs.db_url }} + run: npx tsx scripts/initialize.ts + + - name: Trigger Netlify build and wait for completion + # Branch-scoped DATABASE_URL is set and migrations have run, so the + # build will see the right env. Triggering via API + polling means + # this CI step fails if the Netlify build fails (otherwise we'd post + # a working-preview comment for a broken deploy). + env: + BRANCH: ${{ github.head_ref }} + run: ./scripts/netlify-deploy.sh + + - name: Check for existing deploy comment + id: find-comment + if: success() + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + FOUND=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq '[.[] | select(.body | contains(""))] | length') + if [ "$FOUND" -gt 0 ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Comment on Pull Request + if: success() && steps.find-comment.outputs.exists != 'true' + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + comment-tag: deploy-preview + message: | + | Resource | Link | + |----------|------| + | Branch Preview 🌐 | ${{ steps.branch-preview.outputs.url }} | + | Neon branch 🐘 | https://console.neon.tech/app/projects/${{ vars.NEON_PROJECT_ID }}/branches/${{ steps.create-branch.outputs.branch_id }} | + + - name: Remove failure comment on success + if: success() + continue-on-error: true + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + comment-tag: deploy-preview-failure + mode: delete + + - name: Comment on failure + if: failure() + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + comment-tag: deploy-preview-failure + message: | + ⚠️ **Branch preview setup failed** + + Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. diff --git a/.github/workflows/netlify-deploy-status.yml b/.github/workflows/netlify-deploy-status.yml new file mode 100644 index 000000000..457f7da8f --- /dev/null +++ b/.github/workflows/netlify-deploy-status.yml @@ -0,0 +1,25 @@ +name: Production Deploy Status + +on: + push: + branches: [next] + +permissions: + contents: read + +jobs: + await-production-deploy: + runs-on: ubuntu-latest + env: + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + COMMIT_SHA: ${{ github.sha }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Await Netlify production deploy + # Netlify auto-deploys this push; we just observe via the API and + # surface the outcome as a real GitHub check (Netlify's own deploy + # notifications don't support production-deploy events on this site). + run: ./scripts/netlify-await-deploy.sh diff --git a/.gitignore b/.gitignore index b0861c59f..ffa160a9f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,8 @@ yarn-error.log* .pnpm-debug.log* # local env files -# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables +# .env.development is intentionally committed — it contains only dev-time test credentials. +# Do not commit .env (personal secrets) or any *.local files. .env .env*.local @@ -47,3 +48,36 @@ yarn-error.log* *storybook.log storybook-static + +# e2e testing +/tests/e2e/playwright-report/ +/tests/e2e/test-results/ +/tests/e2e/test-results-*/ +/tests/e2e/screenshots/ +/tests/e2e/.auth/ +/tests/e2e/.context-data.json +/tests/e2e/.context/ +/tests/e2e/.db-snapshots/ +/.e2e-assets/ +/playwright-report/ +/test-results/ +/playwright/.cache/ + +# Serena +.serena + +# playwright MCP +./playwright-mcp + +.pnpm-store +.pnpm-docker-store + +# Local Netlify folder +.netlify + +# Git worktrees +.worktrees + +# plans +/docs/plans +/docs/superpowers diff --git a/.prettierrc b/.prettierrc index 30dc564a1..0f9304eab 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,8 @@ "plugins": ["prettier-plugin-tailwindcss"], "printWidth": 80, "quoteProps": "consistent", - "singleQuote": true + "singleQuote": true, + "tabWidth": 2, + "useTabs": false, + "tailwindFunctions": ["cva", "cx"] } diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 14d86ad62..000000000 --- a/.serena/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/cache diff --git a/.serena/memories/code_style_conventions.md b/.serena/memories/code_style_conventions.md deleted file mode 100644 index c687f2fbb..000000000 --- a/.serena/memories/code_style_conventions.md +++ /dev/null @@ -1,33 +0,0 @@ -# Code Style and Conventions - -## TypeScript Configuration -- Strict mode enabled -- noUncheckedIndexedAccess enabled -- ESModule imports/exports -- Path mapping with `~/*` for project root - -## ESLint Rules -- TypeScript strict rules enabled -- Consistent type definitions (prefer `type` over `interface`) -- Type imports preferred with inline syntax -- No unused variables (args starting with `_` ignored) -- No console statements (use proper logging) -- No direct process.env access - -## Code Style -- **Prettier**: Single quotes, 80 character line width, Tailwind CSS plugin -- **File Extensions**: `.tsx` for React components, `.ts` for utilities -- **Import Style**: Type imports inline, consistent type imports -- **Naming**: camelCase for variables/functions, PascalCase for components - -## Component Structure -- React functional components with TypeScript -- Props typed with explicit interfaces/types -- Default exports for pages and main components -- Named exports for utilities and hooks - -## Database -- Prisma ORM with PostgreSQL -- cuid() for IDs -- Proper indexing on foreign keys -- Json fields for complex data (protocols, networks) \ No newline at end of file diff --git a/.serena/memories/codebase_structure.md b/.serena/memories/codebase_structure.md deleted file mode 100644 index b8b348adb..000000000 --- a/.serena/memories/codebase_structure.md +++ /dev/null @@ -1,40 +0,0 @@ -# Codebase Structure - -## Main Directories - -### `/app` - Next.js App Router -- **`(blobs)/`** - Setup and authentication pages -- **`(interview)/`** - Interview interface and routing -- **`api/`** - API routes (analytics, uploadthing) -- **`dashboard/`** - Admin dashboard pages and components - -### `/components` - Shared UI Components -- **`ui/`** - Base UI components (shadcn/ui based) -- **`data-table/`** - Data table components -- **`layout/`** - Layout components - -### `/lib` - Core Libraries -- **`interviewer/`** - Network Canvas interview engine - - `behaviors/` - Drag & drop, form behaviors - - `components/` - Interview UI components - - `containers/` - Interface containers - - `ducks/` - Redux state management -- **`network-exporters/`** - Data export functionality -- **`ui/`** - UI library components - -### `/actions` - Server Actions -Server-side functions for data operations - -### `/queries` - Database Queries -Prisma-based data fetching functions - -### `/schemas` - Validation Schemas -Zod schemas for data validation - -### `/utils` - Utility Functions -Helper functions and utilities - -## Key Files -- **`prisma/schema.prisma`** - Database schema -- **`env.js`** - Environment validation -- **`fresco.config.ts`** - Application configuration \ No newline at end of file diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md deleted file mode 100644 index 6b9f6cb0f..000000000 --- a/.serena/memories/project_overview.md +++ /dev/null @@ -1,23 +0,0 @@ -# Fresco Project Overview - -## Purpose -Fresco brings Network Canvas interviews to the web browser. It's a pilot project that provides a new way to conduct network interviews without adding new features to Network Canvas. - -## Tech Stack -- **Framework**: Next.js 14 with TypeScript -- **Database**: PostgreSQL with Prisma ORM -- **Authentication**: Lucia Auth -- **UI**: Tailwind CSS with Radix UI components -- **State Management**: Redux Toolkit for interviewer components -- **File Uploads**: UploadThing -- **Testing**: Vitest with React Testing Library -- **E2E Testing**: Playwright -- **Package Manager**: pnpm - -## Key Features -- Web-based network interviews -- Protocol upload and management -- Participant management -- Interview management with export capabilities -- Dashboard for administrators -- Real-time interview interface \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md deleted file mode 100644 index 1dc29f7f5..000000000 --- a/.serena/memories/suggested_commands.md +++ /dev/null @@ -1,31 +0,0 @@ -# Suggested Development Commands - -## Development -- `pnpm dev` - Start development server (includes Docker database setup) -- `pnpm build` - Build the application -- `pnpm start` - Start production server - -## Code Quality -- `pnpm lint` - Run ESLint (with env validation skipped) -- `pnpm ts-lint` - Run TypeScript type checking -- `pnpm ts-lint:watch` - Run TypeScript type checking in watch mode - -## Testing -- `pnpm test` - Run Vitest tests -- `pnpm load-test` - Run load testing with K6 - -## Database -- `npx prisma generate` - Generate Prisma client -- `npx prisma db push` - Push schema changes to database -- `npx prisma studio` - Open Prisma Studio - -## Utilities -- `pnpm knip` - Check for unused dependencies and exports -- `npx prettier --write .` - Format code with Prettier - -## System Commands (macOS) -- `ls` - List directory contents -- `cd` - Change directory -- `grep` - Search text patterns -- `find` - Find files and directories -- `git` - Git version control \ No newline at end of file diff --git a/.serena/memories/task_completion_checklist.md b/.serena/memories/task_completion_checklist.md deleted file mode 100644 index 04a729b2b..000000000 --- a/.serena/memories/task_completion_checklist.md +++ /dev/null @@ -1,39 +0,0 @@ -# Task Completion Checklist - -When completing any coding task, always run these commands in order: - -## 1. Type Checking -```bash -pnpm ts-lint -``` -Fix any TypeScript errors before proceeding. - -## 2. Linting -```bash -pnpm lint --fix -``` -This will automatically fix many ESLint issues. Fix any remaining issues manually. - -## 3. Code Formatting -```bash -npx prettier --write . -``` -Format all code according to project standards. - -## 4. Testing (if applicable) -```bash -pnpm test -``` -Run tests to ensure functionality is working correctly. - -## 5. Build Verification -```bash -pnpm build -``` -Verify the application builds successfully. - -## Additional Checks -- Ensure no `console.log` statements are left in production code -- Verify proper TypeScript types are used -- Check that imports use the `~/` path mapping where appropriate -- Ensure proper error handling is in place \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index eee8c06a0..000000000 --- a/.serena/project.yml +++ /dev/null @@ -1,68 +0,0 @@ -# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) -# * For C, use cpp -# * For JavaScript, use typescript -# Special requirements: -# * csharp: Requires the presence of a .sln file in the project folder. -language: typescript - -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 -ignore_all_files_in_gitignore: true -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed)on 2025-04-07 -ignored_paths: [] - -# whether the project is in read-only mode -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file or directory. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. -excluded_tools: [] - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: "" - -project_name: "Fresco" diff --git a/.storybook/main.ts b/.storybook/main.ts index ec1e6850e..7a1621ecf 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,16 +1,30 @@ -import type { StorybookConfig } from '@storybook/nextjs'; +import { defineMain } from '@storybook/nextjs-vite/node'; +import { stubUseServer } from './vite-plugin-stub-use-server.ts'; -const config: StorybookConfig = { - "stories": [ - "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)" +export default defineMain({ + addons: [ + '@storybook/addon-docs', + '@storybook/addon-a11y', + '@storybook/addon-vitest', + '@chromatic-com/storybook', ], - "addons": [], - "framework": { - "name": "@storybook/nextjs", - "options": {} + framework: { + name: '@storybook/nextjs-vite', + options: { + builder: { + // Customize the Vite builder options here + viteConfigPath: './vitest.config.ts', + }, + }, }, - "staticDirs": [ - "../public" - ] -}; -export default config; \ No newline at end of file + staticDirs: ['../public'], + typescript: { + check: false, + }, + stories: ['../**/*.stories.@(js|jsx|mjs|ts|tsx|mdx)'], + + viteFinal(config) { + config.plugins = [stubUseServer(), ...(config.plugins ?? [])]; + return config; + }, +}); diff --git a/.storybook/preview.ts b/.storybook/preview.ts deleted file mode 100644 index 73e6da9cf..000000000 --- a/.storybook/preview.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Preview } from '@storybook/nextjs' - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - }, -}; - -export default preview; \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 000000000..3117b9f99 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,99 @@ +import '@codaco/tailwind-config/fonts/inclusive-sans.css'; +import '@codaco/tailwind-config/fonts/nunito.css'; +import addonA11y from '@storybook/addon-a11y'; +import addonDocs from '@storybook/addon-docs'; +import addonVitest from '@storybook/addon-vitest'; +import { definePreview } from '@storybook/nextjs-vite'; +import isChromatic from 'chromatic/isChromatic'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { StrictMode } from 'react'; +import Providers from '../components/Providers'; +import '../styles/globals.css'; + +// @chromatic-com/storybook is not included here because it doesn't export a +// CSF Next compatible preview addon. It only provides server-side preset +// functionality and manager UI, so it's configured in main.ts only. +// See: https://github.com/chromaui/addon-visual-tests/pull/404 + +export default definePreview({ + addons: [addonDocs(), addonA11y(), addonVitest()], + parameters: { + options: { + storySort: { + order: [ + 'Design System', + ['Colors', 'Elevation', 'Type Scale', 'Typography'], + 'UI', + 'Systems', + ['Form', 'Dialogs', 'DragAndDrop'], + 'Interview', + '*', + ], + }, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + /** + * base-ui dialog adds focus guards which are picked up by a11y tests + * but are necessary for proper focus management within the dialog, + * and compatible with WCAG guidelines, so we disable this rule here. + */ + config: { + rules: [ + { + id: 'aria-hidden-focus', + selector: '[data-base-ui-focus-guard]', + enabled: false, + }, + ], + }, + }, + }, + + decorators: [ + (Story) => { + // Disable Base UI animations whenever the browser is being driven by + // automation (Playwright in vitest browser mode, or Storybook's + // play-function runner). This makes Base UI dialog open/close flows + // deterministic: they no longer wait on `getAnimations()` so sequences + // like "click Cancel → confirm dialog opens → click Continue editing" + // don't race the form store against CSS animation completion. + // + // Also togglable via `?disableAnimations=1` on the URL for interactive + // debugging of the animation-disabled code path. + // + // Manual browsing has `navigator.webdriver === false`, so interactive + // development still gets the full animations by default. + const disableAnimationsFromAutomation = + typeof navigator !== 'undefined' && navigator.webdriver === true; + const disableAnimations = + disableAnimationsFromAutomation || isChromatic(); + + return ( + // nextjs-vite doesn't seem to pick up the strict mode setting from next config + + {/** + * required by base-ui: https://base-ui.com/react/overview/quick-start#portals + */} +
+ + + +
+
+ ); + }, + ], +}); diff --git a/.storybook/tsconfig.json b/.storybook/tsconfig.json new file mode 100644 index 000000000..f45d85d0a --- /dev/null +++ b/.storybook/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["**/*", "../types/**/*.d.ts"] +} diff --git a/.storybook/vite-plugin-stub-use-server.ts b/.storybook/vite-plugin-stub-use-server.ts new file mode 100644 index 000000000..24652d3fb --- /dev/null +++ b/.storybook/vite-plugin-stub-use-server.ts @@ -0,0 +1,48 @@ +const useServerRegex = /^['"]use server['"]/; +const jsExtRegex = /\.[cm]?[jt]sx?$/; + +const exportAsyncFunctionRegex = /^export\s+async\s+function\s+(\w+)/gm; +const exportConstAsyncRegex = /^export\s+const\s+(\w+)\s*=\s*async/gm; +const exportTypeRegex = /^export\s+type\s+(\w+)/gm; + +function getFirstNonEmptyLine(code: string): string { + for (const line of code.split('\n')) { + const trimmed = line.trim(); + if (trimmed !== '') return trimmed; + } + return ''; +} + +export function stubUseServer() { + return { + name: 'stub-use-server', + enforce: 'pre' as const, + + transform(code: string, id: string) { + if (id.includes('node_modules') || !jsExtRegex.test(id)) { + return null; + } + + const firstLine = getFirstNonEmptyLine(code); + if (!useServerRegex.test(firstLine)) { + return null; + } + + const stubs: string[] = [`'use server';`]; + + for (const match of code.matchAll(exportAsyncFunctionRegex)) { + stubs.push(`export async function ${match[1]}() {}`); + } + + for (const match of code.matchAll(exportConstAsyncRegex)) { + stubs.push(`export const ${match[1]} = async () => {};`); + } + + for (const match of code.matchAll(exportTypeRegex)) { + stubs.push(`export type ${match[1]} = never;`); + } + + return { code: stubs.join('\n'), map: null }; + }, + }; +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index fd1d9bf0c..940260d85 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,3 @@ { - "recommendations": [ - "dbaeumer.vscode-eslint" - ] -} \ No newline at end of file + "recommendations": ["dbaeumer.vscode-eslint"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index f596762db..31ba0c876 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,12 +18,8 @@ "type": "node", "request": "launch", "program": "${workspaceFolder}/node_modules/.bin/next", - "runtimeArgs": [ - "--inspect" - ], - "skipFiles": [ - "/**" - ], + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], "serverReadyAction": { "action": "debugWithEdge", "killOnServerStop": true, @@ -33,4 +29,4 @@ } } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index ba732724f..2d3801a51 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,12 @@ { - "css.customData": [ - "./.vscode/css-data.json" - ], + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.quickSuggestions": { + "strings": "on" + }, + "css.customData": ["./.vscode/css-data.json"], "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "editor.codeActionsOnSave": { @@ -9,6 +14,15 @@ "source.fixAll": "always", "source.fixAll.eslint": "always", "source.fixAll.typescript": "always", + "source.fixAll.tailwindcss": "always" }, - "editor.formatOnSave": true -} \ No newline at end of file + "editor.formatOnSave": true, + "tailwindCSS.classFunctions": ["cva", "cx"], + // Rule is broken: https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1542 + // Implemented a fixable version using https://github.com/schoero/eslint-plugin-better-tailwindcss + // that can also be selectively disabled via eslint-disable-next-line comments + "tailwindCSS.lint.suggestCanonicalClasses": "ignore", + "files.associations": { + "*.css": "tailwindcss" + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 37a968ca8..0484b57bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This document provides guidance for AI assistants working with the Fresco codeba Fresco is a web-based interview platform that brings Network Canvas interviews to the browser. It's built with Next.js 14 (App Router), TypeScript, and PostgreSQL. Version 3.0.0. -**Documentation**: https://documentation.networkcanvas.com/en/fresco +**Documentation**: ## Quick Reference @@ -18,7 +18,7 @@ pnpm storybook # Component library at :6006 # Quality Checks pnpm lint # ESLint -pnpm ts-lint # TypeScript type checking +pnpm typecheck # TypeScript type checking pnpm test # Vitest unit tests pnpm knip # Find unused code @@ -47,15 +47,16 @@ components/ # React components lib/ # Core business logic ├── interviewer/ # Interview session management (Redux) -├── network-exporters/ # Data export functionality -└── network-query/ # Network analysis utilities +├── auth/ # Sessions, guards, password, WebAuthn, TOTP +├── storage/ # S3 / UploadThing storage layers +├── export/ # Export orchestration (uses @codaco/network-exporters) +└── db/ # Prisma schema (lib/db/schema.prisma), client, migrations hooks/ # Custom React hooks queries/ # Database query functions schemas/ # Zod validation schemas types/ # TypeScript type definitions utils/ # Utility functions -prisma/ # Database schema styles/ # Global CSS/SCSS ``` @@ -68,22 +69,27 @@ styles/ # Global CSS/SCSS - **Styling**: Tailwind CSS 4.1 + shadcn/ui - **State Management**: Redux Toolkit (interview sessions) - **Forms**: React Hook Form + Zod validation -- **Package Manager**: pnpm 9.1.1 +- **Package Manager**: pnpm 11.1.2 ## Code Conventions ### TypeScript - **Strict mode enabled** with `noUncheckedIndexedAccess` +- **Do not use type assertions (`as`)** to fix type errors unless absolutely necessary. Find the root cause of the typing issue and refactor to resolve it. Type assertions should ALWAYS be confirmed with the user first. - Use `type` for type definitions (not `interface`) - enforced by ESLint - Prefer inline type imports: `import { type Foo } from './bar'` - Unused variables must start with underscore: `_unusedVar` -- Path alias: `~/` maps to project root +- **Always use path aliases** (`~/`) for imports - never use relative paths like `../` or `./` ```typescript -// Correct +// Correct - use path aliases import { type Protocol } from '@prisma/client'; -import { cn } from '~/utils/shadcn'; +import { cx } from '~/utils/cva'; +import { Button } from '~/components/ui/Button'; + +// Incorrect - never use relative paths +// import { Button } from '../components/ui/Button'; // Type definition export type CreateInterview = { @@ -106,6 +112,7 @@ const dbUrl = env.DATABASE_URL; - `no-console` ESLint rule is enforced - Must disable ESLint for intentional logs: + ```typescript // eslint-disable-next-line no-console console.log('Debug info'); @@ -114,25 +121,26 @@ console.log('Debug info'); ### Server Actions Located in `/actions/`. Pattern: + - Mark with `'use server'` directive - Use `requireApiAuth()` for authentication - Return `{ error, data }` pattern -- Use `safeRevalidateTag()` for cache invalidation +- Use `safeUpdateTag()` for cache invalidation (read-your-own-writes) - Track events with `addEvent()` for activity feed ```typescript 'use server'; import { requireApiAuth } from '~/utils/auth'; -import { safeRevalidateTag } from '~/lib/cache'; -import { prisma } from '~/utils/db'; +import { safeUpdateTag } from '~/lib/cache'; +import { prisma } from '~/lib/db'; export async function deleteItem(id: string) { await requireApiAuth(); try { const result = await prisma.item.delete({ where: { id } }); - safeRevalidateTag('getItems'); + safeUpdateTag('getItems'); return { error: null, data: result }; } catch (error) { return { error: 'Failed to delete', data: null }; @@ -164,9 +172,11 @@ export default async function DashboardPage() { ### UI Components Using shadcn/ui with Tailwind. Follow the pattern: + - Use `cva` (class-variance-authority) for variants - Use `cn()` utility from `~/utils/shadcn` for class merging - Export component + variants + skeleton when applicable +- **Spread HTML props onto root element** - Components should accept all valid HTML attributes for their root element and spread them. This allows consumers to pass `data-testid`, `aria-*`, event handlers, etc. without the component needing explicit props for each. ```typescript import { cva, type VariantProps } from 'class-variance-authority'; @@ -186,8 +196,19 @@ const buttonVariants = cva('base-classes', { export type ButtonProps = { variant?: VariantProps['variant']; } & React.ButtonHTMLAttributes; + +// Example: spreading props onto root element +const Button = ({ variant, className, ...props }: ButtonProps) => ( + - ); -} diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/ConfigureStorage.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/ConfigureStorage.tsx new file mode 100644 index 000000000..b44d524f5 --- /dev/null +++ b/app/(blobs)/(setup)/_components/OnboardSteps/ConfigureStorage.tsx @@ -0,0 +1,30 @@ +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { type StorageEnvStatus } from '~/lib/storage/config'; +import { type S3EnvValues } from '~/schemas/s3Settings'; +import StorageProviderSelector from '../StorageProviderSelector'; + +export default function ConfigureStorage({ + storageEnv, + s3EnvValues, +}: { + storageEnv: StorageEnvStatus; + s3EnvValues: S3EnvValues | null; +}) { + return ( +
+
+ Configure Storage + + Fresco needs a storage provider for protocol assets and data exports. + Choose between UploadThing (managed service) or an S3-compatible + bucket (self-hosted or cloud). + + +
+
+ ); +} diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/ConnectUploadThing.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/ConnectUploadThing.tsx deleted file mode 100644 index 80cdccd3f..000000000 --- a/app/(blobs)/(setup)/_components/OnboardSteps/ConnectUploadThing.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { submitUploadThingForm } from '~/actions/appSettings'; -import Link from '~/components/Link'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { UploadThingTokenForm } from '../UploadThingTokenForm'; - -function ConnectUploadThing() { - return ( -
-
- Connect UploadThing - - Fresco uses a third-party service called UploadThing to store media - files, including protocol assets. In order to use this service, you - need to create an account with UploadThing that will allow you to - generate a token that Fresco can use to securely communicate with it. - - - - Click here - {' '} - to visit UploadThing. Create an app and copy and paste your API key - below. - - - Good to know: - - Your UploadThing account is unique to you, meaning that no one else - will have access to the files stored in your instance of Fresco. For - more information about UploadThing, please review the{' '} - - UploadThing Docs - - . - - - - For help, please refer to the{' '} - - deployment guide - {' '} - in the Fresco documentation. - - -
-
- ); -} - -export default ConnectUploadThing; diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx index 3a832143b..ee6348d6b 100644 --- a/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx @@ -1,27 +1,18 @@ import { SignUpForm } from '~/app/(blobs)/(setup)/_components/SignUpForm'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; +import Heading from '@codaco/fresco-ui/typography/Heading'; function CreateAccount() { return ( -
-
- Create an Account - - To use Fresco, you need to set up an administrator account which will - enable to you access the protected parts of the app. Only one - administrator account can be created. - -
- +
+ Create an Admin Account + {/* Important It is not possible to recover the account details if they are lost. Make sure to store the account details in a safe place, such as a password manager. - + */}
); diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx index 28aabc5df..51e74d1a3 100644 --- a/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx @@ -1,89 +1,64 @@ -import { createId } from '@paralleldrive/cuid2'; -import { FileText } from 'lucide-react'; -import { redirect } from 'next/navigation'; -import { setAppSetting } from '~/actions/appSettings'; -import Section from '~/components/layout/Section'; -import { Button } from '~/components/ui/Button'; -import SubmitButton from '~/components/ui/SubmitButton'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import trackEvent from '~/lib/analytics'; -import { getInstallationId } from '~/queries/appSettings'; - -function Documentation() { - const handleAppConfigured = async () => { - const installationId = await getInstallationId(); - if (!installationId) { - await setAppSetting('installationId', createId()); - } - await setAppSetting('configured', true); - void trackEvent({ - type: 'AppSetup', - metadata: { - installationId, - }, - }); +'use client'; - redirect('/dashboard'); - }; +import { FileText } from 'lucide-react'; +import { completeSetup } from '~/actions/appSettings'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import Button from '@codaco/fresco-ui/Button'; +export default function Documentation() { return ( -
-
- Documentation +
+
+ Documentation This is the end of the onboarding process. You are now ready to use Fresco! For further help and information, consider using the resources below.
-
-
-
- - About Fresco - - Visit our documentation site to learn more about Fresco. -
- -
-
-
- - Using Fresco - - Read our guide on the basic workflow for using Fresco to conduct - your study. -
- -
+
-
- Go to the dashboard! -
+
); } - -export default Documentation; diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx deleted file mode 100644 index 6d29ec2e0..000000000 --- a/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import ImportCSVModal from '~/app/dashboard/participants/_components/ImportCSVModal'; -import AnonymousRecruitmentSwitchClient from '~/components/AnonymousRecruitmentSwitchClient'; -import SettingsSection from '~/components/layout/SettingsSection'; -import LimitInterviewsSwitchClient from '~/components/LimitInterviewsSwitchClient'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import OnboardContinue from '../OnboardContinue'; - -function ManageParticipants({ - allowAnonymousRecruitment, - limitInterviews, -}: { - allowAnonymousRecruitment: boolean; - limitInterviews: boolean; -}) { - return ( -
-
- Configure Participation - - You can now optionally upload a CSV file containing the details of - participants you wish to recruit for your study. You can also choose - to allow anonymous recruitment of participants. Both options can be - configured later from the dashboard. - -
-
- } - > - Upload a CSV file of participants. - - - } - > - - Allow participants to join your study by visiting a URL. - - - - } - > - - Limit each participant to being allowed to complete one interview - per protocol. - - -
-
- -
-
- ); -} - -export default ManageParticipants; diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx index 653d40db6..bbfef59a5 100644 --- a/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx @@ -1,9 +1,10 @@ 'use client'; import { parseAsInteger, useQueryState } from 'nuqs'; -import ProtocolUploader from '~/app/dashboard/_components/ProtocolUploader'; -import { Button } from '~/components/ui/Button'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; +import ProtocolImportDropzone from '~/components/ProtocolImport/ProtocolImportDropzone'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Button } from '@codaco/fresco-ui/Button'; +import { useProtocolImport } from '~/hooks/useProtocolImport'; function ConfigureStudy() { const [currentStep, setCurrentStep] = useQueryState( @@ -11,31 +12,28 @@ function ConfigureStudy() { parseAsInteger.withDefault(1), ); + const { importProtocols } = useProtocolImport(); + const handleNextStep = () => { void setCurrentStep(currentStep + 1); }; return ( -
-
- Import Protocols - - If you have already created a Network Canvas protocol ( - .netcanvas) you can import it now. - - - If you don't have a protocol yet, you can upload one later from - the dashboard. - - -
-
- +
+ Import Protocols + + If you have already created a Network Canvas protocol ( + .netcanvas) you can import it now. + + + If you don't have a protocol yet, you can upload one later from the + dashboard. + + +
+
); diff --git a/app/(blobs)/(setup)/_components/S3ConfigForm.tsx b/app/(blobs)/(setup)/_components/S3ConfigForm.tsx new file mode 100644 index 000000000..013a9a786 --- /dev/null +++ b/app/(blobs)/(setup)/_components/S3ConfigForm.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { Alert, AlertDescription } from '@codaco/fresco-ui/Alert'; +import { Button } from '@codaco/fresco-ui/Button'; +import Field from '@codaco/fresco-ui/form/Field/Field'; +import Form from '@codaco/fresco-ui/form/Form'; +import SubmitButton from '@codaco/fresco-ui/form/SubmitButton'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import { saveS3Config, setStorageProvider } from '~/actions/storageProvider'; +import { type S3EnvValues, s3ConfigSchema } from '~/schemas/s3Settings'; + +export const S3ConfigForm = ({ + disabled = false, + defaultValues, +}: { + disabled?: boolean; + defaultValues?: S3EnvValues; +}) => { + const router = useRouter(); + const [isContinuing, setIsContinuing] = useState(false); + const [continueError, setContinueError] = useState(null); + + const handleContinue = async () => { + setIsContinuing(true); + setContinueError(null); + try { + const result = await setStorageProvider('s3'); + if (!result.success) { + setContinueError(result.error); + return; + } + router.push('/setup?step=3'); + } catch (caught) { + setContinueError( + caught instanceof Error + ? caught.message + : 'An unexpected error occurred', + ); + } finally { + setIsContinuing(false); + } + }; + + const handleSubmit = async (rawData: unknown) => { + if (disabled) { + return { success: true as const }; + } + + try { + const result = await saveS3Config(rawData); + + if (!result.success) { + return { + success: false as const, + fieldErrors: result.fieldErrors ?? {}, + formErrors: 'error' in result && result.error ? [result.error] : [], + }; + } + + router.push('/setup?step=3'); + return { success: true as const }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'An unexpected error occurred'; + return { + success: false as const, + formErrors: [message], + }; + } + }; + + return ( +
+ {disabled && ( + + + These S3 settings are configured via environment variables and + cannot be changed here. + + + )} + + + + + + + {disabled ? ( + <> + {continueError && ( +

{continueError}

+ )} + + + ) : ( + Save and continue + )} + + ); +}; diff --git a/app/(blobs)/(setup)/_components/SandboxCredentials.tsx b/app/(blobs)/(setup)/_components/SandboxCredentials.tsx index ff24262bc..84e00f243 100644 --- a/app/(blobs)/(setup)/_components/SandboxCredentials.tsx +++ b/app/(blobs)/(setup)/_components/SandboxCredentials.tsx @@ -1,12 +1,10 @@ -import { KeyRound } from 'lucide-react'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; +import { Alert, AlertDescription, AlertTitle } from '@codaco/fresco-ui/Alert'; import { env } from '~/env'; export default function SandboxCredentials() { if (!env.SANDBOX_MODE) return null; return ( - Sandbox Credentials
diff --git a/app/(blobs)/(setup)/_components/Sidebar.tsx b/app/(blobs)/(setup)/_components/Sidebar.tsx index bbf8ed3f4..d36a0d157 100644 --- a/app/(blobs)/(setup)/_components/Sidebar.tsx +++ b/app/(blobs)/(setup)/_components/Sidebar.tsx @@ -2,8 +2,9 @@ import { Check } from 'lucide-react'; import { parseAsInteger, useQueryState } from 'nuqs'; -import Heading from '~/components/ui/typography/Heading'; -import { cn } from '~/utils/shadcn'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import { cx } from '@codaco/fresco-ui/utils/cva'; function OnboardSteps({ steps }: { steps: string[] }) { const [currentStep, setCurrentStep] = useQueryState( @@ -12,11 +13,11 @@ function OnboardSteps({ steps }: { steps: string[] }) { ); return ( -
+ {steps.map((step, index) => (
index && 'pointer-events-auto cursor-pointer', @@ -24,10 +25,9 @@ function OnboardSteps({ steps }: { steps: string[] }) { onClick={() => void setCurrentStep(index + 1)} >
- + {step}
))} -
+
); } diff --git a/app/(blobs)/(setup)/_components/SignInForm.tsx b/app/(blobs)/(setup)/_components/SignInForm.tsx index b6c163a70..1742895b6 100644 --- a/app/(blobs)/(setup)/_components/SignInForm.tsx +++ b/app/(blobs)/(setup)/_components/SignInForm.tsx @@ -1,93 +1,364 @@ 'use client'; -import { Loader2 } from 'lucide-react'; +import { + browserSupportsWebAuthn, + startAuthentication, +} from '@simplewebauthn/browser'; +import { ArrowLeft, KeyRound, LockIcon, User2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { login } from '~/actions/auth'; -import { Button } from '~/components/ui/Button'; -import { Input } from '~/components/ui/Input'; -import UnorderedList from '~/components/ui/typography/UnorderedList'; -import { useToast } from '~/components/ui/use-toast'; -import useZodForm from '~/hooks/useZodForm'; +import { useEffect, useState } from 'react'; +import { login, recoveryCodeLogin, type LoginResult } from '~/actions/auth'; +import { verifyTwoFactor } from '~/actions/twoFactor'; +import { + generateAuthenticationOptions, + verifyAuthentication, +} from '~/actions/webauthn'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Button } from '@codaco/fresco-ui/Button'; +import { DialogFooter } from '@codaco/fresco-ui/dialogs/Dialog'; +import Field from '@codaco/fresco-ui/form/Field/Field'; +import Form from '@codaco/fresco-ui/form/Form'; +import SubmitButton from '@codaco/fresco-ui/form/SubmitButton'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import PasswordField from '@codaco/fresco-ui/form/fields/PasswordField'; +import SegmentedCodeField from '@codaco/fresco-ui/form/fields/SegmentedCodeField'; +import { type FormSubmitHandler } from '@codaco/fresco-ui/form/store/types'; import { loginSchema } from '~/schemas/auth'; +function isRateLimited( + result: LoginResult, +): result is { success: false; rateLimited: true; retryAfter: number } { + return 'rateLimited' in result; +} + +function isTwoFactorRequired(result: LoginResult): result is { + success: false; + requiresTwoFactor: true; + twoFactorToken: string; +} { + return 'requiresTwoFactor' in result; +} + export const SignInForm = () => { - const { - register, - handleSubmit, - setError, - formState: { errors, isSubmitting }, - } = useZodForm({ - schema: loginSchema, - }); - - const { toast } = useToast(); const router = useRouter(); - const onSubmit = async (data: unknown) => { + const [twoFactorRequired, setTwoFactorRequired] = useState(false); + const [twoFactorToken, setTwoFactorToken] = useState(null); + const [retryAfter, setRetryAfter] = useState(null); + const [useRecovery, setUseRecovery] = useState(false); + + const [webauthnSupported, setWebauthnSupported] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [passkeyError, setPasskeyError] = useState(null); + const [showRecovery, setShowRecovery] = useState(false); + + useEffect(() => { + setWebauthnSupported(browserSupportsWebAuthn()); + }, []); + + useEffect(() => { + if (retryAfter === null || retryAfter <= 0) { + return; + } + + const interval = setInterval(() => { + setRetryAfter((prev) => { + if (prev === null || prev <= 1) { + return null; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(interval); + }, [retryAfter]); + + const handleSubmit: FormSubmitHandler = async (data) => { const result = await login(data); + if (isRateLimited(result)) { + const secondsRemaining = Math.ceil( + (result.retryAfter - Date.now()) / 1000, + ); + setRetryAfter(Math.max(secondsRemaining, 1)); + return { + success: false, + formErrors: [ + `Too many attempts. Try again in ${String(Math.max(secondsRemaining, 1))} seconds.`, + ], + }; + } + + if (isTwoFactorRequired(result)) { + setTwoFactorToken(result.twoFactorToken); + setTwoFactorRequired(true); + return { success: false }; + } + if (result.success === true) { router.push('/dashboard'); - return; } - // Handle formErrors - if (result.formErrors.length > 0) { - toast({ - variant: 'destructive', - title: 'Login failed', - description: ( - <> - - {result.formErrors.map((error) => ( -
  • {error}
  • - ))} -
    - - ), - }); + return result; + }; + + const handleTwoFactorSubmit: FormSubmitHandler = async (data) => { + const values = data as Record; + const code = values.code; + if (!code) { + return { success: false, fieldErrors: { code: ['Code is required'] } }; + } + + const result = await verifyTwoFactor({ twoFactorToken, code }); + + if (!result.success) { + const error = + 'formErrors' in result && result.formErrors + ? (result.formErrors[0] ?? 'Verification failed') + : 'Verification failed'; + return { success: false, formErrors: [error] }; } - // Handle field errors - if (result.fieldErrors) { - for (const [field, message] of Object.entries(result.fieldErrors)) { - setError(`root.${field}`, { types: { type: 'manual', message } }); + router.push('/dashboard'); + return { success: true }; + }; + + const handlePasskeySignIn = async () => { + setPasskeyError(null); + setPasskeyLoading(true); + + try { + const { error, data } = await generateAuthenticationOptions(); + if (error || !data) { + setPasskeyError(error ?? 'Failed to start passkey authentication'); + return; + } + + // IMMEDIATELY call startAuthentication — preserves Safari user gesture + const credential = await startAuthentication({ + optionsJSON: data.options, + }); + + const result = await verifyAuthentication({ credential }); + if (result.error) { + setPasskeyError(result.error); + return; + } + + router.push('/dashboard'); + } catch (e) { + if (e instanceof Error && e.name === 'NotAllowedError') { + return; } + setPasskeyError('Passkey authentication failed'); + } finally { + setPasskeyLoading(false); + } + }; + + const handleRecoveryLogin: FormSubmitHandler = async (data) => { + const values = data as Record; + const username = values.username; + const recoveryCode = values.recoveryCode; + + if (!username || !recoveryCode) { + return { + success: false, + formErrors: ['Username and recovery code are required'], + }; } + + const result = await recoveryCodeLogin({ username, recoveryCode }); + + if (result.success) { + router.push('/dashboard'); + } + + return result; + }; + + const handleBackToSignIn = () => { + setTwoFactorRequired(false); + setTwoFactorToken(null); + setUseRecovery(false); + setShowRecovery(false); + setPasskeyError(null); }; + if (showRecovery) { + return ( +
    + } + /> + +
    + + + Sign in + +
    + + ); + } + + if (twoFactorRequired) { + return ( +
    + {useRecovery ? ( + + ) : ( + + )} + + + + + + Verify + + + + ); + } + return ( -
    void handleSubmit(onSubmit)(event)} - className="flex w-full flex-col" - > -
    - + + } /> -
    -
    - } /> -
    -
    - -
    -
    +
    + 0} + > + {retryAfter !== null && retryAfter > 0 + ? `Try again in ${String(retryAfter)}s` + : 'Sign in'} + +
    + + + {webauthnSupported && ( + <> +
    +
    + or +
    +
    + + + + {passkeyError && ( + + {passkeyError} + + )} + + )} + + + ); }; diff --git a/app/(blobs)/(setup)/_components/SignUpForm.tsx b/app/(blobs)/(setup)/_components/SignUpForm.tsx index ab16b5c87..94ecfc3f9 100644 --- a/app/(blobs)/(setup)/_components/SignUpForm.tsx +++ b/app/(blobs)/(setup)/_components/SignUpForm.tsx @@ -1,80 +1,203 @@ 'use client'; -import { Loader2 } from 'lucide-react'; +import { + browserSupportsWebAuthn, + startRegistration, +} from '@simplewebauthn/browser'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useMediaQuery } from 'usehooks-ts'; import { signup } from '~/actions/auth'; -import { Button } from '~/components/ui/Button'; -import { Input } from '~/components/ui/Input'; -import useZodForm from '~/hooks/useZodForm'; +import { + generateSignupRegistrationOptions, + signupWithPasskey, +} from '~/actions/webauthn'; +import Field from '@codaco/fresco-ui/form/Field/Field'; +import FieldGroup from '@codaco/fresco-ui/form/FieldGroup'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import PasswordField from '@codaco/fresco-ui/form/fields/PasswordField'; +import RichSelectGroupField from '@codaco/fresco-ui/form/fields/RichSelectGroup'; +import Form from '@codaco/fresco-ui/form/Form'; +import SubmitButton from '@codaco/fresco-ui/form/SubmitButton'; +import { + type FormSubmissionResult, + type FormSubmitHandler, +} from '@codaco/fresco-ui/form/store/types'; import { createUserSchema } from '~/schemas/auth'; -export const SignUpForm = () => { - const { - register, - handleSubmit, - watch, - trigger, - formState: { errors, isValid, isSubmitting }, - } = useZodForm({ - schema: createUserSchema, - mode: 'onTouched', - }); - - const onSubmit = async (data: unknown) => { - await signup(data); +type SignUpFormProps = { + sandboxMode?: boolean; +}; + +export const SignUpForm = ({ sandboxMode = false }: SignUpFormProps) => { + const router = useRouter(); + const [webauthnSupported, setWebauthnSupported] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [passkeyError, setPasskeyError] = useState(null); + + useEffect(() => { + setWebauthnSupported(browserSupportsWebAuthn()); + }, []); + + const showAuthMethodChoice = webauthnSupported && !sandboxMode; + + const handleSubmit: FormSubmitHandler = async (data) => { + const values = data as Record; + const authMethod = + typeof values?.authMethod === 'string' ? values.authMethod : 'password'; + const username = + typeof values?.username === 'string' ? values.username : ''; + + if (authMethod === 'passkey') { + return handlePasskeySignup(username); + } + + return handlePasswordSignup(data); + }; + + const handlePasswordSignup: FormSubmitHandler = async (data) => { + const result = await signup(data); + + return { + success: false, + formErrors: result.error ? [result.error] : [], + }; }; - const password = watch('password'); + const handlePasskeySignup = async ( + username: string, + ): Promise => { + if (!username) { + return { + success: false, + formErrors: ['Username is required'], + }; + } + + setPasskeyError(null); + setPasskeyLoading(true); + + try { + // Step 1: Generate registration options (no session created yet) + const { error: genError, data: regData } = + await generateSignupRegistrationOptions(username); + if (genError || !regData) { + setPasskeyLoading(false); + return { + success: false, + formErrors: [genError ?? 'Failed to start passkey registration'], + }; + } + + // Step 2: OS passkey popup (still no session) + const credential = await startRegistration({ + optionsJSON: regData.options, + }); + + // Step 3: Atomic signup — creates user + stores passkey + session + const result = await signupWithPasskey({ username, credential }); + + if (result.error) { + setPasskeyLoading(false); + return { + success: false, + formErrors: [result.error], + }; + } + + // Session now exists — navigate to next step + router.refresh(); + router.push('/setup?step=2'); + return { success: true }; + } catch (e) { + if (e instanceof Error && e.name === 'NotAllowedError') { + setPasskeyLoading(false); + return { success: false }; + } + setPasskeyLoading(false); + return { + success: false, + formErrors: ['Passkey registration failed'], + }; + } + }; + + const isSmallScreen = useMediaQuery('(max-width: 640px)'); return ( -
    void handleSubmit(onSubmit)(event)} - autoComplete="do-not-autofill" - > -
    - + + {showAuthMethodChoice && ( + -
    -
    - values.authMethod !== 'passkey'} + > + trigger('password'), - })} + showValidationHints /> - {password && password.length > 0 && ( - !!values.password} + > + trigger('confirmPassword'), - })} /> - )} -
    -
    - -
    -
    + + + {passkeyError && ( +

    {passkeyError}

    + )} + + Create account + + ); }; diff --git a/app/(blobs)/(setup)/_components/StorageProviderSelector.tsx b/app/(blobs)/(setup)/_components/StorageProviderSelector.tsx new file mode 100644 index 000000000..ef130a175 --- /dev/null +++ b/app/(blobs)/(setup)/_components/StorageProviderSelector.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useState } from 'react'; +import { Alert, AlertDescription } from '@codaco/fresco-ui/Alert'; +import type { RichSelectOption } from '@codaco/fresco-ui/form/fields/RichSelectGroup'; +import RichSelectGroupField from '@codaco/fresco-ui/form/fields/RichSelectGroup'; +import { type StorageEnvStatus } from '~/lib/storage/config'; +import { type S3EnvValues } from '~/schemas/s3Settings'; +import { S3ConfigForm } from './S3ConfigForm'; +import { UploadThingTokenForm } from './UploadThingTokenForm'; + +type Provider = 'uploadthing' | 's3'; + +const providerOptions: RichSelectOption[] = [ + { + value: 'uploadthing', + label: 'UploadThing', + description: + 'Third-party managed storage. Easy to set up — just paste your API token.', + }, + { + value: 's3', + label: 'S3 / S3-Compatible', + description: + 'Self-hosted or cloud object storage (AWS S3, MinIO, Cloudflare R2, Backblaze B2).', + }, +]; + +const providerLabels: Record = { + uploadthing: 'UploadThing', + s3: 'S3 / S3-Compatible', +}; + +export default function StorageProviderSelector({ + envStatus, + s3EnvValues, +}: { + envStatus: StorageEnvStatus; + s3EnvValues: S3EnvValues | null; +}) { + const [selected, setSelected] = useState( + envStatus.pinnedProvider ?? 'uploadthing', + ); + + const pinned = Boolean(envStatus.pinnedProvider); + + const selectedEnvManaged = + selected === 's3' + ? envStatus.s3EnvManaged + : envStatus.uploadThingEnvManaged; + + const options = pinned + ? providerOptions.map((option) => ({ ...option, disabled: true })) + : providerOptions; + + return ( +
    + {envStatus.pinnedProvider && ( + + + The storage provider is set to{' '} + {providerLabels[envStatus.pinnedProvider]} via the STORAGE_PROVIDER + environment variable. + + + )} + + { + if (value === 'uploadthing' || value === 's3') { + setSelected(value); + } + }} + orientation="horizontal" + size="md" + /> + + {selected === 'uploadthing' && ( + <> +
    + + + )} + {selected === 's3' && ( + <> +
    + + + )} +
    + ); +} diff --git a/app/(blobs)/(setup)/_components/UploadThingTokenForm.tsx b/app/(blobs)/(setup)/_components/UploadThingTokenForm.tsx index a1dc979b7..dfc4d0169 100644 --- a/app/(blobs)/(setup)/_components/UploadThingTokenForm.tsx +++ b/app/(blobs)/(setup)/_components/UploadThingTokenForm.tsx @@ -1,55 +1,131 @@ -import { Loader2 } from 'lucide-react'; -import { z } from 'zod'; -import { Button } from '~/components/ui/Button'; -import { Input } from '~/components/ui/Input'; -import useZodForm from '~/hooks/useZodForm'; +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { Alert, AlertDescription } from '@codaco/fresco-ui/Alert'; +import { Button } from '@codaco/fresco-ui/Button'; +import Field from '@codaco/fresco-ui/form/Field/Field'; +import Form from '@codaco/fresco-ui/form/Form'; +import SubmitButton from '@codaco/fresco-ui/form/SubmitButton'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import { setUploadThingToken } from '~/actions/appSettings'; +import { setStorageProvider } from '~/actions/storageProvider'; import { createUploadThingTokenSchema } from '~/schemas/appSettings'; export const UploadThingTokenForm = ({ - action, + disabled = false, }: { - action: (token: string) => Promise; + disabled?: boolean; }) => { - const { - register, - handleSubmit, - formState: { errors, isValid, isSubmitting }, - } = useZodForm({ - schema: z.object({ - uploadThingToken: createUploadThingTokenSchema, - }), - }); - - const onSubmit = async ({ - uploadThingToken, - }: { - uploadThingToken: string; - }) => { - await action(uploadThingToken); + const router = useRouter(); + const [isContinuing, setIsContinuing] = useState(false); + const [continueError, setContinueError] = useState(null); + + const handleContinue = async () => { + setIsContinuing(true); + setContinueError(null); + try { + const result = await setStorageProvider('uploadthing'); + if (!result.success) { + setContinueError(result.error); + return; + } + router.push('/setup?step=3'); + } catch (caught) { + setContinueError( + caught instanceof Error + ? caught.message + : 'An unexpected error occurred', + ); + } finally { + setIsContinuing(false); + } + }; + + const handleSubmit = async (rawData: unknown) => { + if (disabled) { + return { success: true as const }; + } + + try { + const result = await setUploadThingToken(rawData); + + if (!result.success) { + return { + success: false as const, + fieldErrors: result.fieldErrors, + }; + } + + const providerResult = await setStorageProvider('uploadthing'); + if (!providerResult.success) { + return { + success: false as const, + formErrors: [ + providerResult.error ?? 'Failed to set storage provider.', + ], + }; + } + + router.push('/setup?step=3'); + + return { + success: true as const, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'An unexpected error occurred'; + return { + success: false as const, + formErrors: [message], + }; + } }; return ( -
    void handleSubmit(onSubmit)(event)} - > -
    - -
    -
    - -
    -
    +
    + {disabled && ( + + + The UploadThing token is configured via an environment variable and + cannot be changed here. + + + )} + + {disabled ? ( + <> + {continueError && ( +

    {continueError}

    + )} + + + ) : ( + + Save and continue + + )} + ); }; diff --git a/app/(blobs)/(setup)/layout.tsx b/app/(blobs)/(setup)/layout.tsx index 1faf603c1..c76c1f543 100644 --- a/app/(blobs)/(setup)/layout.tsx +++ b/app/(blobs)/(setup)/layout.tsx @@ -1,7 +1,18 @@ -import type { ReactNode } from 'react'; +import { Loader2 } from 'lucide-react'; +import { type ReactNode, Suspense } from 'react'; import { requireAppNotExpired } from '~/queries/appSettings'; -export default async function Layout({ children }: { children: ReactNode }) { +export default function Layout({ children }: { children: ReactNode }) { + return ( + } + > + {children} + + ); +} + +async function SetupLayoutContent({ children }: { children: ReactNode }) { await requireAppNotExpired(true); return children; } diff --git a/app/(blobs)/(setup)/setup/Setup.tsx b/app/(blobs)/(setup)/setup/Setup.tsx index 1408e0788..915f41a5a 100644 --- a/app/(blobs)/(setup)/setup/Setup.tsx +++ b/app/(blobs)/(setup)/setup/Setup.tsx @@ -1,14 +1,13 @@ 'use client'; -import { motion } from 'motion/react'; import { parseAsInteger, useQueryState } from 'nuqs'; import { useEffect } from 'react'; import { containerClasses } from '~/components/ContainerClasses'; -import { cn } from '~/utils/shadcn'; -import ConnectUploadThing from '../_components/OnboardSteps/ConnectUploadThing'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import { cx } from '@codaco/fresco-ui/utils/cva'; +import ConfigureStorage from '../_components/OnboardSteps/ConfigureStorage'; import CreateAccount from '../_components/OnboardSteps/CreateAccount'; import Documentation from '../_components/OnboardSteps/Documentation'; -import ManageParticipants from '../_components/OnboardSteps/ManageParticipants'; import UploadProtocol from '../_components/OnboardSteps/UploadProtocol'; import OnboardSteps from '../_components/Sidebar'; import type { SetupData } from './page'; @@ -19,71 +18,56 @@ export default function Setup({ setupData }: { setupData: SetupData }) { const steps = [ { label: 'Create Account', - component: CreateAccount, + content: , }, { - label: 'Connect UploadThing', - component: ConnectUploadThing, + label: 'Configure Storage', + content: ( + + ), }, { label: 'Upload Protocol', - component: UploadProtocol, - }, - { - label: 'Configure Participation', - component: () => ( - - ), + content: , }, { label: 'Documentation', - component: Documentation, + content: , }, ]; - const cardClasses = cn(containerClasses, 'flex-row bg-transparent p-0 gap-6'); - const mainClasses = cn('bg-white flex w-full p-12 rounded-xl'); + // The step comes from the URL, so out-of-range values (?step=0, ?step=99) + // must be clamped before indexing into the steps array. + const clampedStep = Math.min(Math.max(step, 1), steps.length); + + const cardClasses = cx( + containerClasses, + 'tablet-portrait:flex-row tablet-portrait:gap-6 flex flex-col gap-4', + ); useEffect(() => { + // Redirect to step 1 if we aren't authenticated if (!setupData.hasAuth && step > 1) { void setStep(1); return; } + // Don't show the user creation step if we _are_ authenticated if (setupData.hasAuth && step === 1) { void setStep(2); return; } - - if (setupData.hasAuth && step === 2 && setupData.hasUploadThingToken) { - void setStep(3); - return; - } - - // if we're past step 2 but we still have null values, go back to step 2 - if (setupData.hasAuth && step > 2) { - if ( - !setupData.hasUploadThingToken || - setupData.allowAnonymousRecruitment === null || - setupData.limitInterviews === null - ) { - void setStep(2); - return; - } - } }, [step, setStep, setupData]); - const StepComponent = steps[step - 1]!.component; - return ( - +
    step.label)} /> -
    - -
    - + + {steps[clampedStep - 1]?.content} + +
    ); } diff --git a/app/(blobs)/(setup)/setup/page.tsx b/app/(blobs)/(setup)/setup/page.tsx index 44bab7dff..4f409832e 100644 --- a/app/(blobs)/(setup)/setup/page.tsx +++ b/app/(blobs)/(setup)/setup/page.tsx @@ -1,12 +1,15 @@ import { Loader2 } from 'lucide-react'; import { Suspense } from 'react'; +import { env } from '~/env'; +import { getServerSession } from '~/lib/auth/guards'; +import { prisma } from '~/lib/db'; +import { getStorageEnvStatus } from '~/lib/storage/config'; import { getAppSetting, requireAppNotConfigured, requireAppNotExpired, } from '~/queries/appSettings'; -import { getServerSession } from '~/utils/auth'; -import { prisma } from '~/lib/db'; +import { type S3EnvValues } from '~/schemas/s3Settings'; import Setup from './Setup'; async function getSetupData() { @@ -15,12 +18,23 @@ async function getSetupData() { 'allowAnonymousRecruitment', ); const limitInterviews = await getAppSetting('limitInterviews'); - const otherData = await prisma.$transaction([ + const otherData = await Promise.all([ prisma.protocol.count(), prisma.participant.count(), ]); - const uploadThingToken = await getAppSetting('uploadThingToken'); + const storageEnv = getStorageEnvStatus(); + + const s3EnvValues: S3EnvValues | null = storageEnv.s3EnvManaged + ? { + s3Endpoint: env.S3_ENDPOINT ?? '', + s3PublicUrl: env.S3_PUBLIC_URL ?? '', + s3Bucket: env.S3_BUCKET ?? '', + s3Region: env.S3_REGION ?? '', + s3AccessKeyId: env.S3_ACCESS_KEY_ID ?? '', + s3SecretAccessKey: env.S3_SECRET_ACCESS_KEY ? '••••••••' : '', + } + : null; return { hasAuth: !!session, @@ -28,25 +42,26 @@ async function getSetupData() { limitInterviews, hasProtocol: otherData[0] > 0, hasParticipants: otherData[1] > 0, - hasUploadThingToken: !!uploadThingToken, + storageEnv, + s3EnvValues, }; } export type SetupData = Awaited>; -export const dynamic = 'force-dynamic'; - -export default async function Page() { - await requireAppNotExpired(true); - await requireAppNotConfigured(); - - const setupData = await getSetupData(); - +export default function Page() { return ( } + fallback={} > - + ); } + +async function SetupContent() { + await requireAppNotExpired(true); + await requireAppNotConfigured(); + const setupData = await getSetupData(); + return ; +} diff --git a/app/(blobs)/(setup)/signin/page.tsx b/app/(blobs)/(setup)/signin/page.tsx index d4319aec1..42f117377 100644 --- a/app/(blobs)/(setup)/signin/page.tsx +++ b/app/(blobs)/(setup)/signin/page.tsx @@ -1,30 +1,35 @@ +import { type Metadata } from 'next'; import { redirect } from 'next/navigation'; +import { connection } from 'next/server'; import { containerClasses } from '~/components/ContainerClasses'; -import { getServerSession } from '~/utils/auth'; -import { cn } from '~/utils/shadcn'; +import { MotionSurface } from '@codaco/fresco-ui/layout/Surface'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import { getServerSession } from '~/lib/auth/guards'; +import { cx } from '@codaco/fresco-ui/utils/cva'; import SandboxCredentials from '../_components/SandboxCredentials'; import { SignInForm } from '../_components/SignInForm'; -export const metadata = { +export const metadata: Metadata = { title: 'Fresco - Sign In', description: 'Sign in to Fresco.', }; -export const dynamic = 'force-dynamic'; - export default async function Page() { + await connection(); const session = await getServerSession(); - - if (session) { - // If the user is already signed in, redirect to the dashboard - redirect('/dashboard'); - } - + if (session) redirect('/dashboard'); return ( -
    -

    Sign In To Fresco

    + + Sign In To Fresco -
    + ); } diff --git a/app/(blobs)/expired/page.tsx b/app/(blobs)/expired/page.tsx index e93b2aa8e..9bcdb06eb 100644 --- a/app/(blobs)/expired/page.tsx +++ b/app/(blobs)/expired/page.tsx @@ -1,34 +1,30 @@ -import { redirect } from 'next/navigation'; import { resetAppSettings } from '~/actions/reset'; import { containerClasses } from '~/components/ContainerClasses'; -import SubmitButton from '~/components/ui/SubmitButton'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import SubmitButton from '~/components/SubmitButton'; import { env } from '~/env'; -import { isAppExpired } from '~/queries/appSettings'; - -export default async function Page() { - const isExpired = await isAppExpired(); - - if (!isExpired) { - redirect('/'); - } +import { cx } from '@codaco/fresco-ui/utils/cva'; +export default function Page() { return ( -
    -

    Installation expired

    -

    + + Installation expired + You did not configure this deployment of Fresco in time, and it has now been locked down for your security. -

    -

    + + Please redeploy a new instance of Fresco to continue using the software. -

    + {env.NODE_ENV === 'development' && ( -
    void resetAppSettings()}> - + + Dev mode: Reset Configuration )} -
    + ); } diff --git a/app/(blobs)/layout.tsx b/app/(blobs)/layout.tsx index d291a4300..2532a59f2 100644 --- a/app/(blobs)/layout.tsx +++ b/app/(blobs)/layout.tsx @@ -1,33 +1,37 @@ import Image from 'next/image'; import Link from 'next/link'; -import type { PropsWithChildren } from 'react'; +import { type PropsWithChildren, Suspense } from 'react'; import BackgroundBlobs from '~/components/BackgroundBlobs/BackgroundBlobs'; import NetlifyBadge from '~/components/NetlifyBadge'; export default function Layout({ children }: PropsWithChildren) { return ( <> -
    -
    +
    + + + +
    + +
    +
    Network Canvas -
    -
    - {children} -
    + +
    {children}
    -
    - -
    ); } diff --git a/app/(interview)/interview/[interviewId]/InterviewClient.tsx b/app/(interview)/interview/[interviewId]/InterviewClient.tsx new file mode 100644 index 000000000..a8a5d6976 --- /dev/null +++ b/app/(interview)/interview/[interviewId]/InterviewClient.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { + Shell, + type AssetRequestHandler, + type FinishHandler, + type InterviewAnalyticsMetadata, + type InterviewPayload, + type StepChangeHandler, + type SyncHandler, +} from '@codaco/interview'; +import { useRouter } from 'next/navigation'; +import { parseAsInteger, useQueryState } from 'nuqs'; +import posthog from 'posthog-js'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { env } from '~/env.js'; +import { POSTHOG_APP_NAME } from '~/fresco.config'; + +type Props = { + payload: InterviewPayload; + assetUrls: Record; + initialStep: number; + installationId: string; + disableAnalytics: boolean; +}; + +export default function InterviewClient({ + payload, + assetUrls, + initialStep, + installationId, + disableAnalytics, +}: Props) { + const router = useRouter(); + const [currentStep, setCurrentStep] = useQueryState( + 'step', + parseAsInteger.withDefault(initialStep).withOptions({ history: 'push' }), + ); + + // Refs let onSync read the latest values even though the package's sync + // middleware captures the handler once at store creation time. + const currentStepRef = useRef(currentStep); + useEffect(() => { + currentStepRef.current = currentStep; + }, [currentStep]); + + const assetUrlsRef = useRef(assetUrls); + useEffect(() => { + assetUrlsRef.current = assetUrls; + }, [assetUrls]); + + const onStepChange = useCallback( + (step) => { + void setCurrentStep(step); + }, + [setCurrentStep], + ); + + const onSync = useCallback(async (id, session) => { + const response = await fetch(`/interview/${id}/sync`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...session, + currentStep: currentStepRef.current, + }), + }); + if (!response.ok) throw new Error('Sync failed'); + }, []); + + const onFinish = useCallback( + async (id, signal) => { + const response = await fetch(`/api/interviews/${id}/finish`, { + method: 'POST', + signal, + }); + if (!response.ok) throw new Error('Failed to finish interview'); + router.push('/interview/finished'); + }, + [router], + ); + + const onRequestAsset = useCallback((assetId) => { + const url = assetUrlsRef.current[assetId]; + if (!url) return Promise.reject(new Error(`No URL for asset ${assetId}`)); + return Promise.resolve(url); + }, []); + + const flags = useMemo( + () => ({ + isDevelopment: env.NODE_ENV === 'development', + }), + [], + ); + + const analytics = useMemo( + () => ({ + installationId, + hostApp: POSTHOG_APP_NAME, + }), + [installationId], + ); + + return ( + + ); +} diff --git a/app/(interview)/interview/[interviewId]/layout.tsx b/app/(interview)/interview/[interviewId]/layout.tsx new file mode 100644 index 000000000..5fe2377ca --- /dev/null +++ b/app/(interview)/interview/[interviewId]/layout.tsx @@ -0,0 +1,17 @@ +import { type ReactNode, Suspense } from 'react'; +import SmallScreenOverlay from '../_components/SmallScreenOverlay'; + +export default function InterviewSessionLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + <> + + + + {children} + + ); +} diff --git a/app/(interview)/interview/[interviewId]/loading.tsx b/app/(interview)/interview/[interviewId]/loading.tsx deleted file mode 100644 index fe350aa90..000000000 --- a/app/(interview)/interview/[interviewId]/loading.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Loader2 } from 'lucide-react'; - -export default function Loading() { - return ( -
    - -
    - ); -} diff --git a/app/(interview)/interview/[interviewId]/mapInterviewPayload.ts b/app/(interview)/interview/[interviewId]/mapInterviewPayload.ts new file mode 100644 index 000000000..a4568bb78 --- /dev/null +++ b/app/(interview)/interview/[interviewId]/mapInterviewPayload.ts @@ -0,0 +1,55 @@ +import { + isValidAssetType, + type InterviewPayload, + type ResolvedAsset, +} from '@codaco/interview/contract'; +import type { GetInterviewByIdQuery } from '~/queries/interviews'; + +export function mapInterviewPayload( + source: NonNullable, +): { + payload: InterviewPayload; + assetUrls: Record; + initialStep: number; +} { + const { protocol, ...session } = source; + + const assets: ResolvedAsset[] = protocol.assets.map((a) => { + if (!isValidAssetType(a.type)) { + throw new Error(`Unrecognised asset type from database: "${a.type}"`); + } + return { + assetId: a.assetId, + name: a.name, + type: a.type, + value: a.value ?? undefined, + }; + }); + + const assetUrls: Record = {}; + for (const a of protocol.assets) { + if (a.url) assetUrls[a.assetId] = a.url; + } + + const payload: InterviewPayload = { + session: { + id: session.id, + startTime: session.startTime.toISOString(), + finishTime: session.finishTime?.toISOString() ?? null, + exportTime: session.exportTime?.toISOString() ?? null, + lastUpdated: session.lastUpdated.toISOString(), + network: session.network, + stageMetadata: session.stageMetadata ?? undefined, + }, + protocol: { + ...protocol, + schemaVersion: 8, + hash: protocol.hash, + description: protocol.description ?? undefined, + importedAt: protocol.importedAt.toISOString(), + assets, + }, + }; + + return { payload, assetUrls, initialStep: session.currentStep }; +} diff --git a/app/(interview)/interview/[interviewId]/page.tsx b/app/(interview)/interview/[interviewId]/page.tsx index e1e03afc6..15c1f2571 100644 --- a/app/(interview)/interview/[interviewId]/page.tsx +++ b/app/(interview)/interview/[interviewId]/page.tsx @@ -1,49 +1,116 @@ import { cookies } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; -import { syncInterview } from '~/actions/interviews'; +import { after, connection } from 'next/server'; +import { Suspense } from 'react'; +import SuperJSON from 'superjson'; +import { type ActivityType } from '~/app/dashboard/_components/ActivityFeed/types'; +import Spinner from '@codaco/fresco-ui/Spinner'; +import { getServerSession } from '~/lib/auth/guards'; +import { safeRevalidateTag } from '~/lib/cache'; +import { prisma } from '~/lib/db'; +import { captureEvent, shutdownPostHog } from '~/lib/posthog-server'; import { getAppSetting } from '~/queries/appSettings'; -import { getInterviewById } from '~/queries/interviews'; -import { getServerSession } from '~/utils/auth'; -import InterviewShell from '../_components/InterviewShell'; +import { + getInterviewById, + type GetInterviewByIdQuery, +} from '~/queries/interviews'; +import InterviewClient from './InterviewClient'; +import { mapInterviewPayload } from './mapInterviewPayload'; -export const dynamic = 'force-dynamic'; // Force dynamic rendering for this page +export default function Page(props: { + params: Promise<{ interviewId: string }>; +}) { + return ( + + +
    + } + > + + + ); +} -export default async function Page({ - params, +async function InterviewContent({ + params: paramsPromise, }: { - params: { interviewId: string }; + params: Promise<{ interviewId: string }>; }) { - const { interviewId } = params; + await connection(); + const { interviewId } = await paramsPromise; if (!interviewId) { return 'No interview id found'; } - const interview = await getInterviewById(interviewId); - const session = await getServerSession(); + const rawInterview = await getInterviewById(interviewId); - // If the interview is not found, redirect to the 404 page - if (!interview) { + if (!rawInterview) { notFound(); } - // if limitInterviews is enabled - // Check cookies for interview already completed for this user for this protocol - // and redirect to finished page + const interview = + SuperJSON.parse>(rawInterview); + const session = await getServerSession(); + const limitInterviews = await getAppSetting('limitInterviews'); - if (limitInterviews && cookies().get(interview?.protocol?.id ?? '')) { + if (limitInterviews && (await cookies()).get(interview.protocol.id)) { redirect('/interview/finished'); } - // If the interview is finished and there is no session, redirect to the finish page - if (interview?.finishTime && !session) { + if (!session && interview?.finishTime) { redirect('/interview/finished'); } + after(async () => { + try { + const message = session + ? `Interview "${interviewId}" was opened by user "${session.user.username}"` + : `Interview "${interviewId}" was opened`; + + const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); + + const recentEvent = await prisma.events.findFirst({ + where: { + type: 'Interview Opened', + message, + timestamp: { gte: thirtyMinutesAgo }, + }, + }); + + if (recentEvent) return; + + await prisma.events.create({ + data: { + type: 'Interview Opened' satisfies ActivityType, + message, + }, + }); + + safeRevalidateTag('activityFeed'); + + await captureEvent('Interview Opened', { message }); + await shutdownPostHog(); + } catch { + // Non-critical — don't block the interview + } + }); + + const { payload, assetUrls, initialStep } = mapInterviewPayload(interview); + + const installationId = (await getAppSetting('installationId')) ?? 'unknown'; + const disableAnalytics = (await getAppSetting('disableAnalytics')) ?? false; + return ( - <> - - + ); } diff --git a/app/(interview)/interview/[interviewId]/sync/route.ts b/app/(interview)/interview/[interviewId]/sync/route.ts new file mode 100644 index 000000000..c654802e4 --- /dev/null +++ b/app/(interview)/interview/[interviewId]/sync/route.ts @@ -0,0 +1,89 @@ +import { NcNetworkSchema, StageMetadataSchema } from '@codaco/shared-consts'; +import { after, NextResponse, type NextRequest } from 'next/server'; +import { z } from 'zod'; +import { prisma } from '~/lib/db'; +import { captureException, shutdownPostHog } from '~/lib/posthog-server'; +import { getAppSetting } from '~/queries/appSettings'; +import { ensureError } from '~/utils/ensureError'; + +/** + * Handle post requests from the client to store the current interview state. + */ +const routeHandler = async ( + request: NextRequest, + { params }: { params: Promise<{ interviewId: string }> }, +) => { + const { interviewId } = await params; + + const rawPayload = await request.json(); + + const Schema = z.object({ + id: z.string(), + network: NcNetworkSchema, + currentStep: z.number(), + stageMetadata: StageMetadataSchema.optional(), + lastUpdated: z.string(), + }); + + const validatedRequest = Schema.safeParse(rawPayload); + + if (!validatedRequest.success) { + after(async () => { + await captureException(validatedRequest.error, { + interviewId, + }); + await shutdownPostHog(); + }); + + // Return a generic message rather than the full Zod error, which would + // otherwise disclose the accepted schema shape to unauthenticated callers. + return NextResponse.json( + { error: 'Invalid request body' }, + { status: 400 }, + ); + } + + const { network, currentStep, stageMetadata } = validatedRequest.data; + + const freezeEnabled = await getAppSetting('freezeInterviewsAfterCompletion'); + + if (freezeEnabled) { + const interview = await prisma.interview.findUnique({ + where: { id: interviewId }, + select: { finishTime: true }, + }); + + if (interview?.finishTime) { + return NextResponse.json({ success: true }); + } + } + + try { + await prisma.interview.update({ + where: { + id: interviewId, + }, + data: { + network, + currentStep, + stageMetadata: stageMetadata ?? undefined, + // `lastUpdated` is intentionally NOT taken from the client. Prisma's + // @updatedAt sets it server-side; trusting the client value let a + // participant backdate it (overwriting newer data) and corrupt the + // dashboard sort/filter/export ordering, which keys on this column. + }, + }); + + return NextResponse.json({ success: true }); + } catch (e) { + const error = ensureError(e); + return NextResponse.json( + { + error: error.message, + }, + { status: 500 }, + ); + } +}; + +export { routeHandler as POST }; diff --git a/app/(interview)/interview/_components/ErrorMessage.tsx b/app/(interview)/interview/_components/ErrorMessage.tsx index 459fd9927..a13e543cc 100644 --- a/app/(interview)/interview/_components/ErrorMessage.tsx +++ b/app/(interview)/interview/_components/ErrorMessage.tsx @@ -1,3 +1,6 @@ +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; + type ErrorMessageProps = { title: string; message: string; @@ -7,8 +10,8 @@ export const ErrorMessage = ({ title, message }: ErrorMessageProps) => { return (
    -
    {title}
    -

    {message}

    + {title} + {message}
    ); diff --git a/app/(interview)/interview/_components/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx deleted file mode 100644 index a1685c2c7..000000000 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client'; - -import { Provider } from 'react-redux'; -import DialogManager from '~/lib/interviewer/components/DialogManager'; -import ProtocolScreen from '~/lib/interviewer/containers/ProtocolScreen'; -import { - SET_SERVER_SESSION, - type SetServerSessionAction, -} from '~/lib/interviewer/ducks/modules/setServerSession'; -import { store } from '~/lib/interviewer/store'; -import ServerSync from './ServerSync'; -import { useEffect, useState } from 'react'; -import { parseAsInteger, useQueryState } from 'nuqs'; -import type { SyncInterviewType } from '~/actions/interviews'; -import type { getInterviewById } from '~/queries/interviews'; - -// The job of interview shell is to receive the server-side session and protocol -// and create a redux store with that data. -// Eventually it will handle syncing this data back. -const InterviewShell = ({ - interview, - syncInterview, -}: { - interview: Awaited>; - syncInterview: SyncInterviewType; -}) => { - const [initialized, setInitialized] = useState(false); - const [currentStage, setCurrentStage] = useQueryState('step', parseAsInteger); - - useEffect(() => { - if (initialized || !interview) { - return; - } - - const { protocol, ...serverSession } = interview; - - // If we have a current stage in the URL bar, and it is different from the - // server session, set the server session to the current stage. - // - // If we don't have a current stage in the URL bar, set it to the server - // session, and set the URL bar to the server session. - if (currentStage === null) { - void setCurrentStage(serverSession.currentStep); - } else if (currentStage !== serverSession.currentStep) { - serverSession.currentStep = currentStage; - } - - // If there's no current stage in the URL bar, set it. - store.dispatch({ - type: SET_SERVER_SESSION, - payload: { - protocol, - session: serverSession, - }, - }); - - setInitialized(true); - }, [initialized, setInitialized, currentStage, setCurrentStage, interview]); - - if (!initialized || !interview) { - return null; - } - - return ( - - - - - - - ); -}; - -export default InterviewShell; diff --git a/app/(interview)/interview/_components/ServerSync.tsx b/app/(interview)/interview/_components/ServerSync.tsx deleted file mode 100644 index f067b57bb..000000000 --- a/app/(interview)/interview/_components/ServerSync.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client'; - -import { debounce, isEqual } from 'es-toolkit'; -import { type ReactNode, useCallback, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import type { SyncInterviewType } from '~/actions/interviews'; -import usePrevious from '~/hooks/usePrevious'; -import { getActiveSession } from '~/lib/interviewer/selectors/shared'; - -// The job of ServerSync is to listen to actions in the redux store, and to sync -// data with the server. -const ServerSync = ({ - interviewId, - children, - serverSync, -}: { - interviewId: string; - children: ReactNode; - serverSync: SyncInterviewType; -}) => { - const [init, setInit] = useState(false); - // Current stage - const currentSession = useSelector(getActiveSession); - const prevCurrentSession = usePrevious(currentSession); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedSessionSync = useCallback( - debounce(serverSync, 2000, { - edges: ['trailing', 'leading'], - }), - [serverSync], - ); - - useEffect(() => { - if (!init) { - setInit(true); - return; - } - - if ( - isEqual(currentSession, prevCurrentSession) || - !currentSession || - !prevCurrentSession - ) { - return; - } - - void debouncedSessionSync({ - id: interviewId, - network: currentSession.network, - currentStep: currentSession.currentStep ?? 0, - stageMetadata: currentSession.stageMetadata, // Temporary storage used by tiestrengthcensus/dyadcensus to store negative responses - }); - }, [ - currentSession, - prevCurrentSession, - interviewId, - init, - debouncedSessionSync, - ]); - - return children; -}; - -export default ServerSync; diff --git a/app/(interview)/interview/_components/SmallScreenOverlay.tsx b/app/(interview)/interview/_components/SmallScreenOverlay.tsx index 637fb26d9..da9e86671 100644 --- a/app/(interview)/interview/_components/SmallScreenOverlay.tsx +++ b/app/(interview)/interview/_components/SmallScreenOverlay.tsx @@ -1,10 +1,12 @@ import Image from 'next/image'; +import { connection } from 'next/server'; import { env } from 'node:process'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; import { getAppSetting } from '~/queries/appSettings'; const SmallScreenOverlay = async () => { + await connection(); const disableSmallScreenOverlay = await getAppSetting( 'disableSmallScreenOverlay', ); @@ -13,7 +15,7 @@ const SmallScreenOverlay = async () => { } return ( -
    +
    { height={300} alt="Screen too small" /> - Screen Size Too Small - - + Screen Size Too Small + To complete this interview, please use a device with a larger screen, or maximize your browser window. - + Note: it is not possible to complete this interview using a mobile phone. diff --git a/app/(interview)/interview/finished/page.tsx b/app/(interview)/interview/finished/page.tsx index ced3d6e59..6097c0525 100644 --- a/app/(interview)/interview/finished/page.tsx +++ b/app/(interview)/interview/finished/page.tsx @@ -1,15 +1,14 @@ import { BadgeCheck } from 'lucide-react'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; - -export const dynamic = 'force-dynamic'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; export default function InterviewCompleted() { return ( -
    - - Thank you for participating! + + + Thank you for participating! Your interview has been successfully completed. -
    + ); } diff --git a/app/(interview)/interview/layout.tsx b/app/(interview)/interview/layout.tsx deleted file mode 100644 index b36dbda6d..000000000 --- a/app/(interview)/interview/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import SmallScreenOverlay from '~/app/(interview)/interview/_components/SmallScreenOverlay'; -import '~/styles/interview.scss'; - -export const metadata = { - title: 'Network Canvas Fresco - Interview', - description: 'Interview', -}; - -function RootLayout({ children }: { children: React.ReactNode }) { - return ( -
    - - {children} -
    - ); -} - -export default RootLayout; diff --git a/app/(interview)/layout.tsx b/app/(interview)/layout.tsx new file mode 100644 index 000000000..bff762ea7 --- /dev/null +++ b/app/(interview)/layout.tsx @@ -0,0 +1,20 @@ +import { ThemedRegion } from '@codaco/fresco-ui/ThemedRegion'; +import { type Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Network Canvas Fresco - Interview', + description: 'Interview', +}; + +function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export default RootLayout; diff --git a/app/(interview)/onboard/[protocolId]/__tests__/route.test.ts b/app/(interview)/onboard/[protocolId]/__tests__/route.test.ts new file mode 100644 index 000000000..9875f0a90 --- /dev/null +++ b/app/(interview)/onboard/[protocolId]/__tests__/route.test.ts @@ -0,0 +1,432 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies before importing the handler +vi.mock('~/actions/interviews', () => ({ + createInterview: vi.fn(), +})); + +vi.mock('~/queries/appSettings', () => ({ + getAppSetting: vi.fn(), +})); + +vi.mock('next/headers', () => ({ + cookies: vi.fn(() => ({ + get: vi.fn(), + })), +})); + +vi.mock('~/env', () => ({ + env: { + PUBLIC_URL: 'http://localhost:3000', + }, +})); + +vi.mock('next/server', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + after: vi.fn(), + }; +}); + +vi.mock('~/lib/posthog-server', () => ({ + captureEvent: vi.fn(), + captureException: vi.fn(), + shutdownPostHog: vi.fn(), +})); + +// Import after mocks are set up +import { createInterview } from '~/actions/interviews'; +import { getAppSetting } from '~/queries/appSettings'; +import { cookies } from 'next/headers'; + +// Import the handlers +import { GET, POST } from '../route'; + +const mockCreateInterview = vi.mocked(createInterview); +const mockGetAppSetting = vi.mocked(getAppSetting); +const mockCookies = vi.mocked(cookies); + +describe('Onboard Route Handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock implementations + mockGetAppSetting.mockResolvedValue(false); + mockCookies.mockResolvedValue({ + get: vi.fn().mockReturnValue(undefined), + has: vi.fn().mockReturnValue(false), + getAll: vi.fn().mockReturnValue([]), + set: vi.fn(), + delete: vi.fn(), + size: 0, + [Symbol.iterator]: vi.fn(), + }); + }); + + describe('GET handler', () => { + it('should redirect to error page when no protocolId is provided', async () => { + const request = new NextRequest( + 'http://localhost:3000/onboard/undefined', + ); + const params = Promise.resolve({ protocolId: 'undefined' }); + + const response = await GET(request, { params }); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/onboard/error', + ); + }); + + it('should extract participantIdentifier from query string', async () => { + const protocolId = 'test-protocol-id'; + const participantIdentifier = 'TEST-PARTICIPANT-001'; + const createdInterviewId = 'interview-123'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId, + error: null, + errorType: null, + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}?participantIdentifier=${participantIdentifier}`, + ); + const params = Promise.resolve({ protocolId }); + + const response = await GET(request, { params }); + + expect(mockCreateInterview).toHaveBeenCalledWith({ + participantIdentifier, + protocolId, + }); + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + `http://localhost:3000/interview/${createdInterviewId}`, + ); + }); + + it('should pass undefined when no participantIdentifier is provided', async () => { + const protocolId = 'test-protocol-id'; + const createdInterviewId = 'interview-456'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId, + error: null, + errorType: null, + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + ); + const params = Promise.resolve({ protocolId }); + + await GET(request, { params }); + + expect(mockCreateInterview).toHaveBeenCalledWith({ + participantIdentifier: undefined, + protocolId, + }); + }); + + it('should redirect to finished page when limitInterviews is enabled and cookie exists', async () => { + const protocolId = 'test-protocol-id'; + + mockGetAppSetting.mockResolvedValue(true); + mockCookies.mockResolvedValue({ + get: vi.fn().mockReturnValue({ value: 'completed' }), + } as unknown as Awaited>); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + ); + const params = Promise.resolve({ protocolId }); + + const response = await GET(request, { params }); + + expect(mockCreateInterview).not.toHaveBeenCalled(); + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/interview/finished', + ); + }); + + it('should allow new interview when limitInterviews is enabled but no cookie exists', async () => { + const protocolId = 'test-protocol-id'; + const createdInterviewId = 'interview-789'; + + mockGetAppSetting.mockResolvedValue(true); + mockCookies.mockResolvedValue({ + get: vi.fn().mockReturnValue(undefined), + } as unknown as Awaited>); + mockCreateInterview.mockResolvedValue({ + createdInterviewId, + error: null, + errorType: null, + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + ); + const params = Promise.resolve({ protocolId }); + + const response = await GET(request, { params }); + + expect(mockCreateInterview).toHaveBeenCalled(); + expect(response.headers.get('location')).toBe( + `http://localhost:3000/interview/${createdInterviewId}`, + ); + }); + + it('should redirect to error page when createInterview returns an error', async () => { + const protocolId = 'test-protocol-id'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId: null, + error: 'Failed to create interview', + errorType: 'unknown-error', + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + ); + const params = Promise.resolve({ protocolId }); + + const response = await GET(request, { params }); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/onboard/error', + ); + }); + + it('should redirect to no-anonymous-recruitment page when anonymous recruitment is disabled', async () => { + const protocolId = 'test-protocol-id'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId: null, + error: 'Anonymous recruitment is not enabled', + errorType: 'no-anonymous-recruitment', + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + ); + const params = Promise.resolve({ protocolId }); + + const response = await GET(request, { params }); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/onboard/no-anonymous-recruitment', + ); + }); + }); + + describe('POST handler', () => { + it('should extract participantIdentifier from JSON body', async () => { + const protocolId = 'test-protocol-id'; + const participantIdentifier = 'POST-PARTICIPANT-001'; + const createdInterviewId = 'interview-post-123'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId, + error: null, + errorType: null, + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + { + method: 'POST', + body: JSON.stringify({ participantIdentifier }), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + const params = Promise.resolve({ protocolId }); + + const response = await POST(request, { params }); + + expect(mockCreateInterview).toHaveBeenCalledWith({ + participantIdentifier, + protocolId, + }); + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + `http://localhost:3000/interview/${createdInterviewId}`, + ); + }); + + it('should handle POST with empty body gracefully', async () => { + const protocolId = 'test-protocol-id'; + const createdInterviewId = 'interview-post-456'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId, + error: null, + errorType: null, + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + { + method: 'POST', + body: JSON.stringify({}), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + const params = Promise.resolve({ protocolId }); + + await POST(request, { params }); + + expect(mockCreateInterview).toHaveBeenCalledWith({ + participantIdentifier: undefined, + protocolId, + }); + }); + + it('should redirect to error page when POST body parsing fails', async () => { + const protocolId = 'test-protocol-id'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId: null, + error: 'Failed to create interview', + errorType: 'parse-error', + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + { + method: 'POST', + body: JSON.stringify(null), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + const params = Promise.resolve({ protocolId }); + + await POST(request, { params }); + + expect(mockCreateInterview).toHaveBeenCalledWith({ + participantIdentifier: undefined, + protocolId, + }); + }); + + it('should redirect to no-anonymous-recruitment page when anonymous recruitment is disabled', async () => { + const protocolId = 'test-protocol-id'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId: null, + error: 'Anonymous recruitment is not enabled', + errorType: 'no-anonymous-recruitment', + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + { + method: 'POST', + body: JSON.stringify({}), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + const params = Promise.resolve({ protocolId }); + + const response = await POST(request, { params }); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/onboard/no-anonymous-recruitment', + ); + }); + + it('should check limitInterviews for POST requests too', async () => { + const protocolId = 'test-protocol-id'; + + mockGetAppSetting.mockResolvedValue(true); + mockCookies.mockResolvedValue({ + get: vi.fn().mockReturnValue({ value: 'completed' }), + } as unknown as Awaited>); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + { + method: 'POST', + body: JSON.stringify({ participantIdentifier: 'test' }), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + const params = Promise.resolve({ protocolId }); + + const response = await POST(request, { params }); + + expect(mockCreateInterview).not.toHaveBeenCalled(); + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/interview/finished', + ); + }); + }); + + describe('Edge cases', () => { + it('should handle protocolId with special characters', async () => { + const protocolId = 'test-protocol-123_abc'; + const createdInterviewId = 'interview-special'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId, + error: null, + errorType: null, + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}`, + ); + const params = Promise.resolve({ protocolId }); + + const response = await GET(request, { params }); + + expect(mockCreateInterview).toHaveBeenCalledWith({ + participantIdentifier: undefined, + protocolId, + }); + expect(response.headers.get('location')).toBe( + `http://localhost:3000/interview/${createdInterviewId}`, + ); + }); + + it('should handle URL-encoded participantIdentifier', async () => { + const protocolId = 'test-protocol-id'; + const participantIdentifier = 'user@example.com'; + const createdInterviewId = 'interview-encoded'; + + mockCreateInterview.mockResolvedValue({ + createdInterviewId, + error: null, + errorType: null, + }); + + const request = new NextRequest( + `http://localhost:3000/onboard/${protocolId}?participantIdentifier=${encodeURIComponent(participantIdentifier)}`, + ); + const params = Promise.resolve({ protocolId }); + + await GET(request, { params }); + + expect(mockCreateInterview).toHaveBeenCalledWith({ + participantIdentifier, + protocolId, + }); + }); + }); +}); diff --git a/app/(interview)/onboard/[protocolId]/route.ts b/app/(interview)/onboard/[protocolId]/route.ts index 0cadd1f92..2467cd7c5 100644 --- a/app/(interview)/onboard/[protocolId]/route.ts +++ b/app/(interview)/onboard/[protocolId]/route.ts @@ -1,17 +1,15 @@ import { cookies } from 'next/headers'; -import { NextResponse, type NextRequest } from 'next/server'; +import { after, NextResponse, type NextRequest } from 'next/server'; import { createInterview } from '~/actions/interviews'; import { env } from '~/env'; -import trackEvent from '~/lib/analytics'; +import { captureEvent, shutdownPostHog } from '~/lib/posthog-server'; import { getAppSetting } from '~/queries/appSettings'; -export const dynamic = 'force-dynamic'; - const handler = async ( req: NextRequest, - { params }: { params: { protocolId: string } }, + { params }: { params: Promise<{ protocolId: string }> }, ) => { - const protocolId = params.protocolId; // From route segment + const { protocolId } = await params; // when deployed via docker `req.url` and `req.nextUrl` // shows Docker Container ID instead of real host @@ -30,7 +28,7 @@ const handler = async ( // if limitInterviews is enabled // Check cookies for interview already completed for this user for this protocol // and redirect to finished page - if (limitInterviews && cookies().get(protocolId)) { + if (limitInterviews && (await cookies()).get(protocolId)) { url.pathname = '/interview/finished'; return NextResponse.redirect(url); } @@ -51,42 +49,51 @@ const handler = async ( } // Create a new interview given the protocolId and participantId - const { createdInterviewId, error } = await createInterview({ + const { createdInterviewId, error, errorType } = await createInterview({ participantIdentifier, protocolId, }); if (error) { - void trackEvent({ - type: 'Error', - name: error, - message: 'Failed to create interview', - metadata: { + after(async () => { + await captureEvent('Error', { + name: error, + message: 'Failed to create interview', path: '/onboard/[protocolId]/route.ts', - }, + }); + await shutdownPostHog(); }); + if (errorType === 'no-anonymous-recruitment') { + url.pathname = '/onboard/no-anonymous-recruitment'; + return NextResponse.redirect(url); + } + url.pathname = '/onboard/error'; return NextResponse.redirect(url); } - // eslint-disable-next-line no-console - console.log( - `🚀 Created interview with ID ${createdInterviewId} using protocol ${protocolId} for participant ${ - participantIdentifier ?? 'Anonymous' - }...`, - ); + // Note: the interview id is the unauthenticated access capability for the + // participant flow, so it must never be written to logs (where it could leak + // and let a third party read or tamper with the interview). - void trackEvent({ - type: 'InterviewStarted', - metadata: { + after(async () => { + await captureEvent('InterviewStarted', { usingAnonymousParticipant: !participantIdentifier, - }, + }); + await shutdownPostHog(); }); // Redirect to the interview + // Explicitly disable caching to prevent Netlify from caching this redirect + // (Netlify adds max-age=86400 by default, causing all users to get the same interview) + // See: https://github.com/opennextjs/opennextjs-netlify/issues/3460 url.pathname = `/interview/${createdInterviewId}`; - return NextResponse.redirect(url); + return NextResponse.redirect(url, { + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + }); }; export { handler as GET, handler as POST }; diff --git a/app/api/[version]/interview/[interviewId]/route.ts b/app/api/[version]/interview/[interviewId]/route.ts new file mode 100644 index 000000000..8c325d495 --- /dev/null +++ b/app/api/[version]/interview/[interviewId]/route.ts @@ -0,0 +1,92 @@ +import { after, type NextRequest, NextResponse } from 'next/server'; +import { + createCorsHeaders, + requireApiTokenAuth, +} from '~/app/api/_helpers/auth'; +import { prisma } from '~/lib/db'; +import { captureException, shutdownPostHog } from '~/lib/posthog-server'; +import { getAppSetting } from '~/queries/appSettings'; +import { ensureError } from '~/utils/ensureError'; + +const corsHeaders = createCorsHeaders('GET, OPTIONS'); + +export function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ version: string; interviewId: string }> }, +) { + const { version, interviewId } = await params; + + if (version !== 'v1') { + return NextResponse.json( + { error: `Unsupported API version: ${version}` }, + { status: 404, headers: corsHeaders }, + ); + } + + const enabled = await getAppSetting('enableInterviewDataApi'); + if (!enabled) { + return NextResponse.json( + { error: 'Interview Data API is not enabled' }, + { status: 403, headers: corsHeaders }, + ); + } + + const authResult = await requireApiTokenAuth(request); + if ('error' in authResult) { + return NextResponse.json( + { error: 'Authentication required. Provide a Bearer token.' }, + { status: 401, headers: corsHeaders }, + ); + } + + try { + const interview = await prisma.interview.findUnique({ + where: { id: interviewId }, + include: { + participant: { + select: { + id: true, + identifier: true, + label: true, + }, + }, + protocol: { + select: { + id: true, + name: true, + schemaVersion: true, + description: true, + codebook: true, + }, + }, + }, + }); + + if (!interview) { + return NextResponse.json( + { error: 'Interview not found' }, + { status: 404, headers: corsHeaders }, + ); + } + + return NextResponse.json({ data: interview }, { headers: corsHeaders }); + } catch (e) { + const error = ensureError(e); + await captureException(error); + after(async () => { + await shutdownPostHog(); + }); + + return NextResponse.json( + { error: 'Failed to fetch interview' }, + { status: 500, headers: corsHeaders }, + ); + } +} diff --git a/app/api/[version]/interview/route.ts b/app/api/[version]/interview/route.ts new file mode 100644 index 000000000..783ea22e3 --- /dev/null +++ b/app/api/[version]/interview/route.ts @@ -0,0 +1,128 @@ +import { after, type NextRequest, NextResponse } from 'next/server'; +import { + createCorsHeaders, + requireApiTokenAuth, +} from '~/app/api/_helpers/auth'; +import { createVersionedHandler } from '~/app/api/_helpers/versioning'; +import { prisma } from '~/lib/db'; +import { type Prisma } from '~/lib/db/generated/client'; +import { captureException, shutdownPostHog } from '~/lib/posthog-server'; +import { getAppSetting } from '~/queries/appSettings'; +import { ensureError } from '~/utils/ensureError'; + +const corsHeaders = createCorsHeaders('GET, OPTIONS'); + +export function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }); +} + +async function v1(request: NextRequest) { + const enabled = await getAppSetting('enableInterviewDataApi'); + if (!enabled) { + return NextResponse.json( + { error: 'Interview Data API is not enabled' }, + { status: 403, headers: corsHeaders }, + ); + } + + const authResult = await requireApiTokenAuth(request); + if ('error' in authResult) { + return NextResponse.json( + { error: 'Authentication required. Provide a Bearer token.' }, + { status: 401, headers: corsHeaders }, + ); + } + + try { + const { searchParams } = request.nextUrl; + const page = Math.max(1, Number(searchParams.get('page') ?? '1')); + const perPage = Math.min( + 100, + Math.max(1, Number(searchParams.get('perPage') ?? '10')), + ); + const protocolId = searchParams.get('protocolId'); + const participantId = searchParams.get('participantId'); + const status = searchParams.get('status'); + + const where: Prisma.InterviewWhereInput = {}; + + if (protocolId) { + where.protocolId = protocolId; + } + + if (participantId) { + where.participantId = participantId; + } + + if (status === 'completed') { + where.finishTime = { not: null }; + } else if (status === 'in-progress') { + where.finishTime = null; + } + + const [interviews, total] = await Promise.all([ + prisma.interview.findMany({ + where, + select: { + id: true, + startTime: true, + finishTime: true, + lastUpdated: true, + currentStep: true, + protocolId: true, + participantId: true, + participant: { + select: { + id: true, + identifier: true, + label: true, + }, + }, + protocol: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { lastUpdated: 'desc' }, + skip: (page - 1) * perPage, + take: perPage, + }), + prisma.interview.count({ where }), + ]); + + return NextResponse.json( + { + data: interviews, + meta: { + page, + perPage, + pageCount: Math.ceil(total / perPage), + total, + }, + }, + { headers: corsHeaders }, + ); + } catch (e) { + const error = ensureError(e); + await captureException(error); + after(async () => { + await shutdownPostHog(); + }); + + return NextResponse.json( + { error: 'Failed to fetch interviews' }, + { status: 500, headers: corsHeaders }, + ); + } +} + +const handlers = { + v1: { GET: v1 }, +}; + +export const GET = createVersionedHandler(handlers, 'GET'); diff --git a/app/api/[version]/protocols-meta/route.ts b/app/api/[version]/protocols-meta/route.ts new file mode 100644 index 000000000..4b080135e --- /dev/null +++ b/app/api/[version]/protocols-meta/route.ts @@ -0,0 +1,71 @@ +import { after, type NextRequest, NextResponse } from 'next/server'; +import { + createCorsHeaders, + requireApiTokenAuth, +} from '~/app/api/_helpers/auth'; +import { createVersionedHandler } from '~/app/api/_helpers/versioning'; +import { prisma } from '~/lib/db'; +import { captureException, shutdownPostHog } from '~/lib/posthog-server'; +import { getAppSetting } from '~/queries/appSettings'; +import { ensureError } from '~/utils/ensureError'; + +const corsHeaders = createCorsHeaders('GET, OPTIONS'); + +export function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }); +} + +async function v1(request: NextRequest) { + try { + const enabled = await getAppSetting('enableInterviewDataApi'); + if (!enabled) { + return NextResponse.json( + { error: 'Interview Data API is not enabled' }, + { status: 403, headers: corsHeaders }, + ); + } + + const authResult = await requireApiTokenAuth(request); + if ('error' in authResult) { + return NextResponse.json( + { error: 'Authentication required. Provide a Bearer token.' }, + { + status: 401, + headers: { ...corsHeaders, 'WWW-Authenticate': 'Bearer' }, + }, + ); + } + + const protocols = await prisma.protocol.findMany({ + select: { + id: true, + name: true, + importedAt: true, + lastModified: true, + }, + orderBy: { importedAt: 'desc' }, + }); + + return NextResponse.json(protocols, { headers: corsHeaders }); + } catch (e) { + const error = ensureError(e); + await captureException(error); + after(async () => { + await shutdownPostHog(); + }); + + return NextResponse.json( + { error: 'Failed to fetch protocols' }, + { status: 500, headers: corsHeaders }, + ); + } +} + +const handlers = { + v1: { GET: v1 }, +}; + +export const GET = createVersionedHandler(handlers, 'GET'); diff --git a/app/api/_helpers/__tests__/auth.test.ts b/app/api/_helpers/__tests__/auth.test.ts new file mode 100644 index 000000000..a8d0f297f --- /dev/null +++ b/app/api/_helpers/__tests__/auth.test.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockVerifyApiToken } = vi.hoisted(() => ({ + mockVerifyApiToken: vi.fn(), +})); + +vi.mock('~/actions/apiTokens', () => ({ + verifyApiToken: mockVerifyApiToken, +})); + +import { createCorsHeaders, requireApiTokenAuth } from '../auth'; + +describe('API auth helpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createCorsHeaders', () => { + it('should return headers with specified methods', () => { + const headers = createCorsHeaders('GET, POST'); + + expect(headers['Access-Control-Allow-Origin']).toBe('*'); + expect(headers['Access-Control-Allow-Methods']).toBe('GET, POST'); + expect(headers['Access-Control-Allow-Headers']).toBe( + 'Content-Type, Authorization', + ); + }); + }); + + describe('requireApiTokenAuth', () => { + it('should return error when no authorization header is present', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/test'); + + const result = await requireApiTokenAuth(request); + + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.error).toBeInstanceOf(NextResponse); + const body = (await result.error.json()) as { error: string }; + expect(body.error).toContain('Authentication required'); + } + }); + + it('should return error when token is invalid', async () => { + mockVerifyApiToken.mockResolvedValue({ valid: false }); + + const request = new NextRequest('http://localhost:3000/api/v1/test', { + headers: { Authorization: 'Bearer invalid-token' }, + }); + + const result = await requireApiTokenAuth(request); + + expect('error' in result).toBe(true); + expect(mockVerifyApiToken).toHaveBeenCalledWith('invalid-token'); + }); + + it('should return valid when token is valid', async () => { + mockVerifyApiToken.mockResolvedValue({ valid: true }); + + const request = new NextRequest('http://localhost:3000/api/v1/test', { + headers: { Authorization: 'Bearer valid-token' }, + }); + + const result = await requireApiTokenAuth(request); + + expect(result).toEqual({ valid: true }); + expect(mockVerifyApiToken).toHaveBeenCalledWith('valid-token'); + }); + + it('should extract token from Bearer prefix', async () => { + mockVerifyApiToken.mockResolvedValue({ valid: true }); + + const request = new NextRequest('http://localhost:3000/api/v1/test', { + headers: { Authorization: 'Bearer my-secret-token' }, + }); + + await requireApiTokenAuth(request); + + expect(mockVerifyApiToken).toHaveBeenCalledWith('my-secret-token'); + }); + }); +}); diff --git a/app/api/_helpers/__tests__/versioning.test.ts b/app/api/_helpers/__tests__/versioning.test.ts new file mode 100644 index 000000000..f45a771d0 --- /dev/null +++ b/app/api/_helpers/__tests__/versioning.test.ts @@ -0,0 +1,64 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createVersionedHandler } from '../versioning'; + +describe('createVersionedHandler', () => { + const mockV1Handler = vi.fn(); + const mockV2Handler = vi.fn(); + + const handlers = { + v1: { GET: mockV1Handler, POST: mockV1Handler }, + v2: { GET: mockV2Handler }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockV1Handler.mockResolvedValue(Response.json({ ok: true })); + mockV2Handler.mockResolvedValue(Response.json({ ok: true })); + }); + + it('should route to the correct version handler', async () => { + const handler = createVersionedHandler(handlers, 'GET'); + const request = new NextRequest('http://localhost:3000/api/v1/test'); + + await handler(request, { params: Promise.resolve({ version: 'v1' }) }); + + expect(mockV1Handler).toHaveBeenCalledWith(request); + }); + + it('should return 404 for unsupported versions', async () => { + const handler = createVersionedHandler(handlers, 'GET'); + const request = new NextRequest('http://localhost:3000/api/v99/test'); + + const response = await handler(request, { + params: Promise.resolve({ version: 'v99' }), + }); + + expect(response.status).toBe(404); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('Unsupported API version'); + }); + + it('should return 405 for unsupported methods', async () => { + const handler = createVersionedHandler(handlers, 'DELETE'); + const request = new NextRequest('http://localhost:3000/api/v1/test'); + + const response = await handler(request, { + params: Promise.resolve({ version: 'v1' }), + }); + + expect(response.status).toBe(405); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('DELETE not supported'); + }); + + it('should support multiple versions', async () => { + const handler = createVersionedHandler(handlers, 'GET'); + const request = new NextRequest('http://localhost:3000/api/v2/test'); + + await handler(request, { params: Promise.resolve({ version: 'v2' }) }); + + expect(mockV2Handler).toHaveBeenCalledWith(request); + expect(mockV1Handler).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/_helpers/auth.ts b/app/api/_helpers/auth.ts new file mode 100644 index 000000000..93b8b95ee --- /dev/null +++ b/app/api/_helpers/auth.ts @@ -0,0 +1,36 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { verifyApiToken } from '~/actions/apiTokens'; + +export function createCorsHeaders(methods: string) { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': methods, + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }; +} + +export async function requireApiTokenAuth( + req: NextRequest, +): Promise<{ valid: true } | { error: NextResponse }> { + const authHeader = req.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!token) { + return { + error: NextResponse.json( + { error: 'Authentication required. Provide a Bearer token.' }, + { status: 401 }, + ), + }; + } + + const { valid } = await verifyApiToken(token); + + if (!valid) { + return { + error: NextResponse.json({ error: 'Invalid API token' }, { status: 401 }), + }; + } + + return { valid: true }; +} diff --git a/app/api/_helpers/versioning.ts b/app/api/_helpers/versioning.ts new file mode 100644 index 000000000..1dc54e501 --- /dev/null +++ b/app/api/_helpers/versioning.ts @@ -0,0 +1,34 @@ +import { type HTTP_METHOD } from 'next/dist/server/web/http'; +import { type NextRequest } from 'next/server'; + +type Handler = (request: NextRequest) => Response | Promise; + +export function createVersionedHandler( + handlers: Record>, + method: HTTP_METHOD, +) { + return async ( + request: NextRequest, + { params }: { params: Promise<{ version: string }> }, + ) => { + const { version } = await params; + + const versionHandlers = handlers[version]; + if (!versionHandlers) { + return Response.json( + { error: `Unsupported API version: ${version}` }, + { status: 404 }, + ); + } + + const handler = versionHandlers[method]; + if (!handler) { + return Response.json( + { error: `${method} not supported in ${version}` }, + { status: 405 }, + ); + } + + return handler(request); + }; +} diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts deleted file mode 100644 index ba6cf4052..000000000 --- a/app/api/analytics/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createRouteHandler } from '@codaco/analytics'; -import { type NextRequest } from 'next/server'; -import { getDisableAnalytics, getInstallationId } from '~/queries/appSettings'; - -const routeHandler = async (request: NextRequest) => { - const installationId = await getInstallationId(); - const disableAnalytics = await getDisableAnalytics(); - - return createRouteHandler({ - installationId: installationId ?? 'Unknown Installation ID', - disableAnalytics, - })(request); -}; - -export { routeHandler as POST }; diff --git a/app/api/assets/[key]/route.ts b/app/api/assets/[key]/route.ts new file mode 100644 index 000000000..dc1a09bb6 --- /dev/null +++ b/app/api/assets/[key]/route.ts @@ -0,0 +1,130 @@ +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { prisma } from '~/lib/db'; +import { getS3Bucket, getS3PublicClient } from '~/lib/storage/layers/S3Client'; + +// Must exceed the longest plausible interview session so cached redirect +// targets keep working for ranged media requests. +const PRESIGN_EXPIRY_SECONDS = 60 * 60 * 24; +// Much shorter than the presign expiry: if credentials or bucket config +// rotate, browsers re-request a fresh redirect within this window (bounded +// staleness), while already-cached redirects keep working for ranged media +// requests because the signed URL itself stays valid for the full expiry. +const REDIRECT_CACHE_SECONDS = 60 * 60; + +// Keys we mint are `${uuid}${ext}` — never a path. Anything else is rejected so +// this route cannot be used to sign a GET for an arbitrary object name. +const KEY_PATTERN = /^[A-Za-z0-9._-]+$/; + +// Map known extensions to a content type we serve verbatim, so a file's stored +// (browser-supplied) content type can't be sniffed into something executable. +const CONTENT_TYPE_BY_EXT: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.bmp': 'image/bmp', + '.svg': 'image/svg+xml', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', + '.ogg': 'video/ogg', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.m4a': 'audio/mp4', + '.pdf': 'application/pdf', +}; + +// Types that can execute script when opened as a top-level document. We force +// `attachment` for these (and for unknown types) so they download instead of +// rendering in the Fresco origin. Inline embedding via /
    + } + /> ); }, cell: ({ row }) => { @@ -59,10 +72,8 @@ export const InterviewColumns = (): ColumnDef< className="flex items-center gap-2" title={row.original.participant.identifier} > - - - {row.original.participant.identifier} - + + {row.original.participant.identifier}
    ); @@ -71,18 +82,37 @@ export const InterviewColumns = (): ColumnDef< { id: 'protocolName', accessorKey: 'protocol.name', - header: ({ column }) => { + sortingFn: 'text', + meta: { + filterType: 'faceted', + filterConfig: { + type: 'faceted', + options: () => + filterOptions.protocolNames.map((name) => ({ + value: name, + label: name.replace(/\.netcanvas$/, ''), + })), + }, + }, + filterFn: facetedFilterFn, + header: ({ column, table }) => { return ( -
    - Protocol icon - -
    + + Protocol icon + Protocol Name +
    + } + /> ); }, cell: ({ row }) => { @@ -101,80 +131,154 @@ export const InterviewColumns = (): ColumnDef< { id: 'startTime', accessorKey: 'startTime', - header: ({ column }) => { - return ; + sortingFn: 'datetime', + meta: { + filterType: 'date', + filterConfig: { type: 'date' }, + }, + filterFn: dateFilterFn, + header: ({ column, table }) => { + return ( + + ); }, cell: ({ row }) => { - const date = new Date(row.original.startTime); - return ; + return ; }, }, { id: 'lastUpdated', accessorKey: 'lastUpdated', - header: ({ column }) => { - return ; + sortingFn: 'datetime', + meta: { + filterType: 'date', + filterConfig: { type: 'date' }, + }, + filterFn: dateFilterFn, + header: ({ column, table }) => { + return ( + + ); }, cell: ({ row }) => { - const date = new Date(row.original.lastUpdated); - return ; + return ; }, }, { id: 'progress', - accessorFn: (row) => { - const stages = row.protocol.stages; - return Array.isArray(stages) - ? (row.currentStep / stages.length) * 100 - : 0; + sortingFn: 'basic', + accessorFn: (row) => + computeInterviewProgress({ + finishTime: row.finishTime, + currentStep: row.currentStep, + stageCount: row.protocol.stageCount, + }), + meta: { + filterType: 'range', + filterConfig: { + type: 'range', + min: 0, + max: 100, + step: 1, + presets: [ + { label: 'Not Started', min: 0, max: 0 }, + { label: 'In Progress', min: 1, max: 99 }, + { label: 'Complete', min: 100, max: 100 }, + ], + formatLabel: (v: number) => `${String(v)}%`, + }, }, - header: ({ column }) => { - return ; + filterFn: rangeFilterFn, + header: ({ column, table }) => { + return ( + + ); }, cell: ({ row }) => { - const stages = row.original.protocol.stages! as unknown as Stage[]; - const progress = (row.original.currentStep / stages.length) * 100; + const progress = computeInterviewProgress({ + finishTime: row.original.finishTime, + currentStep: row.original.currentStep, + stageCount: row.original.protocol.stageCount, + }); return ( -
    - -
    {progress.toFixed(0)}%
    +
    + +
    {progress.toFixed(0)}%
    ); }, }, { id: 'network', + enableSorting: false, accessorFn: (row) => { - const network = row.network as NcNetwork; - const nodeCount = network?.nodes?.length ?? 0; - const edgeCount = network?.edges?.length ?? 0; + const network = row.network; + const nodeCount = network.nodes.reduce((sum, n) => sum + n.count, 0); + const edgeCount = network.edges.reduce((sum, e) => sum + e.count, 0); return nodeCount + edgeCount; }, - header: ({ column }) => { - return ; + meta: { + filterType: 'operator', + filterConfig: { + type: 'operator', + operators: ['eq', 'gt', 'lt', 'gte', 'lte'], + entitySelector: { + label: 'Entity Type', + getOptions: () => [ + ...filterOptions.nodeTypes.map((t) => ({ + value: `nodes.${t.value}`, + label: `${t.label} (nodes)`, + })), + ...filterOptions.edgeTypes.map((t) => ({ + value: `edges.${t.value}`, + label: `${t.label} (edges)`, + })), + ], + }, + }, + }, + filterFn: operatorFilterFn, + header: ({ column, table }) => { + return ( + + ); }, cell: ({ row }) => { - const network = row.original.network as NcNetwork; - const codebook = row.original.protocol.codebook as Codebook; - - return ; + return ; }, }, { + id: 'exportTime', accessorKey: 'exportTime', - header: ({ column }) => { - return ; + sortingFn: 'datetime', + meta: { + filterType: 'boolean', + filterConfig: { + type: 'boolean', + trueLabel: 'Exported', + falseLabel: 'Not Exported', + }, + }, + filterFn: booleanFilterFn, + header: ({ column, table }) => { + return ( + + ); }, cell: ({ row }) => { if (!row.original.exportTime) { - return Not exported; + return Not exported; } - return ( -
    - -
    - ); + return ; }, }, ]; diff --git a/app/dashboard/_components/InterviewsTable/InterviewsSelectionBar.tsx b/app/dashboard/_components/InterviewsTable/InterviewsSelectionBar.tsx new file mode 100644 index 000000000..9085f247e --- /dev/null +++ b/app/dashboard/_components/InterviewsTable/InterviewsSelectionBar.tsx @@ -0,0 +1,82 @@ +import { AnimatePresence } from 'motion/react'; +import { FileUp, Trash } from 'lucide-react'; +import { Button } from '@codaco/fresco-ui/Button'; +import CloseButton from '@codaco/fresco-ui/CloseButton'; +import { cx } from '@codaco/fresco-ui/utils/cva'; +import { MotionSurface } from '@codaco/fresco-ui/layout/Surface'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; + +type InterviewsSelectionBarProps = { + selectedCount: number; + totalCount: number; + isBusy: boolean; + onSelectAllMatching: () => void; + onDeselectAll: () => void; + onDeleteSelected: () => void; + onExportSelected: () => void; +}; + +export const InterviewsSelectionBar = ({ + selectedCount, + totalCount, + isBusy, + onSelectAllMatching, + onDeselectAll, + onDeleteSelected, + onExportSelected, +}: InterviewsSelectionBarProps) => { + return ( + + {selectedCount > 0 && ( + + + {selectedCount} selected + + {selectedCount < totalCount && ( + + )} +
    + + +
    + +
    + )} +
    + ); +}; diff --git a/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx b/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx index 27495aa29..31fc34432 100644 --- a/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx +++ b/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx @@ -1,131 +1,257 @@ 'use client'; +import { + type ColumnDef, + type Row, + type RowSelectionState, +} from '@tanstack/react-table'; import { HardDriveUpload } from 'lucide-react'; -import { hash as objectHash } from 'ohash'; -import { use, useMemo, useState } from 'react'; +import { use, useMemo, useState, useTransition } from 'react'; +import { Button } from '@codaco/fresco-ui/Button'; +import { cx } from '@codaco/fresco-ui/utils/cva'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@codaco/fresco-ui/DropdownMenu'; +import { useToast } from '@codaco/fresco-ui/Toast'; +import { + getInterviewDeletionInfo, + resolveInterviewIds, +} from '~/actions/interviews'; import { ActionsDropdown } from '~/app/dashboard/_components/InterviewsTable/ActionsDropdown'; import { InterviewColumns } from '~/app/dashboard/_components/InterviewsTable/Columns'; import { DeleteInterviewsDialog } from '~/app/dashboard/interviews/_components/DeleteInterviewsDialog'; import { ExportInterviewsDialog } from '~/app/dashboard/interviews/_components/ExportInterviewsDialog'; import { GenerateInterviewURLs } from '~/app/dashboard/interviews/_components/GenerateInterviewURLs'; -import { DataTable } from '~/components/DataTable/DataTable'; -import { Button } from '~/components/ui/Button'; +import NuqsClearFilters from '~/components/DataTable/nuqs/NuqsClearFilters'; +import NuqsSearchFilter from '~/components/DataTable/nuqs/NuqsSearchFilter'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '~/components/ui/dropdown-menu'; -import type { GetInterviewsReturnType } from '~/queries/interviews'; + NuqsTableProvider, + useNuqsTable, +} from '~/components/DataTable/nuqs/NuqsTableProvider'; +import type { + GetInterviewsQuery, + GetInterviewsReturnType, + InterviewFilterOptions, +} from '~/queries/interviews'; import type { GetProtocolsReturnType } from '~/queries/protocols'; +import InterviewsTableRows from './InterviewsTableRows'; +import { INTERVIEWS_PREFIX, type InterviewsSearchParams } from './searchParams'; -export const InterviewsTable = ({ - interviewsPromise, - protocolsPromise, -}: { +const clearableFilters = [ + 'q', + 'protocol', + 'started', + 'updated', + 'progress', + 'exported', + 'network', +] as const; + +type InterviewRow = GetInterviewsQuery[number]; + +type InterviewsTableProps = { interviewsPromise: GetInterviewsReturnType; + filterOptionsPromise: Promise; protocolsPromise: GetProtocolsReturnType; -}) => { - const interviews = use(interviewsPromise); + searchParams: InterviewsSearchParams; +}; - const [selectedInterviews, setSelectedInterviews] = - useState(); +export const InterviewsTable = (props: InterviewsTableProps) => { + return ( + + + + ); +}; + +const InterviewsTableInner = ({ + interviewsPromise, + filterOptionsPromise, + protocolsPromise, + searchParams, +}: InterviewsTableProps) => { + // TanStack Table: consumers must also opt out so React Compiler doesn't memoize JSX that depends on the table ref. + 'use no memo'; + const { isPending } = useNuqsTable(); + const { add } = useToast(); + const filterOptions = use(filterOptionsPromise); + + const [rowSelection, setRowSelection] = useState({}); + const [interviewsToDelete, setInterviewsToDelete] = useState< + { id: string; exportTime: Date | null }[] + >([]); + const [selectedInterviewIds, setSelectedInterviewIds] = useState( + [], + ); const [showDeleteModal, setShowDeleteModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false); + const [isResolving, startResolving] = useTransition(); + const [isSelecting, startSelecting] = useTransition(); + const [isDeleteResolving, startDeleteResolving] = useTransition(); - const unexportedInterviews = useMemo( - () => interviews.filter((interview) => !interview.exportTime), - [interviews], + const selectedIds = Object.keys(rowSelection).filter( + (id) => rowSelection[id], ); - const completedInterviews = useMemo( - () => interviews.filter((interview) => interview.finishTime), - [interviews], - ); + const columns = useMemo[]>(() => { + const actionsColumn: ColumnDef = { + id: 'actions', + enableSorting: false, + cell: ({ row }: { row: Row }) => ( + + ), + }; + return [...InterviewColumns(filterOptions), actionsColumn]; + }, [filterOptions]); - const handleDelete = (data: typeof interviews) => { - setSelectedInterviews(data); - setShowDeleteModal(true); + const handleDeleteSelected = () => { + startDeleteResolving(async () => { + const result = await getInterviewDeletionInfo(selectedIds); + if (result.error) { + add({ + title: 'Error', + description: result.error, + variant: 'destructive', + }); + return; + } + setInterviewsToDelete(result.data); + setShowDeleteModal(true); + }); }; - const handleExportUnexported = () => { - setSelectedInterviews(unexportedInterviews); + const handleExportSelected = () => { + setSelectedInterviewIds(selectedIds); setShowExportModal(true); }; - const handleExportAll = () => { - setSelectedInterviews(interviews); - setShowExportModal(true); + const handleSelectAllMatching = () => { + startSelecting(async () => { + const result = await resolveInterviewIds(searchParams); + if (result.error) { + add({ + title: 'Error', + description: result.error, + variant: 'destructive', + }); + return; + } + setRowSelection(Object.fromEntries(result.ids.map((id) => [id, true]))); + }); }; - const handleExportCompleted = () => { - setSelectedInterviews(completedInterviews); - setShowExportModal(true); + const handleDeselectAll = () => { + setRowSelection({}); + }; + + const resolveAndExport = (extra?: { + onlyUnexported?: boolean; + onlyCompleted?: boolean; + }) => { + startResolving(async () => { + const result = await resolveInterviewIds(searchParams, extra); + if (result.error) { + add({ + title: 'Error', + description: result.error, + variant: 'destructive', + }); + return; + } + setSelectedInterviewIds(result.ids); + setShowExportModal(true); + }); }; const handleResetExport = () => { - setSelectedInterviews([]); + setSelectedInterviewIds([]); setShowExportModal(false); }; + const exportDropdown = ( + + } />} + disabled={isResolving} + nativeButton + data-testid="export-interviews-button" + className="tablet-landscape:w-auto w-full" + > + Export Interview Data + + + resolveAndExport()} + > + Export all interviews + + resolveAndExport({ onlyCompleted: true })} + > + Export all completed interviews + + resolveAndExport({ onlyUnexported: true })} + > + Export all unexported interviews + + + + ); + return ( <> - { - setSelectedInterviews(selected); - setShowExportModal(true); - }} - actions={ActionsDropdown} - defaultSortBy={{ id: 'lastUpdated', desc: true }} - headerItems={ - <> - - - - - - - Export all interviews - - - Export all completed interviews - - - Export all unexported interviews - - - - - - } + interviewsToDelete={interviewsToDelete} /> +
    +
    + + + {exportDropdown} + + +
    + } + /> +
    +
    ); }; diff --git a/app/dashboard/_components/InterviewsTable/InterviewsTableRows.tsx b/app/dashboard/_components/InterviewsTable/InterviewsTableRows.tsx new file mode 100644 index 000000000..60114acee --- /dev/null +++ b/app/dashboard/_components/InterviewsTable/InterviewsTableRows.tsx @@ -0,0 +1,286 @@ +import { + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type ColumnFiltersState, + type OnChangeFn, + type PaginationState, + type RowSelectionState, + type SortingState, +} from '@tanstack/react-table'; +import { type OperatorCondition } from '@codaco/fresco-ui/DataTable/filters/types'; +import { + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsJson, + parseAsString, + parseAsStringLiteral, + useQueryStates, +} from 'nuqs'; +import { use, useMemo, type ReactNode } from 'react'; +import superjson from 'superjson'; +import { z } from 'zod/mini'; +import { DataTable } from '@codaco/fresco-ui/DataTable/DataTable'; +import { useNuqsTable } from '~/components/DataTable/nuqs/NuqsTableProvider'; +import type { + GetInterviewsQuery, + GetInterviewsReturnType, +} from '~/queries/interviews'; +import { InterviewsSelectionBar } from './InterviewsSelectionBar'; +import { searchParamsUrlKeys, sortableFields, sortOrder } from './searchParams'; + +type InterviewRow = GetInterviewsQuery[number]; + +// Mirror of searchParams.ts's networkConditionSchema, with `entityLabel` added +// so the parsed value is a valid fresco-ui OperatorCondition for the popover. +// The server schema strips `entityLabel`, so writing it back to the URL is safe. +const networkConditionSchema = z.array( + z.object({ + entityKind: z.enum(['nodes', 'edges']), + entityType: z.string(), + entityLabel: z.optional(z.string()), + operator: z.enum(['eq', 'gt', 'lt', 'gte', 'lte']), + value: z.number(), + }), +); + +const parseRange = (raw: string): { from: string; to: string } | null => { + const [from, to] = raw.split('..'); + if (from === undefined || to === undefined) return null; + return { from, to }; +}; + +const parseNumericRange = ( + raw: string, +): { min: number; max: number } | null => { + const [minRaw, maxRaw] = raw.split('..'); + if (minRaw === undefined || maxRaw === undefined) return null; + const min = Number(minRaw); + const max = Number(maxRaw); + if (Number.isNaN(min) || Number.isNaN(max)) return null; + return { min, max }; +}; + +const isStringRange = (v: unknown): v is { from: string; to: string } => + typeof v === 'object' && + v !== null && + 'from' in v && + 'to' in v && + typeof v.from === 'string' && + typeof v.to === 'string'; + +const isNumericRange = (v: unknown): v is { min: number; max: number } => + typeof v === 'object' && + v !== null && + 'min' in v && + 'max' in v && + typeof v.min === 'number' && + typeof v.max === 'number'; + +const isStringArray = (v: unknown): v is string[] => + Array.isArray(v) && v.every((item) => typeof item === 'string'); + +const isOperatorValue = ( + v: unknown, +): v is { conditions: OperatorCondition[] } => + typeof v === 'object' && + v !== null && + 'conditions' in v && + Array.isArray(v.conditions); + +export default function InterviewsTableRows({ + interviewsPromise, + rowSelection, + onRowSelectionChange, + columns, + toolbar, + isBusy, + onDeleteSelected, + onExportSelected, + onSelectAllMatching, + onDeselectAll, +}: { + interviewsPromise: GetInterviewsReturnType; + rowSelection: RowSelectionState; + onRowSelectionChange: OnChangeFn; + columns: ColumnDef[]; + toolbar: ReactNode; + isBusy: boolean; + onDeleteSelected: () => void; + onExportSelected: () => void; + onSelectAllMatching: () => void; + onDeselectAll: () => void; +}) { + // TanStack Table returns a mutable ref with stable identity, defeating React Compiler memoization. + 'use no memo'; + const data = use(interviewsPromise); + const rows = useMemo( + () => superjson.parse(data.rows), + [data.rows], + ); + const { startTransition } = useNuqsTable(); + + // Pagination, sort, and every filter param are read here so the header + // popovers can hydrate from the URL. Filtering still runs server-side, so the + // table is in manual mode and translates column filters back to the URL. + const [params, setTableState] = useQueryStates( + { + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: parseAsStringLiteral(sortOrder).withDefault('none'), + sortField: + parseAsStringLiteral(sortableFields).withDefault('lastUpdated'), + protocol: parseAsArrayOf(parseAsString), + started: parseAsString, + updated: parseAsString, + progress: parseAsString, + exported: parseAsBoolean, + network: parseAsJson((v) => networkConditionSchema.parse(v)), + }, + { + urlKeys: searchParamsUrlKeys, + shallow: false, + clearOnDefault: true, + startTransition, + }, + ); + + const { page, perPage, sort, sortField } = params; + + const pagination: PaginationState = { + pageIndex: page - 1, + pageSize: perPage, + }; + + const sorting: SortingState = + sort === 'none' ? [] : [{ id: sortField, desc: sort === 'desc' }]; + + const columnFilters: ColumnFiltersState = useMemo(() => { + const filters: ColumnFiltersState = []; + if (params.protocol) { + filters.push({ id: 'protocolName', value: params.protocol }); + } + if (params.started) { + const range = parseRange(params.started); + if (range) filters.push({ id: 'startTime', value: range }); + } + if (params.updated) { + const range = parseRange(params.updated); + if (range) filters.push({ id: 'lastUpdated', value: range }); + } + if (params.progress) { + const range = parseNumericRange(params.progress); + if (range) filters.push({ id: 'progress', value: range }); + } + if (params.network) { + const conditions: OperatorCondition[] = params.network.map((c) => ({ + entityKind: c.entityKind, + entityType: c.entityType, + entityLabel: c.entityLabel ?? c.entityType, + operator: c.operator, + value: c.value, + })); + filters.push({ id: 'network', value: { conditions } }); + } + if (params.exported !== null) { + filters.push({ id: 'exportTime', value: params.exported }); + } + return filters; + }, [ + params.protocol, + params.started, + params.updated, + params.progress, + params.network, + params.exported, + ]); + + const onColumnFiltersChange: OnChangeFn = (updater) => { + const next = + typeof updater === 'function' ? updater(columnFilters) : updater; + const byId = new Map(next.map((f) => [f.id, f.value])); + + const protocol = byId.get('protocolName'); + const startTime = byId.get('startTime'); + const lastUpdated = byId.get('lastUpdated'); + const progress = byId.get('progress'); + const network = byId.get('network'); + const exportTime = byId.get('exportTime'); + + void setTableState({ + page: 1, + protocol: isStringArray(protocol) ? protocol : null, + started: isStringRange(startTime) + ? `${startTime.from}..${startTime.to}` + : null, + updated: isStringRange(lastUpdated) + ? `${lastUpdated.from}..${lastUpdated.to}` + : null, + progress: isNumericRange(progress) + ? `${String(progress.min)}..${String(progress.max)}` + : null, + network: isOperatorValue(network) ? network.conditions : null, + exported: typeof exportTime === 'boolean' ? exportTime : null, + }); + }; + + const table = useReactTable({ + data: rows, + columns, + pageCount: data.pageCount, + getRowId: (row) => row.id, + state: { pagination, sorting, rowSelection, columnFilters }, + onPaginationChange: (updater) => { + const next = + typeof updater === 'function' ? updater(pagination) : updater; + void setTableState({ + page: next.pageIndex + 1, + perPage: next.pageSize, + }); + }, + onSortingChange: (updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater; + const first = next[0]; + if (!first) { + void setTableState({ sort: null, sortField: null }); + return; + } + const field = sortableFields.find((f) => f === first.id); + if (!field) return; + void setTableState({ + sort: first.desc ? 'desc' : 'asc', + sortField: field, + }); + }, + onColumnFiltersChange, + onRowSelectionChange, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + manualPagination: true, + manualSorting: true, + manualFiltering: true, + }); + + const selectedCount = Object.keys(rowSelection).filter( + (id) => rowSelection[id], + ).length; + + return ( + <> + + + + ); +} diff --git a/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx b/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx index da04088cb..520865170 100644 --- a/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx +++ b/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx @@ -1,20 +1,34 @@ import { Suspense } from 'react'; -import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; -import { getInterviews } from '~/queries/interviews'; +import { DataTableSkeleton } from '@codaco/fresco-ui/DataTable/DataTableSkeleton'; +import { getInterviewFilterOptions, getInterviews } from '~/queries/interviews'; import { getProtocols } from '~/queries/protocols'; +import type { InterviewsSearchParams } from './searchParams'; import { InterviewsTable } from './InterviewsTable'; -export default function InterviewsTableServer() { - const interviewsPromise = getInterviews(); +export default function InterviewsTableServer({ + searchParams, +}: { + searchParams: InterviewsSearchParams; +}) { + const interviewsPromise = getInterviews(searchParams); + const filterOptionsPromise = getInterviewFilterOptions(); const protocolsPromise = getProtocols(); return ( } + fallback={ + + } > ); diff --git a/app/dashboard/_components/InterviewsTable/NetworkSummary.tsx b/app/dashboard/_components/InterviewsTable/NetworkSummary.tsx index faa2e2cab..e0fcc53a2 100644 --- a/app/dashboard/_components/InterviewsTable/NetworkSummary.tsx +++ b/app/dashboard/_components/InterviewsTable/NetworkSummary.tsx @@ -1,16 +1,8 @@ -import type { Codebook, NcNetwork } from '@codaco/shared-consts'; -import { cn } from '~/utils/shadcn'; - -type NodeColorSequence = - | 'node-color-seq-1' - | 'node-color-seq-2' - | 'node-color-seq-3' - | 'node-color-seq-4' - | 'node-color-seq-5' - | 'node-color-seq-6' - | 'node-color-seq-7' - | 'node-color-seq-8'; +import Node, { type NodeColorSequence } from '@codaco/fresco-ui/Node'; +import { cx } from '@codaco/fresco-ui/utils/cva'; +import type { GetInterviewsQuery } from '~/queries/interviews'; +// TODO: Move to shared-consts or protocol-validation type EdgeColorSequence = | 'edge-color-seq-1' | 'edge-color-seq-2' @@ -22,96 +14,67 @@ type EdgeColorSequence = | 'edge-color-seq-8' | 'edge-color-seq-9'; -type NodeSummaryProps = { - color: NodeColorSequence; - count: number; - typeName: string; -}; - type EdgeSummaryProps = { color: EdgeColorSequence; count: number; typeName: string; }; -function NodeSummary({ color, count, typeName }: NodeSummaryProps) { - const classes = cn( - 'flex items-center h-8 w-8 justify-center rounded-full', - 'bg-linear-145 from-50% to-50%', - 'from-[var(--node-color-seq-1)] to-[var(--node-color-seq-1-dark)]', - color === 'node-color-seq-1' && - 'from-[var(--node-color-seq-1)] to-[var(--node-color-seq-1-dark)]', - color === 'node-color-seq-2' && - 'from-[var(--node-color-seq-2)] to-[var(--node-color-seq-2-dark)]', - color === 'node-color-seq-3' && - 'from-[var(--node-color-seq-3)] to-[var(--node-color-seq-3-dark)]', - color === 'node-color-seq-4' && - 'from-[var(--node-color-seq-4)] to-[var(--node-color-seq-4-dark)]', - color === 'node-color-seq-5' && - 'from-[var(--node-color-seq-5)] to-[var(--node-color-seq-5-dark)]', - color === 'node-color-seq-6' && - 'from-[var(--node-color-seq-6)] to-[var(--node-color-seq-6-dark)]', - color === 'node-color-seq-7' && - 'from-[var(--node-color-seq-7)] to-[var(--node-color-seq-7-dark)]', - color === 'node-color-seq-8' && - 'from-[var(--node-color-seq-8)] to-[var(--node-color-seq-8-dark)]', - ); - return ( -
    -
    - {count} -
    - {typeName} -
    - ); -} function EdgeSummary({ color, count, typeName }: EdgeSummaryProps) { - const lightColorClass = cn( - 'fill-[var(--edge-color-seq-1)]', - color === 'edge-color-seq-1' && 'fill-[var(--edge-color-seq-1)]', - color === 'edge-color-seq-2' && 'fill-[var(--edge-color-seq-2)]', - color === 'edge-color-seq-3' && 'fill-[var(--edge-color-seq-3)]', - color === 'edge-color-seq-4' && 'fill-[var(--edge-color-seq-4)]', - color === 'edge-color-seq-5' && 'fill-[var(--edge-color-seq-5)]', - color === 'edge-color-seq-6' && 'fill-[var(--edge-color-seq-6)]', - color === 'edge-color-seq-7' && 'fill-[var(--edge-color-seq-7)]', - color === 'edge-color-seq-8' && 'fill-[var(--edge-color-seq-8)]', - color === 'edge-color-seq-9' && 'fill-[var(--edge-color-seq-9)]', - ); - - const darkColorClass = cn( - 'fill-[var(--edge-color-seq-1-dark)]', - color === 'edge-color-seq-1' && 'fill-[var(--edge-color-seq-1-dark)]', - color === 'edge-color-seq-2' && 'fill-[var(--edge-color-seq-2-dark)]', - color === 'edge-color-seq-3' && 'fill-[var(--edge-color-seq-3-dark)]', - color === 'edge-color-seq-4' && 'fill-[var(--edge-color-seq-4-dark)]', - color === 'edge-color-seq-5' && 'fill-[var(--edge-color-seq-5-dark)]', - color === 'edge-color-seq-6' && 'fill-[var(--edge-color-seq-6-dark)]', - color === 'edge-color-seq-7' && 'fill-[var(--edge-color-seq-7-dark)]', - color === 'edge-color-seq-8' && 'fill-[var(--edge-color-seq-8-dark)]', - color === 'edge-color-seq-9' && 'fill-[var(--edge-color-seq-9-dark)]', + /** + * There is a bug in the suggestCanonicalClasses rule: https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1542 + */ + const edgeColorClasses = cx( + color === 'edge-color-seq-1' && + // eslint-disable-next-line better-tailwindcss/enforce-canonical-classes + '[--fill-dark:oklch(from_var(--edge-1)_calc(l_-_var(--dark-mod))_c_h)] [--fill:var(--edge-1)]', + color === 'edge-color-seq-2' && + // eslint-disable-next-line better-tailwindcss/enforce-canonical-classes + '[--fill-dark:oklch(from_var(--edge-2)_calc(l_-_var(--dark-mod))_c_h)] [--fill:var(--edge-2)]', + color === 'edge-color-seq-3' && + // eslint-disable-next-line better-tailwindcss/enforce-canonical-classes + '[--fill-dark:oklch(from_var(--edge-3)_calc(l_-_var(--dark-mod))_c_h)] [--fill:var(--edge-3)]', + color === 'edge-color-seq-4' && + // eslint-disable-next-line better-tailwindcss/enforce-canonical-classes + '[--fill-dark:oklch(from_var(--edge-4)_calc(l_-_var(--dark-mod))_c_h)] [--fill:var(--edge-4)]', + color === 'edge-color-seq-5' && + // eslint-disable-next-line better-tailwindcss/enforce-canonical-classes + '[--fill-dark:oklch(from_var(--edge-5)_calc(l_-_var(--dark-mod))_c_h)] [--fill:var(--edge-5)]', + color === 'edge-color-seq-6' && + // eslint-disable-next-line better-tailwindcss/enforce-canonical-classes + '[--fill-dark:oklch(from_var(--edge-6)_calc(l_-_var(--dark-mod))_c_h)] [--fill:var(--edge-6)]', + color === 'edge-color-seq-7' && + // eslint-disable-next-line better-tailwindcss/enforce-canonical-classes + '[--fill-dark:oklch(from_var(--edge-7)_calc(l_-_var(--dark-mod))_c_h)] [--fill:var(--edge-7)]', + color === 'edge-color-seq-8' && + // eslint-disable-next-line better-tailwindcss/enforce-canonical-classes + '[--fill-dark:oklch(from_var(--edge-8)_calc(l_-_var(--dark-mod))_c_h)] [--fill:var(--edge-8)]', + color === 'edge-color-seq-9' && + // eslint-disable-next-line better-tailwindcss/enforce-canonical-classes + '[--fill-dark:oklch(from_var(--edge-9)_calc(l_-_var(--dark-mod))_c_h)] [--fill:var(--edge-9)]', ); return (
    -
    +
    - - - + + + @@ -145,58 +108,45 @@ function EdgeSummary({ color, count, typeName }: EdgeSummaryProps) { const NetworkSummary = ({ network, - codebook, }: { - network: NcNetwork | null; - codebook: Codebook | null; + network: GetInterviewsQuery[number]['network']; }) => { - if (!network || !codebook) { - return
    No interview data
    ; - } - const nodeSummaries = Object.entries( - network.nodes?.reduce>((acc, node) => { - acc[node.type] = (acc[node.type] ?? 0) + 1; - return acc; - }, {}) ?? {}, - ).map(([nodeType, count]) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const nodeInfo = codebook.node?.[nodeType]!; - return ( - - ); - }); + const nodeSummaries = network.nodes.map( + ({ type: nodeType, count, name, color }) => ( +
    + + {name} +
    + ), + ); + + const edgeSummaries = network.edges + .map(({ type: edgeType, count, name, color }) => { + if (!color) return null; - const edgeSummaries = Object.entries( - network.edges?.reduce>((acc, edge) => { - acc[edge.type] = (acc[edge.type] ?? 0) + 1; - return acc; - }, {}) ?? {}, - ).map(([edgeType, count]) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const edgeInfo = codebook.edge?.[edgeType]!; - return ( - - ); - }); + return ( + + ); + }) + .filter(Boolean); if (nodeSummaries.length === 0 && edgeSummaries.length === 0) { return
    No nodes or edges
    ; } return ( -
    -
    {nodeSummaries}
    -
    {edgeSummaries}
    +
    + {nodeSummaries} + {edgeSummaries}
    ); }; diff --git a/app/dashboard/_components/InterviewsTable/__tests__/computeInterviewProgress.test.ts b/app/dashboard/_components/InterviewsTable/__tests__/computeInterviewProgress.test.ts new file mode 100644 index 000000000..845df2699 --- /dev/null +++ b/app/dashboard/_components/InterviewsTable/__tests__/computeInterviewProgress.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { computeInterviewProgress } from '../computeInterviewProgress'; + +describe('computeInterviewProgress', () => { + it('returns 100 for a finished interview even when currentStep is below stageCount', () => { + // Completion is determined by finishTime, not currentStep: the finish flow + // records finishTime but does not reliably advance currentStep to the end. + expect( + computeInterviewProgress({ + finishTime: new Date(), + currentStep: 11, + stageCount: 12, + }), + ).toBe(100); + }); + + it('returns 100 for a finished interview regardless of stageCount', () => { + expect( + computeInterviewProgress({ + finishTime: new Date(), + currentStep: 0, + stageCount: 0, + }), + ).toBe(100); + }); + + it('divides by stageCount + 1 to match the package, which appends a finish stage', () => { + // @codaco/interview indexes currentStep against [...protocolStages, finish], + // so the true step total is stageCount + 1. + expect( + computeInterviewProgress({ + finishTime: null, + currentStep: 1, + stageCount: 3, + }), + ).toBe(25); + }); + + it('reports an unfinished interview parked on the finish screen below 100%', () => { + // currentStep === stageCount means the participant reached the appended + // finish screen but has not finished; finishTime is the only 100% signal. + expect( + computeInterviewProgress({ + finishTime: null, + currentStep: 12, + stageCount: 12, + }), + ).toBeCloseTo((12 / 13) * 100); + }); + + it('returns 0 for a not-started interview', () => { + expect( + computeInterviewProgress({ + finishTime: null, + currentStep: 0, + stageCount: 12, + }), + ).toBe(0); + }); + + it('returns 0 for a not-started interview when stageCount is 0', () => { + expect( + computeInterviewProgress({ + finishTime: null, + currentStep: 0, + stageCount: 0, + }), + ).toBe(0); + }); +}); diff --git a/app/dashboard/_components/InterviewsTable/buildInterviewWhere.ts b/app/dashboard/_components/InterviewsTable/buildInterviewWhere.ts new file mode 100644 index 000000000..abd4ae752 --- /dev/null +++ b/app/dashboard/_components/InterviewsTable/buildInterviewWhere.ts @@ -0,0 +1,127 @@ +import { Prisma } from '~/lib/db/generated/client'; +import type { InterviewsSearchParams, NetworkCondition } from './searchParams'; + +function parseRange(raw: string | null): { lo: string; hi: string } | null { + if (!raw) return null; + const [lo, hi] = raw.split('..'); + if (lo === undefined || hi === undefined || lo === '' || hi === '') { + return null; + } + return { lo, hi }; +} + +function operatorSql(op: NetworkCondition['operator']): Prisma.Sql { + switch (op) { + case 'eq': + return Prisma.raw('='); + case 'gt': + return Prisma.raw('>'); + case 'lt': + return Prisma.raw('<'); + case 'gte': + return Prisma.raw('>='); + case 'lte': + return Prisma.raw('<='); + } +} + +/** + * Progress percentage expression mirroring `computeInterviewProgress`: a + * finished interview (finishTime set) is 100%, otherwise progress is + * currentStep / (protocol stage count + 1). The +1 accounts for the finish + * stage that @codaco/interview appends to the stage list, against which + * currentStep is indexed. Assumes the aliases `i` (Interview) and `p` + * (Protocol). + */ +const PROGRESS_SQL = + '(CASE WHEN i."finishTime" IS NOT NULL THEN 100 ELSE (i."currentStep"::float / (COALESCE(jsonb_array_length(p."stages"::jsonb),0) + 1)) * 100 END)'; + +/** + * Builds the WHERE predicate (without the `WHERE` keyword) for the interview + * list. Returns `Prisma.empty` when no filters are active. Column references + * assume the aliases `i` (Interview), `p` (Protocol), `par` (Participant). + */ +export function buildInterviewWhere( + params: InterviewsSearchParams, +): Prisma.Sql { + const conditions: Prisma.Sql[] = []; + + if (params.q) { + conditions.push( + Prisma.sql`par."identifier" ILIKE '%' || ${params.q} || '%'`, + ); + } + + if (params.protocol && params.protocol.length > 0) { + conditions.push(Prisma.sql`p."name" IN (${Prisma.join(params.protocol)})`); + } + + const started = parseRange(params.started); + if (started) { + // Upper bound extended to end of day so date-only "to" values are inclusive. + conditions.push( + Prisma.sql`i."startTime" >= ${new Date(started.lo)} AND i."startTime" <= ${new Date(`${started.hi}T23:59:59.999`)}`, + ); + } + + const updated = parseRange(params.updated); + if (updated) { + // Upper bound extended to end of day so date-only "to" values are inclusive. + conditions.push( + Prisma.sql`i."lastUpdated" >= ${new Date(updated.lo)} AND i."lastUpdated" <= ${new Date(`${updated.hi}T23:59:59.999`)}`, + ); + } + + const progress = parseRange(params.progress); + if (progress) { + const min = Number(progress.lo); + const max = Number(progress.hi); + conditions.push( + Prisma.sql`${Prisma.raw(PROGRESS_SQL)} BETWEEN ${min} AND ${max}`, + ); + } + + if (params.exported !== null) { + conditions.push( + params.exported + ? Prisma.sql`i."exportTime" IS NOT NULL` + : Prisma.sql`i."exportTime" IS NULL`, + ); + } + + if (params.network && params.network.length > 0) { + // AND-combine each condition (see filterFns.js verification note). + for (const c of params.network) { + conditions.push( + Prisma.sql`(SELECT COUNT(*) FROM jsonb_array_elements( + COALESCE(i."network"->${c.entityKind}, '[]'::jsonb)) AS e + WHERE e->>'type' = ${c.entityType}) ${operatorSql(c.operator)} ${c.value}`, + ); + } + } + + if (conditions.length === 0) return Prisma.empty; + return Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`; +} + +const DEFAULT_SORT_COLUMN = 'i."lastUpdated"'; + +const SORT_COLUMN: Record = { + identifier: 'par."identifier"', + protocolName: 'p."name"', + startTime: 'i."startTime"', + lastUpdated: 'i."lastUpdated"', + exportTime: 'i."exportTime"', + progress: PROGRESS_SQL, +}; + +export function buildInterviewOrderBy( + params: InterviewsSearchParams, +): Prisma.Sql { + if (params.sort === 'none') { + return Prisma.sql`ORDER BY i."lastUpdated" DESC, i."id" DESC`; + } + const col = SORT_COLUMN[params.sortField] ?? DEFAULT_SORT_COLUMN; + const dir = params.sort === 'asc' ? Prisma.raw('ASC') : Prisma.raw('DESC'); + return Prisma.sql`ORDER BY ${Prisma.raw(col)} ${dir}, i."id" DESC`; +} diff --git a/app/dashboard/_components/InterviewsTable/computeInterviewProgress.ts b/app/dashboard/_components/InterviewsTable/computeInterviewProgress.ts new file mode 100644 index 000000000..39026d473 --- /dev/null +++ b/app/dashboard/_components/InterviewsTable/computeInterviewProgress.ts @@ -0,0 +1,25 @@ +type InterviewProgressInput = { + finishTime: Date | null; + currentStep: number; + stageCount: number; +}; + +/** + * Progress for an interview row, kept in step with @codaco/interview. + * + * Completion is determined by finishTime, not currentStep: the finish flow + * records finishTime but does not reliably advance currentStep to the end. + * + * For in-progress interviews the denominator is stageCount + 1, because the + * package indexes currentStep against [...protocolStages, finishStage] — the + * appended finish screen makes the true step total one greater than the + * protocol's stage count. + */ +export function computeInterviewProgress({ + finishTime, + currentStep, + stageCount, +}: InterviewProgressInput): number { + if (finishTime) return 100; + return (currentStep / (stageCount + 1)) * 100; +} diff --git a/app/dashboard/_components/InterviewsTable/searchParams.ts b/app/dashboard/_components/InterviewsTable/searchParams.ts new file mode 100644 index 000000000..09f3ccbbc --- /dev/null +++ b/app/dashboard/_components/InterviewsTable/searchParams.ts @@ -0,0 +1,61 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsJson, + parseAsString, + parseAsStringLiteral, +} from 'nuqs/server'; +import { z } from 'zod/mini'; + +export const INTERVIEWS_PREFIX = 'iv'; + +export const sortOrder = ['asc', 'desc', 'none'] as const; +export const sortableFields = [ + 'identifier', + 'protocolName', + 'startTime', + 'lastUpdated', + 'exportTime', + 'progress', +] as const; + +// Network operator filter condition shape (matches fresco-ui OperatorCondition). +const networkConditionSchema = z.array( + z.object({ + entityKind: z.enum(['nodes', 'edges']), + entityType: z.string(), + operator: z.enum(['eq', 'gt', 'lt', 'gte', 'lte']), + value: z.number(), + }), +); +export type NetworkCondition = z.infer[number]; + +const parseNetwork = parseAsJson((v) => networkConditionSchema.parse(v)); + +export const searchParamsParsers = { + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: parseAsStringLiteral(sortOrder).withDefault('none'), + sortField: parseAsStringLiteral(sortableFields).withDefault('lastUpdated'), + q: parseAsString, + protocol: parseAsArrayOf(parseAsString), + started: parseAsString, // "fromISO..toISO" + updated: parseAsString, // "fromISO..toISO" + progress: parseAsString, // "min..max" + exported: parseAsBoolean, + network: parseNetwork, +}; + +export const searchParamsUrlKeys = Object.fromEntries( + Object.keys(searchParamsParsers).map((k) => [k, `${INTERVIEWS_PREFIX}_${k}`]), +) as Record; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers, { + urlKeys: searchParamsUrlKeys, +}); + +export type InterviewsSearchParams = Awaited< + ReturnType +>; diff --git a/app/dashboard/_components/MobileNavDrawer.tsx b/app/dashboard/_components/MobileNavDrawer.tsx new file mode 100644 index 000000000..9cca0bb9a --- /dev/null +++ b/app/dashboard/_components/MobileNavDrawer.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { Menu, Settings, X } from 'lucide-react'; +import { motion } from 'motion/react'; +import type { Route } from 'next'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useState } from 'react'; +import type { UrlObject } from 'url'; +import { logout } from '~/actions/auth'; +import Modal from '@codaco/fresco-ui/Modal'; +import ModalPopup from '@codaco/fresco-ui/Modal/ModalPopup'; +import SubmitButton from '~/components/SubmitButton'; +import { cx } from '@codaco/fresco-ui/utils/cva'; + +type NavItem = { + label: string; + href: UrlObject | Route; + icon?: React.ReactNode; +}; + +const navItems: NavItem[] = [ + { label: 'Dashboard', href: '/dashboard' }, + { label: 'Protocols', href: '/dashboard/protocols' }, + { label: 'Participants', href: '/dashboard/participants' }, + { label: 'Interviews', href: '/dashboard/interviews' }, +]; + +const MobileNavLink = ({ + item, + isActive, + onClick, +}: { + item: NavItem; + isActive: boolean; + onClick: () => void; +}) => { + return ( + + {item.icon} + {item.label} + + ); +}; + +export function MobileNavDrawer() { + const [open, setOpen] = useState(false); + const pathname = usePathname(); + + const handleClose = () => setOpen(false); + + return ( + <> + + + + + + + + + ); +} diff --git a/app/dashboard/_components/NavigationBar.tsx b/app/dashboard/_components/NavigationBar.tsx index 7783aa907..c0c87722b 100644 --- a/app/dashboard/_components/NavigationBar.tsx +++ b/app/dashboard/_components/NavigationBar.tsx @@ -1,86 +1,147 @@ 'use client'; -import { motion } from 'motion/react'; +import { Settings } from 'lucide-react'; +import { motion, useReducedMotion, type Variants } from 'motion/react'; import type { Route } from 'next'; -import Image from 'next/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import type { UrlObject } from 'url'; -import Heading from '~/components/ui/typography/Heading'; -import { env } from '~/env'; -import { cn } from '~/utils/shadcn'; +import { MotionSurface } from '@codaco/fresco-ui/layout/Surface'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Spinner from '@codaco/fresco-ui/Spinner'; +import { cx } from '@codaco/fresco-ui/utils/cva'; +import { MobileNavDrawer } from './MobileNavDrawer'; import UserMenu from './UserMenu'; +const containerVariants: Variants = { + hidden: { + y: '-150%', + }, + visible: { + y: 0, + transition: { + type: 'spring', + delayChildren: 0.5, + staggerChildren: 0.1, + }, + }, +}; + +const itemVariants: Variants = { + hidden: { opacity: 0, y: '-100%' }, + visible: { + opacity: 1, + y: 0, + transition: { + type: 'spring', + }, + }, +}; + const NavButton = ({ label, href, isActive = false, }: { - label: string; + label: string | React.ReactNode; href: UrlObject | Route; isActive?: boolean; }) => { return ( - + - {label} + {isActive && ( + + )} + {label} - {isActive && ( - - )} ); }; export function NavigationBar() { const pathname = usePathname(); + const shouldReduceMotion = useReducedMotion(); return ( - - - Fresco - - Fresco - {env.APP_VERSION} - - -
      - - - - - -
    - -
    +
    + + + + + Fresco + + +
      + + + + +
    +
    + + + Settings +
    + } + href="/dashboard/settings" + isActive={pathname === '/dashboard/settings'} + /> + + + + +
    + +
    + +
    + +
    ); } diff --git a/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx b/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx index bcf099ee2..e28a9b41a 100644 --- a/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx +++ b/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx @@ -1,62 +1,55 @@ -import { MoreHorizontal } from 'lucide-react'; -import { Button } from '~/components/ui/Button'; +import type { Row } from '@tanstack/react-table'; +import { DeleteIcon, MoreHorizontal, PencilIcon } from 'lucide-react'; +import { IconButton } from '@codaco/fresco-ui/Button'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, -} from '~/components/ui/dropdown-menu'; -import type { Row } from '@tanstack/react-table'; -import { useState } from 'react'; -import ParticipantModal from '~/app/dashboard/participants/_components/ParticipantModal'; -import type { ParticipantWithInterviews } from '~/types/types'; -import type { Participant } from '~/lib/db/generated/client'; +} from '@codaco/fresco-ui/DropdownMenu'; +import type { ParticipantRow } from './ParticipantsTableClient'; -export const ActionsDropdown = ({ +export function ActionsDropdown({ row, - data, - deleteHandler, + onEdit, + onDelete, }: { - row: Row; - data: ParticipantWithInterviews[]; - deleteHandler: (participant: ParticipantWithInterviews) => void; -}) => { - const [selectedParticipant, setSelectedParticipant] = - useState(null); - const [showParticipantModal, setShowParticipantModal] = useState(false); - - const editParticipant = (data: Participant) => { - setSelectedParticipant(data); - setShowParticipantModal(true); - }; - + row: Row; + onEdit: (participant: ParticipantRow) => void; + onDelete: (participant: ParticipantRow) => void; +}) { return ( - <> - + } + size="sm" + /> + } + nativeButton /> - - - - - + + Actions - editParticipant(row.original)}> + onEdit(row.original)} + icon={} + > Edit - deleteHandler(row.original)}> + onDelete(row.original)} + icon={} + > Delete - - - + + + ); -}; +} diff --git a/app/dashboard/_components/ParticipantsTable/Columns.tsx b/app/dashboard/_components/ParticipantsTable/Columns.tsx index bd96e707a..c76b350df 100644 --- a/app/dashboard/_components/ParticipantsTable/Columns.tsx +++ b/app/dashboard/_components/ParticipantsTable/Columns.tsx @@ -1,30 +1,20 @@ -import { type ColumnDef } from '@tanstack/react-table'; -import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; -import { Checkbox } from '~/components/ui/checkbox'; -import { GenerateParticipationURLButton } from './GenerateParticipantURLButton'; -import { type ParticipantWithInterviews } from '~/types/types'; +import { type StrictColumnDef } from '@codaco/fresco-ui/DataTable/types'; import Image from 'next/image'; -import InfoTooltip from '~/components/InfoTooltip'; -import { InfoIcon } from 'lucide-react'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { buttonVariants } from '~/components/ui/Button'; -import { Badge } from '~/components/ui/badge'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; +import Checkbox from '@codaco/fresco-ui/form/fields/Checkbox'; +import { DataTableColumnHeader } from '@codaco/fresco-ui/DataTable/ColumnHeader'; +import { SelectAllHeader } from '@codaco/fresco-ui/DataTable/SelectAllHeader'; +import { Badge } from '@codaco/fresco-ui/Badge'; +import type { ProtocolWithInterviews } from '../ProtocolsTable/ProtocolsTableClient'; +import { GenerateParticipationURLButton } from './GenerateParticipantURLButton'; +import type { ParticipantRow } from './ParticipantsTableClient'; export function getParticipantColumns( - protocols: Awaited, -): ColumnDef[] { + protocols: ProtocolWithInterviews[], +): StrictColumnDef[] { return [ { id: 'select', - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), + header: ({ table }) => , cell: ({ row }) => ( { return ; }, @@ -65,6 +56,7 @@ export function getParticipantColumns( }, { accessorKey: 'label', + sortingFn: 'text', header: ({ column }) => { return ; }, @@ -74,6 +66,8 @@ export function getParticipantColumns( }, { id: 'interviews', + accessorFn: (row) => row._count.interviews, + sortingFn: 'basic', header: ({ column }) => { return ; }, @@ -91,32 +85,12 @@ export function getParticipantColumns( }, { id: 'participant-url', - header: () => { + enableSorting: false, + header: ({ column }) => { return ( - - Unique Participant URL - -
    - } - content={ - <> - Unique Participant URL - - A unique participant URL allows a participant to take an - interview simply by visiting a URL. A participation URL is - specific to each participant, and should only be shared with - them. - - - } + ); }, diff --git a/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx b/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx index 62f7b2646..c79fbb729 100644 --- a/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx +++ b/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx @@ -1,99 +1,90 @@ 'use client'; -import type { Participant, Protocol } from '~/lib/db/generated/client'; -import { useRef, useState } from 'react'; +import { Copy } from 'lucide-react'; +import { memo, useState } from 'react'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Button } from '@codaco/fresco-ui/Button'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '~/components/ui/select'; + Popover, + PopoverContent, + PopoverTrigger, +} from '@codaco/fresco-ui/Popover'; +import { useToast } from '@codaco/fresco-ui/Toast'; +import type { Protocol } from '~/lib/db/generated/client'; +import SelectField from '@codaco/fresco-ui/form/fields/Select/Native'; +import type { ProtocolWithInterviews } from '../ProtocolsTable/ProtocolsTableClient'; -import { PopoverTrigger } from '@radix-ui/react-popover'; -import { Check, Copy } from 'lucide-react'; -import { Button } from '~/components/ui/Button'; -import { Popover, PopoverContent } from '~/components/ui/popover'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { useToast } from '~/components/ui/use-toast'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; +export const GenerateParticipationURLButton = memo( + function GenerateParticipationURLButton({ + participant, + protocols, + }: { + participant: { identifier: string }; + protocols: ProtocolWithInterviews[]; + }) { + const [open, setOpen] = useState(false); + const [selectedProtocol, setSelectedProtocol] = + useState | null>(); -export const GenerateParticipationURLButton = ({ - participant, - protocols, -}: { - participant: Participant; - protocols: Awaited; -}) => { - const [selectedProtocol, setSelectedProtocol] = useState(); + const { promise } = useToast(); - const { toast } = useToast(); - - const handleCopy = (url: string) => { - if (url) { - navigator.clipboard - .writeText(url) - .then(() => { - toast({ - title: 'Success!', - icon: , - description: 'Participation URL copied to clipboard', - variant: 'success', - }); - }) - .catch(() => { - toast({ - title: 'Error', - description: 'Could not copy text', - variant: 'destructive', - }); + const handleCopy = (url: string) => { + if (url) { + void promise(navigator.clipboard.writeText(url), { + loading: 'Copying URL to clipboard...', + success: 'URL copied to clipboard!', + error: 'Failed to copy URL to clipboard.', }); - } - }; + } + }; - const ref = useRef(null); - - return ( - - - - - - - Select a protocol, and the URL will be copied to your clipboard. - - - - - ); -}; + setSelectedProtocol(null); + }} + value={selectedProtocol?.id} + placeholder="Select a Protocol..." + /> + + + ); + }, +); diff --git a/app/dashboard/_components/ParticipantsTable/ParticipantsSelectionBar.tsx b/app/dashboard/_components/ParticipantsTable/ParticipantsSelectionBar.tsx new file mode 100644 index 000000000..2a1c8af56 --- /dev/null +++ b/app/dashboard/_components/ParticipantsTable/ParticipantsSelectionBar.tsx @@ -0,0 +1,82 @@ +import { AnimatePresence } from 'motion/react'; +import { FileUp, Trash } from 'lucide-react'; +import { Button } from '@codaco/fresco-ui/Button'; +import CloseButton from '@codaco/fresco-ui/CloseButton'; +import { cx } from '@codaco/fresco-ui/utils/cva'; +import { MotionSurface } from '@codaco/fresco-ui/layout/Surface'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; + +type ParticipantsSelectionBarProps = { + selectedCount: number; + totalCount: number; + isBusy: boolean; + onSelectAllMatching: () => void; + onDeselectAll: () => void; + onDeleteSelected: () => void; + onExportSelected: () => void; +}; + +export const ParticipantsSelectionBar = ({ + selectedCount, + totalCount, + isBusy, + onSelectAllMatching, + onDeselectAll, + onDeleteSelected, + onExportSelected, +}: ParticipantsSelectionBarProps) => { + return ( + + {selectedCount > 0 && ( + + + {selectedCount} selected + + {selectedCount < totalCount && ( + + )} +
    + + +
    + +
    + )} +
    + ); +}; diff --git a/app/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx b/app/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx index a1e4023b3..32bd1c960 100644 --- a/app/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx +++ b/app/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx @@ -1,20 +1,37 @@ import { Suspense } from 'react'; -import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; -import { getParticipants } from '~/queries/participants'; +import { DataTableSkeleton } from '@codaco/fresco-ui/DataTable/DataTableSkeleton'; +import { + getParticipants, + getParticipantsForSelect, +} from '~/queries/participants'; import { getProtocols } from '~/queries/protocols'; import { ParticipantsTableClient } from './ParticipantsTableClient'; +import type { ParticipantsSearchParams } from './searchParams'; -export default function ParticipantsTable() { - const participantsPromise = getParticipants(); +export default function ParticipantsTable({ + searchParams, +}: { + searchParams: ParticipantsSearchParams; +}) { + const participantsPromise = getParticipants(searchParams); + const allParticipantsPromise = getParticipantsForSelect(); const protocolsPromise = getProtocols(); return ( } + fallback={ + + } > ); diff --git a/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx b/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx index 18463d060..371c248c4 100644 --- a/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx +++ b/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx @@ -1,127 +1,326 @@ 'use client'; -import { type ColumnDef } from '@tanstack/react-table'; -import { Trash } from 'lucide-react'; -import { use, useCallback, useMemo, useState } from 'react'; import { - deleteAllParticipants, + type ColumnDef, + type Row, + type RowSelectionState, +} from '@tanstack/react-table'; +import { FileUp } from 'lucide-react'; +import { use, useMemo, useState, useTransition } from 'react'; +import SuperJSON from 'superjson'; +import { deleteParticipants, + getParticipantDeletionInfo, + getParticipantsForExport, + resolveParticipantIds, } from '~/actions/participants'; import { ActionsDropdown } from '~/app/dashboard/_components/ParticipantsTable/ActionsDropdown'; import { getParticipantColumns } from '~/app/dashboard/_components/ParticipantsTable/Columns'; +import AddParticipantButton from '~/app/dashboard/participants/_components/AddParticipantButton'; import { DeleteParticipantsDialog } from '~/app/dashboard/participants/_components/DeleteParticipantsDialog'; -import { DataTable } from '~/components/DataTable/DataTable'; -import { Button } from '~/components/ui/Button'; -import type { GetParticipantsReturnType } from '~/queries/participants'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; -import type { ParticipantWithInterviews } from '~/types/types'; -import AddParticipantButton from '../../participants/_components/AddParticipantButton'; -import { GenerateParticipantURLs } from '../../participants/_components/ExportParticipants/GenerateParticipantURLsButton'; - -export const ParticipantsTableClient = ({ - participantsPromise, - protocolsPromise, -}: { +import { useExportParticipants } from '~/app/dashboard/participants/_components/ExportParticipants/ExportParticipants'; +import ImportParticipants from '~/app/dashboard/participants/_components/ImportParticipants'; +import ParticipantModal from '~/app/dashboard/participants/_components/ParticipantModal'; +import { Button } from '@codaco/fresco-ui/Button'; +import { cx } from '@codaco/fresco-ui/utils/cva'; +import { useToast } from '@codaco/fresco-ui/Toast'; +import NuqsClearFilters from '~/components/DataTable/nuqs/NuqsClearFilters'; +import NuqsSearchFilter from '~/components/DataTable/nuqs/NuqsSearchFilter'; +import { + NuqsTableProvider, + useNuqsTable, +} from '~/components/DataTable/nuqs/NuqsTableProvider'; +import type { Participant } from '~/lib/db/generated/client'; +import type { + GetParticipantsForSelectQuery, + GetParticipantsForSelectReturnType, + GetParticipantsQuery, + GetParticipantsReturnType, +} from '~/queries/participants'; +import type { + GetProtocolsQuery, + GetProtocolsReturnType, +} from '~/queries/protocols'; +import ParticipantsTableRows from './ParticipantsTableRows'; +import { + PARTICIPANTS_PREFIX, + type ParticipantsSearchParams, +} from './searchParams'; + +const clearableFilters = ['q'] as const; + +export type ParticipantRow = GetParticipantsQuery[number]; + +type ParticipantsTableProps = { participantsPromise: GetParticipantsReturnType; + allParticipantsPromise: GetParticipantsForSelectReturnType; protocolsPromise: GetProtocolsReturnType; -}) => { - const participants = use(participantsPromise); - const protocols = use(protocolsPromise); + searchParams: ParticipantsSearchParams; +}; + +export const ParticipantsTableClient = (props: ParticipantsTableProps) => { + return ( + + + + ); +}; - // Memoize the columns so they don't re-render on every render - const columns = useMemo[]>( - () => getParticipantColumns(protocols), - [protocols], +const ParticipantsTableInner = ({ + participantsPromise, + allParticipantsPromise, + protocolsPromise, + searchParams, +}: ParticipantsTableProps) => { + // TanStack Table: consumers must also opt out so React Compiler doesn't memoize JSX that depends on the table ref. + 'use no memo'; + const { isPending } = useNuqsTable(); + const { add } = useToast(); + const rawAllParticipants = use(allParticipantsPromise); + const rawProtocols = use(protocolsPromise); + const allParticipants = useMemo( + () => SuperJSON.parse(rawAllParticipants), + [rawAllParticipants], ); + const protocols = useMemo( + () => SuperJSON.parse(rawProtocols), + [rawProtocols], + ); + + const exportParticipants = useExportParticipants(protocols); - const [participantsToDelete, setParticipantsToDelete] = useState< - ParticipantWithInterviews[] | null - >(null); + const [rowSelection, setRowSelection] = useState({}); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleteIds, setDeleteIds] = useState([]); + const [deleteHaveInterviews, setDeleteHaveInterviews] = useState(false); + const [deleteHaveUnexported, setDeleteHaveUnexported] = useState(false); - // Actual delete handler, which handles optimistic updates, etc. - const doDelete = async () => { - if (!participantsToDelete) { - return; - } + const [editingParticipant, setEditingParticipant] = + useState(null); + const [showEditModal, setShowEditModal] = useState(false); + + const [isSelecting, startSelecting] = useTransition(); + const [isDeleteResolving, startDeleteResolving] = useTransition(); + const [isExportResolving, startExportResolving] = useTransition(); + + const selectedIds = Object.keys(rowSelection).filter( + (id) => rowSelection[id], + ); + + const handleEditParticipant = useMemo( + () => (participant: ParticipantRow) => { + const existing = allParticipants.find((p) => p.id === participant.id); + if (!existing) return; + setEditingParticipant(existing); + setShowEditModal(true); + }, + [allParticipants], + ); - // Check if we are deleting all and call the appropriate function - if (participantsToDelete.length === participants.length) { - await deleteAllParticipants(); - resetDelete(); - return; - } + const openDeleteDialog = ( + ids: string[], + haveInterviews: boolean, + haveUnexported: boolean, + ) => { + setDeleteIds(ids); + setDeleteHaveInterviews(haveInterviews); + setDeleteHaveUnexported(haveUnexported); + setShowDeleteModal(true); + }; - await deleteParticipants(participantsToDelete.map((p) => p.id)); + const handleDeleteSingle = useMemo( + () => (participant: ParticipantRow) => { + openDeleteDialog( + [participant.id], + participant._count.interviews > 0, + participant.interviews.some((interview) => !interview.exportTime), + ); + }, + [], + ); + + const columns = useMemo[]>( + () => [ + ...getParticipantColumns(protocols), + { + id: 'actions', + enableSorting: false, + cell: ({ row }: { row: Row }) => ( + + ), + }, + ], + [protocols, handleEditParticipant, handleDeleteSingle], + ); + const handleDeleteSelected = () => { + startDeleteResolving(async () => { + const result = await getParticipantDeletionInfo(selectedIds); + if (result.error) { + add({ + title: 'Error', + description: result.error, + variant: 'destructive', + }); + return; + } + openDeleteDialog( + result.data.map((p) => p.id), + result.data.some((p) => p.hasInterviews), + result.data.some((p) => p.hasUnexportedInterviews), + ); + }); + }; + + const doDelete = async () => { + await deleteParticipants(deleteIds); + setRowSelection({}); resetDelete(); }; - // Resets the state when the dialog is closed. const resetDelete = () => { setShowDeleteModal(false); - setParticipantsToDelete(null); + setDeleteIds([]); + setDeleteHaveInterviews(false); + setDeleteHaveUnexported(false); }; - const handleDeleteItems = useCallback( - (items: ParticipantWithInterviews[]) => { - // Set state to the items to be deleted - setParticipantsToDelete(items); + const resolveAndExport = (ids: string[]) => { + startExportResolving(async () => { + const result = await getParticipantsForExport(ids); + if (result.error) { + add({ + title: 'Error', + description: result.error, + variant: 'destructive', + }); + return; + } + exportParticipants(result.data); + }); + }; - // Show the dialog - setShowDeleteModal(true); - }, - [], - ); + const handleExportSelected = () => { + resolveAndExport(selectedIds); + }; - const handleDeleteAll = useCallback(() => { - // Set state to all items - setParticipantsToDelete(participants); + const handleExportAll = () => { + startExportResolving(async () => { + const idsResult = await resolveParticipantIds(searchParams); + if (idsResult.error) { + add({ + title: 'Error', + description: idsResult.error, + variant: 'destructive', + }); + return; + } + const result = await getParticipantsForExport(idsResult.ids); + if (result.error) { + add({ + title: 'Error', + description: result.error, + variant: 'destructive', + }); + return; + } + exportParticipants(result.data); + }); + }; - // Show the dialog - setShowDeleteModal(true); - }, [participants]); + const handleSelectAllMatching = () => { + startSelecting(async () => { + const result = await resolveParticipantIds(searchParams); + if (result.error) { + add({ + title: 'Error', + description: result.error, + variant: 'destructive', + }); + return; + } + setRowSelection(Object.fromEntries(result.ids.map((id) => [id, true]))); + }); + }; + + const handleDeselectAll = () => { + setRowSelection({}); + }; return ( <> participant._count.interviews > 0, - ) - } - haveUnexportedInterviews={ - !!participantsToDelete?.some((participant) => - participant.interviews.some((interview) => !interview.exportTime), - ) - } + participantCount={deleteIds.length} + haveInterviews={deleteHaveInterviews} + haveUnexportedInterviews={deleteHaveUnexported} onConfirm={doDelete} onCancel={resetDelete} /> - -
    - - -
    - - - } + +
    + + } + /> +
    ); }; + +const Toolbar = ({ + existingParticipants, + isExportResolving, + onExportAll, +}: { + existingParticipants: GetParticipantsForSelectQuery; + isExportResolving: boolean; + onExportAll: () => void; +}) => { + return ( +
    + + + + + +
    + ); +}; diff --git a/app/dashboard/_components/ParticipantsTable/ParticipantsTableRows.tsx b/app/dashboard/_components/ParticipantsTable/ParticipantsTableRows.tsx new file mode 100644 index 000000000..e917dee54 --- /dev/null +++ b/app/dashboard/_components/ParticipantsTable/ParticipantsTableRows.tsx @@ -0,0 +1,137 @@ +import { + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type OnChangeFn, + type PaginationState, + type RowSelectionState, + type SortingState, +} from '@tanstack/react-table'; +import { parseAsInteger, parseAsStringLiteral, useQueryStates } from 'nuqs'; +import { use, useMemo, type ReactNode } from 'react'; +import superjson from 'superjson'; +import { DataTable } from '@codaco/fresco-ui/DataTable/DataTable'; +import { useNuqsTable } from '~/components/DataTable/nuqs/NuqsTableProvider'; +import type { + GetParticipantsQuery, + GetParticipantsReturnType, +} from '~/queries/participants'; +import { ParticipantsSelectionBar } from './ParticipantsSelectionBar'; +import { searchParamsUrlKeys, sortableFields, sortOrder } from './searchParams'; + +type ParticipantRow = GetParticipantsQuery[number]; + +export default function ParticipantsTableRows({ + participantsPromise, + rowSelection, + onRowSelectionChange, + columns, + toolbar, + isBusy, + onDeleteSelected, + onExportSelected, + onSelectAllMatching, + onDeselectAll, +}: { + participantsPromise: GetParticipantsReturnType; + rowSelection: RowSelectionState; + onRowSelectionChange: OnChangeFn; + columns: ColumnDef[]; + toolbar: ReactNode; + isBusy: boolean; + onDeleteSelected: () => void; + onExportSelected: () => void; + onSelectAllMatching: () => void; + onDeselectAll: () => void; +}) { + // TanStack Table returns a mutable ref with stable identity, defeating React Compiler memoization. + 'use no memo'; + const data = use(participantsPromise); + const rows = useMemo( + () => superjson.parse(data.rows), + [data.rows], + ); + const { startTransition } = useNuqsTable(); + + const [params, setTableState] = useQueryStates( + { + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: parseAsStringLiteral(sortOrder).withDefault('none'), + sortField: parseAsStringLiteral(sortableFields).withDefault('identifier'), + }, + { + urlKeys: searchParamsUrlKeys, + shallow: false, + clearOnDefault: true, + startTransition, + }, + ); + + const { page, perPage, sort, sortField } = params; + + const pagination: PaginationState = { + pageIndex: page - 1, + pageSize: perPage, + }; + + const sorting: SortingState = + sort === 'none' ? [] : [{ id: sortField, desc: sort === 'desc' }]; + + const table = useReactTable({ + data: rows, + columns, + pageCount: data.pageCount, + getRowId: (row) => row.id, + state: { pagination, sorting, rowSelection }, + onPaginationChange: (updater) => { + const next = + typeof updater === 'function' ? updater(pagination) : updater; + void setTableState({ + page: next.pageIndex + 1, + perPage: next.pageSize, + }); + }, + onSortingChange: (updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater; + const first = next[0]; + if (!first) { + void setTableState({ sort: null, sortField: null }); + return; + } + const field = sortableFields.find((f) => f === first.id); + if (!field) return; + void setTableState({ + sort: first.desc ? 'desc' : 'asc', + sortField: field, + }); + }, + onRowSelectionChange, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + manualPagination: true, + manualSorting: true, + }); + + const selectedCount = Object.keys(rowSelection).filter( + (id) => rowSelection[id], + ).length; + + return ( + <> + + + + ); +} diff --git a/app/dashboard/_components/ParticipantsTable/buildParticipantWhere.ts b/app/dashboard/_components/ParticipantsTable/buildParticipantWhere.ts new file mode 100644 index 000000000..8f6e68901 --- /dev/null +++ b/app/dashboard/_components/ParticipantsTable/buildParticipantWhere.ts @@ -0,0 +1,42 @@ +import { Prisma } from '~/lib/db/generated/client'; +import type { ParticipantsSearchParams } from './searchParams'; + +/** + * Builds the WHERE predicate (without the `WHERE` keyword) for the participant + * list. Returns `Prisma.empty` when no filters are active. Column references + * assume the alias `p` (Participant). + */ +export function buildParticipantWhere( + params: ParticipantsSearchParams, +): Prisma.Sql { + const conditions: Prisma.Sql[] = []; + + if (params.q) { + conditions.push(Prisma.sql`p."identifier" ILIKE '%' || ${params.q} || '%'`); + } + + if (conditions.length === 0) return Prisma.empty; + return Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`; +} + +const DEFAULT_SORT_COLUMN = 'p."identifier"'; + +const INTERVIEW_COUNT_SQL = + '(SELECT COUNT(*) FROM "Interview" iv WHERE iv."participantId" = p."id")'; + +const SORT_COLUMN: Record = { + identifier: 'p."identifier"', + label: 'p."label"', + interviews: INTERVIEW_COUNT_SQL, +}; + +export function buildParticipantOrderBy( + params: ParticipantsSearchParams, +): Prisma.Sql { + if (params.sort === 'none') { + return Prisma.sql`ORDER BY p."identifier" ASC, p."id" ASC`; + } + const col = SORT_COLUMN[params.sortField] ?? DEFAULT_SORT_COLUMN; + const dir = params.sort === 'asc' ? Prisma.raw('ASC') : Prisma.raw('DESC'); + return Prisma.sql`ORDER BY ${Prisma.raw(col)} ${dir}, p."id" ASC`; +} diff --git a/app/dashboard/_components/ParticipantsTable/searchParams.ts b/app/dashboard/_components/ParticipantsTable/searchParams.ts new file mode 100644 index 000000000..01c325eb8 --- /dev/null +++ b/app/dashboard/_components/ParticipantsTable/searchParams.ts @@ -0,0 +1,34 @@ +import { + createSearchParamsCache, + parseAsInteger, + parseAsString, + parseAsStringLiteral, +} from 'nuqs/server'; + +export const PARTICIPANTS_PREFIX = 'pt'; + +export const sortOrder = ['asc', 'desc', 'none'] as const; +export const sortableFields = ['identifier', 'label', 'interviews'] as const; + +export const searchParamsParsers = { + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: parseAsStringLiteral(sortOrder).withDefault('none'), + sortField: parseAsStringLiteral(sortableFields).withDefault('identifier'), + q: parseAsString, +}; + +export const searchParamsUrlKeys = Object.fromEntries( + Object.keys(searchParamsParsers).map((k) => [ + k, + `${PARTICIPANTS_PREFIX}_${k}`, + ]), +) as Record; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers, { + urlKeys: searchParamsUrlKeys, +}); + +export type ParticipantsSearchParams = Awaited< + ReturnType +>; diff --git a/app/dashboard/_components/ProtocolUploader.tsx b/app/dashboard/_components/ProtocolUploader.tsx index c7feb1ad8..a5ec37a6e 100644 --- a/app/dashboard/_components/ProtocolUploader.tsx +++ b/app/dashboard/_components/ProtocolUploader.tsx @@ -1,111 +1,29 @@ 'use client'; -import { FileDown, Loader2 } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import { useCallback } from 'react'; -import { useDropzone } from 'react-dropzone'; -import JobCard from '~/components/ProtocolImport/JobCard'; -import { Button, type ButtonProps } from '~/components/ui/Button'; -import { PROTOCOL_EXTENSION } from '~/fresco.config'; -import usePortal from '~/hooks/usePortal'; +import ProtocolImportPopover from '~/components/ProtocolImport/ProtocolImportPopover'; +import { type ButtonProps } from '@codaco/fresco-ui/Button'; import { useProtocolImport } from '~/hooks/useProtocolImport'; -import { withNoSSRWrapper } from '~/utils/NoSSRWrapper'; -import { cn } from '~/utils/shadcn'; -function ProtocolUploader({ +export default function ProtocolUploader({ className, buttonVariant, buttonSize, - hideCancelButton, buttonDisabled, }: { className?: string; buttonVariant?: ButtonProps['variant']; buttonSize?: ButtonProps['size']; - hideCancelButton?: boolean; buttonDisabled?: boolean; }) { - const Portal = usePortal(); - - const { importProtocols, jobs, cancelJob, cancelAllJobs } = - useProtocolImport(); - - const { getInputProps, open } = useDropzone({ - // Disable automatic opening of file dialog - we do it manually to allow for - // job cards to be clicked - noClick: true, - onDropAccepted: importProtocols, - accept: { - 'application/octect-stream': [PROTOCOL_EXTENSION], - 'application/zip': [PROTOCOL_EXTENSION], - }, - }); - - const handleCancelJob = useCallback( - (jobId: string) => () => cancelJob(jobId), - [cancelJob], - ); - - const isActive = jobs && jobs.length > 0 && jobs.some((job) => !job.error); + const { importProtocols } = useProtocolImport(); return ( - <> - - {!hideCancelButton && jobs.length > 0 && ( - - )} - - - - - {jobs.map((job, index) => ( - - ))} - - - - - + ); } - -export default withNoSSRWrapper(ProtocolUploader); diff --git a/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx b/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx index c2dabc44d..2d281ae03 100644 --- a/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx +++ b/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx @@ -1,18 +1,21 @@ 'use client'; -import { MoreHorizontal } from 'lucide-react'; -import { Button } from '~/components/ui/Button'; +import type { Row } from '@tanstack/react-table'; +import { Download, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { DeleteProtocolsDialog } from '~/app/dashboard/protocols/_components/DeleteProtocolsDialog'; +import { IconButton } from '@codaco/fresco-ui/Button'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, -} from '~/components/ui/dropdown-menu'; -import type { Row } from '@tanstack/react-table'; -import { useState } from 'react'; -import type { ProtocolWithInterviews } from '~/types/types'; -import { DeleteProtocolsDialog } from '~/app/dashboard/protocols/_components/DeleteProtocolsDialog'; +} from '@codaco/fresco-ui/DropdownMenu'; +import { useToast } from '@codaco/fresco-ui/Toast'; +import { useDownload } from '~/hooks/useDownload'; +import type { ProtocolWithInterviews } from './ProtocolsTableClient'; export const ActionsDropdown = ({ row, @@ -22,12 +25,28 @@ export const ActionsDropdown = ({ const [showDeleteModal, setShowDeleteModal] = useState(false); const [protocolToDelete, setProtocolToDelete] = useState(); + const { promise } = useToast(); + const download = useDownload(); const handleDelete = (data: ProtocolWithInterviews) => { setProtocolToDelete([data]); setShowDeleteModal(true); }; + const handleDownload = async () => { + const { originalFileUrl, name } = row.original; + if (!originalFileUrl) return; + + const response = await fetch(originalFileUrl); + if (!response.ok) { + throw new Error('Failed to download protocol file'); + } + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + download(blobUrl, name); + URL.revokeObjectURL(blobUrl); + }; + return ( <> - - - + } + size="sm" + /> + } + nativeButton + /> - Actions - handleDelete(row.original)}> - Delete - + + Actions + {row.original.originalFileUrl && ( + + void promise(handleDownload(), { + loading: 'Downloading protocol...', + success: 'Protocol downloaded!', + error: 'Failed to download protocol.', + }) + } + icon={} + > + Download + + )} + handleDelete(row.original)} + icon={} + > + Delete + + diff --git a/app/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx b/app/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx index 0712f284a..0f32a9578 100644 --- a/app/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx +++ b/app/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx @@ -1,16 +1,16 @@ 'use client'; -import { Check, Copy } from 'lucide-react'; +import { Copy } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { Button } from '~/components/ui/Button'; -import { useToast } from '~/components/ui/use-toast'; +import { Button } from '@codaco/fresco-ui/Button'; +import { useToast } from '@codaco/fresco-ui/Toast'; export const AnonymousRecruitmentURLButton = ({ protocolId, }: { protocolId: string; }) => { - const { toast } = useToast(); + const { promise } = useToast(); const [url, setUrl] = useState(null); useEffect(() => { @@ -24,30 +24,16 @@ export const AnonymousRecruitmentURLButton = ({ return; } - navigator.clipboard - .writeText(url) - .then(() => { - toast({ - title: 'Success!', - description: 'URL copied to clipboard', - variant: 'success', - icon: , - }); - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error('Could not copy text: ', error); - toast({ - title: 'Error', - description: 'Could not copy text', - variant: 'destructive', - }); - }); + void promise(navigator.clipboard.writeText(url), { + loading: 'Copying URL to clipboard...', + success: 'URL copied to clipboard!', + error: 'Failed to copy URL to clipboard.', + }); }; return ( - ); diff --git a/app/dashboard/_components/ProtocolsTable/Columns.tsx b/app/dashboard/_components/ProtocolsTable/Columns.tsx index 2074deb1f..0ca0478c6 100644 --- a/app/dashboard/_components/ProtocolsTable/Columns.tsx +++ b/app/dashboard/_components/ProtocolsTable/Columns.tsx @@ -1,23 +1,17 @@ 'use client'; -import { type ColumnDef } from '@tanstack/react-table'; -import { Checkbox } from '~/components/ui/checkbox'; -import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; -import type { ProtocolWithInterviews } from '~/types/types'; -import { AnonymousRecruitmentURLButton } from './AnonymousRecruitmentURLButton'; -import TimeAgo from '~/components/ui/TimeAgo'; +import { type StrictColumnDef } from '@codaco/fresco-ui/DataTable/types'; import Image from 'next/image'; -import { buttonVariants } from '~/components/ui/Button'; -import InfoTooltip from '~/components/InfoTooltip'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import Heading from '~/components/ui/typography/Heading'; -import Link from '~/components/Link'; -import { InfoIcon } from 'lucide-react'; +import Checkbox from '@codaco/fresco-ui/form/fields/Checkbox'; +import { DataTableColumnHeader } from '@codaco/fresco-ui/DataTable/ColumnHeader'; +import TimeAgo from '@codaco/fresco-ui/TimeAgo'; +import { AnonymousRecruitmentURLButton } from './AnonymousRecruitmentURLButton'; +import type { ProtocolWithInterviews } from './ProtocolsTableClient'; export const getProtocolColumns = ( allowAnonRecruitment = false, -): ColumnDef[] => { - const columns: ColumnDef[] = [ +): StrictColumnDef[] => { + const columns: StrictColumnDef[] = [ { id: 'select', header: ({ table }) => ( @@ -39,25 +33,28 @@ export const getProtocolColumns = ( }, { accessorKey: 'name', + sortingFn: 'text', header: ({ column }) => { return ; }, cell: ({ row }) => { return ( -
    +
    Protocol icon - {row.original.name} + {row.original.name}
    ); }, }, { accessorKey: 'importedAt', + sortingFn: 'datetime', header: ({ column }) => { return ; }, @@ -65,6 +62,7 @@ export const getProtocolColumns = ( }, { accessorKey: 'lastModified', + sortingFn: 'datetime', header: ({ column }) => { return ; }, @@ -75,38 +73,12 @@ export const getProtocolColumns = ( if (allowAnonRecruitment) { columns.push({ id: 'participant-url', - header: () => { + enableSorting: false, + header: ({ column }) => { return ( - - Anonymous Participation URL - -
    - } - content={ - <> - - Anonymous Participation URLs - - - Anonymous recruitment is enabled, so you can generate - anonymous participation URLs for your protocols from the - "Anonymous Participation URL" column in the table - below.. These URLs can be shared with participants to allow - them to self-enroll in your study. - - - To disable anonymous recruitment, visit the{' '} - settings page. - - - } + ); }, diff --git a/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index b49876875..738f5c851 100644 --- a/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -1,18 +1,27 @@ -import { unstable_noStore } from 'next/cache'; import { Suspense } from 'react'; -import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; +import { DataTableSkeleton } from '@codaco/fresco-ui/DataTable/DataTableSkeleton'; +import { env } from '~/env'; import { getAppSetting } from '~/queries/appSettings'; import { getProtocols } from '~/queries/protocols'; +import { getStorageProvider } from '~/queries/storageProvider'; import ProtocolsTableClient from './ProtocolsTableClient'; async function getData() { - unstable_noStore(); - - return Promise.all([ + const [ + protocols, + allowAnonymousRecruitment, + storageProvider, + uploadThingToken, + ] = await Promise.all([ getProtocols(), getAppSetting('allowAnonymousRecruitment'), + getStorageProvider(), getAppSetting('uploadThingToken'), ]); + const storageConfigured = + storageProvider === 's3' || + Boolean(env.UPLOADTHING_TOKEN ?? uploadThingToken); + return [protocols, allowAnonymousRecruitment, storageConfigured] as const; } export type GetData = ReturnType; @@ -20,7 +29,13 @@ export type GetData = ReturnType; export default function ProtocolsTable() { return ( } + fallback={ + + } > diff --git a/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx b/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx index 4159b33c3..c036720a8 100644 --- a/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx +++ b/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx @@ -1,17 +1,32 @@ 'use client'; -import { use, useState } from 'react'; -import { DeleteProtocolsDialog } from '~/app/dashboard/protocols/_components/DeleteProtocolsDialog'; -import { DataTable } from '~/components/DataTable/DataTable'; -import type { ProtocolWithInterviews } from '~/types/types'; +import { type ColumnDef, type Row } from '@tanstack/react-table'; +import { Trash } from 'lucide-react'; +import { use, useMemo, useState } from 'react'; +import { SuperJSON } from 'superjson'; +import { DataTable } from '@codaco/fresco-ui/DataTable/DataTable'; +import { DataTableFloatingBar } from '@codaco/fresco-ui/DataTable/DataTableFloatingBar'; +import { DataTableToolbar } from '@codaco/fresco-ui/DataTable/DataTableToolbar'; +import { Button } from '@codaco/fresco-ui/Button'; +import { useClientDataTable } from '~/hooks/useClientDataTable'; +import type { GetProtocolsQuery } from '~/queries/protocols'; +import { DeleteProtocolsDialog } from '../../protocols/_components/DeleteProtocolsDialog'; import ProtocolUploader from '../ProtocolUploader'; import { ActionsDropdown } from './ActionsDropdown'; import { getProtocolColumns } from './Columns'; import { type GetData } from './ProtocolsTable'; +export type ProtocolWithInterviews = GetProtocolsQuery[number]; + const ProtocolsTableClient = ({ dataPromise }: { dataPromise: GetData }) => { - const [protocols, allowAnonymousRecruitment, hasUploadThingToken] = + // TanStack Table: consumers must also opt out so React Compiler doesn't memoize JSX that depends on the table ref. + 'use no memo'; + const [rawProtocols, allowAnonymousRecruitment, storageConfigured] = use(dataPromise); + const protocols = useMemo( + () => SuperJSON.parse(rawProtocols), + [rawProtocols], + ); const [showAlertDialog, setShowAlertDialog] = useState(false); const [protocolsToDelete, setProtocolsToDelete] = @@ -22,15 +37,52 @@ const ProtocolsTableClient = ({ dataPromise }: { dataPromise: GetData }) => { setShowAlertDialog(true); }; + const actionsColumn: ColumnDef = { + id: 'actions', + cell: ({ row }: { row: Row }) => ( + + ), + }; + + const columns = useMemo[]>( + () => [...getProtocolColumns(allowAnonymousRecruitment), actionsColumn], + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowAnonymousRecruitment], + ); + + const { table } = useClientDataTable({ + data: protocols, + columns, + defaultSortBy: { id: 'importedAt', desc: true }, + }); + return ( <> } + table={table} + toolbar={ + + + + } + floatingBar={ + + + + } /> ; }) { - const protocols = use(protocolsPromise); - const participants = use(participantsPromise); + const rawProtocols = use(protocolsPromise); + const protocols = SuperJSON.parse(rawProtocols); + const rawParticipants = use(participantsPromise); + const participants = + SuperJSON.parse(rawParticipants); const allowAnonymousRecruitment = use(allowAnonymousRecruitmentPromise); - const [selectedProtocol, setSelectedProtocol] = useState(); + const [selectedProtocol, setSelectedProtocol] = useState>(); const [selectedParticipant, setSelectedParticipant] = useState(); const router = useRouter(); @@ -46,35 +50,34 @@ export default function RecruitmentTestSection({ return `/onboard/${selectedProtocol?.id}` as Route; } - return `/onboard/${selectedProtocol?.id}/?participantIdentifier=${selectedParticipant?.identifier}` as Route; + return `/onboard/${selectedProtocol?.id}/?participantIdentifier=${encodeURIComponent( + selectedParticipant.identifier, + )}` as Route; }; return ( <> -
    - - + placeholder="Select a Participant..." + />
    -
    +
    - setShowConfirmDialog(state)} - > - - - Are you sure? - - This action will delete ALL application data, including interviews - and protocols. This action cannot be undone. Do you want to - continue? - - - - + closeDialog={() => setShowConfirmDialog(false)} + title="Are you sure?" + description="This action will delete ALL application data, including interviews and protocols. This action cannot be undone. Do you want to continue?" + footer={ + <> + - - - + + } + > ); }; diff --git a/app/dashboard/_components/SummaryStatistics/Icons.tsx b/app/dashboard/_components/SummaryStatistics/Icons.tsx index 5a92bfb0b..5d67b1d7a 100644 --- a/app/dashboard/_components/SummaryStatistics/Icons.tsx +++ b/app/dashboard/_components/SummaryStatistics/Icons.tsx @@ -1,29 +1,29 @@ export const ProtocolIcon = () => ( -
    +
    -
    -
    +
    +
    -
    -
    +
    +
    ); export const InterviewIcon = () => ( -
    +
    -
    -
    +
    +
    -
    +
    -
    -
    +
    +
    -
    -
    +
    +
    diff --git a/app/dashboard/_components/SummaryStatistics/StatCard.tsx b/app/dashboard/_components/SummaryStatistics/StatCard.tsx index 064a4ba66..ca9665a02 100644 --- a/app/dashboard/_components/SummaryStatistics/StatCard.tsx +++ b/app/dashboard/_components/SummaryStatistics/StatCard.tsx @@ -1,12 +1,15 @@ import { use } from 'react'; -import { Skeleton } from '~/components/ui/skeleton'; -import Heading from '~/components/ui/typography/Heading'; -import { cn } from '~/utils/shadcn'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import { Skeleton } from '@codaco/fresco-ui/Skeleton'; +import { cx } from '@codaco/fresco-ui/utils/cva'; -const statCardClasses = cn( - 'flex flex-col gap-4 rounded-xl border border-[hsl(var(--platinum--dark))] bg-card p-4 text-card-foreground shadow-xl shadow-platinum-dark transition-all', - 'sm:flex-row sm:items-center md:p-6 lg:gap-6 lg:p-10', - ' hover:scale-[102%]', +const statCardClasses = cx( + 'flex flex-col gap-4 border transition-all', + '@3xs:flex-row @3xs:items-center @lg:gap-6', + 'hover:elevation-medium hover:scale-[102%]', + 'w-full rounded outline-none', + 'tablet-landscape:px-6 tablet-landscape:py-8 px-4 py-6', ); function StatCard({ title, @@ -22,13 +25,22 @@ function StatCard({ const data = use(dataPromise); return ( -
    -
    {icon}
    + +
    {icon}
    - {title} - {data[render]} + + {title} + + + {data[render]} +
    -
    + ); } @@ -40,13 +52,15 @@ export function StatCardSkeleton({ icon: React.ReactNode; }) { return ( -
    -
    {icon}
    + +
    {icon}
    - {title} + + {title} +
    -
    + ); } diff --git a/app/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx b/app/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx index e2cf2012a..3e4aa28c8 100644 --- a/app/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx +++ b/app/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx @@ -1,20 +1,28 @@ import Image from 'next/image'; import Link from 'next/link'; import { Suspense } from 'react'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { getSummaryStatistics } from '~/queries/summaryStatistics'; +import ResponsiveContainer from '@codaco/fresco-ui/layout/ResponsiveContainer'; +import { type getSummaryStatistics } from '~/queries/summaryStatistics'; import { InterviewIcon, ProtocolIcon } from './Icons'; import StatCard, { StatCardSkeleton } from './StatCard'; -export default function SummaryStatistics() { - const data = getSummaryStatistics(); +type SummaryStatisticsProps = { + dataPromise: ReturnType; +}; +export default function SummaryStatistics({ + dataPromise, +}: SummaryStatisticsProps) { return ( - + } /> @@ -22,13 +30,17 @@ export default function SummaryStatistics() { > } /> - + - + } /> @@ -69,7 +85,7 @@ export default function SummaryStatistics() { > } /> @@ -78,25 +94,3 @@ export default function SummaryStatistics() { ); } - -export const SummaryStatisticsSkeleton = () => ( - - } /> - - } - /> - } /> - -); diff --git a/app/dashboard/_components/UpdateSettingsValue.tsx b/app/dashboard/_components/UpdateSettingsValue.tsx index df50e22bd..8cc1a8233 100644 --- a/app/dashboard/_components/UpdateSettingsValue.tsx +++ b/app/dashboard/_components/UpdateSettingsValue.tsx @@ -1,34 +1,38 @@ import { Loader2 } from 'lucide-react'; -import { useState } from 'react'; -import { type z } from 'zod'; -import { Button } from '~/components/ui/Button'; -import { Input } from '~/components/ui/Input'; +import { type ReactNode, useState } from 'react'; +import type { z } from 'zod/mini'; +import { setAppSetting } from '~/actions/appSettings'; +import { Button } from '@codaco/fresco-ui/Button'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import { type AppSetting } from '~/schemas/appSettings'; import ReadOnlyEnvAlert from '../settings/ReadOnlyEnvAlert'; -export default function UpdateSettingsValue({ +export default function UpdateSettingsValue({ + settingsKey, initialValue, - updateValue, - schema, readOnly, + schema, + suffixComponent, + placeholder, }: { - initialValue?: T; - updateValue: (value: T) => Promise; - schema: z.ZodSchema; + settingsKey: AppSetting; + initialValue?: string; readOnly?: boolean; + schema: z.ZodMiniType; + suffixComponent?: ReactNode; + placeholder?: string; }) { const [newValue, setNewValue] = useState(initialValue); const [error, setError] = useState(null); const [isSaving, setSaving] = useState(false); - // If key is empty or invalid, set the error state - const handleChange = (event: React.ChangeEvent) => { - const value = event.target.value; - - const result = schema.safeParse(value); + // If settingsKey is empty or invalid, set the error state + const handleChange = (value: string | undefined) => { + const result = schema.safeParse(value ?? initialValue ?? ''); if (!result.success) { setError( - `Invalid: ${result.error.errors.map((e) => e.message).join(', ')}`, + `Invalid: ${result.error.issues.map((e) => e.message).join(', ')}`, ); } else { setError(null); @@ -47,30 +51,40 @@ export default function UpdateSettingsValue({ if (!newValue) return; setSaving(true); - await updateValue(newValue); - setSaving(false); + setError(null); + try { + await setAppSetting(settingsKey, newValue); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : 'Failed to save setting', + ); + } finally { + setSaving(false); + } }; return ( <> {readOnly && } - event.target.select()} type="text" - error={error} className="w-full" + placeholder={placeholder} disabled={readOnly ?? isSaving} + suffixComponent={suffixComponent} /> + {error &&

    {error}

    } {newValue !== initialValue && (
    - {!isSaving && ( - - )} - } + diff --git a/app/dashboard/_components/UpdateUploadThingTokenAlert.tsx b/app/dashboard/_components/UpdateUploadThingTokenAlert.tsx index de66d219a..1803d0f7c 100644 --- a/app/dashboard/_components/UpdateUploadThingTokenAlert.tsx +++ b/app/dashboard/_components/UpdateUploadThingTokenAlert.tsx @@ -1,16 +1,19 @@ -import { AlertTriangleIcon } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@codaco/fresco-ui/Alert'; import Link from '~/components/Link'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; +import { env } from '~/env'; import { getAppSetting } from '~/queries/appSettings'; +import { getStorageProvider } from '~/queries/storageProvider'; export default async function UpdateUploadThingTokenAlert() { - const uploadThingToken = await getAppSetting('uploadThingToken'); + const storageProvider = await getStorageProvider(); + if (storageProvider === 's3') return null; + const uploadThingToken = + env.UPLOADTHING_TOKEN ?? (await getAppSetting('uploadThingToken')); if (uploadThingToken) return null; return ( - - + Configuration update required You need to add a new UploadThing API key before you can upload diff --git a/app/dashboard/_components/UploadThingModal.tsx b/app/dashboard/_components/UploadThingModal.tsx index 3c47e4ffa..e020b44d2 100644 --- a/app/dashboard/_components/UploadThingModal.tsx +++ b/app/dashboard/_components/UploadThingModal.tsx @@ -2,75 +2,53 @@ import Image from 'next/image'; import { useState } from 'react'; -import { setAppSetting } from '~/actions/appSettings'; import { UploadThingTokenForm } from '~/app/(blobs)/(setup)/_components/UploadThingTokenForm'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; import Link from '~/components/Link'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '~/components/ui/dialog'; -import { Divider } from '~/components/ui/Divider'; -import Paragraph from '~/components/ui/typography/Paragraph'; +import Dialog from '@codaco/fresco-ui/dialogs/Dialog'; function UploadThingModal() { const [open, setOpen] = useState(true); return ( - - - - Required Environment Variable Update - - - The Fresco update you installed requires a new UploadThing API - key.{' '} - - Until you add it, you will not be able to upload new protocols - - . Existing protocols will continue to function. - - - - Updating the key should take a matter of minutes, and can be - completed using the following steps: - -
      -
    1. - Visit the{' '} - - UploadThing dashboard - -
    2. -
    3. Select your project.
    4. -
    5. Select the API Keys tab.
    6. -
    7. - Ensure you have the SDK v7+ tab selected. -
    8. -
    9. - Copy the token by clicking the Copy button (see screenshot - below).{' '} - UploadThing API key dashboard -
    10. -
    11. - Paste the token into the field below and click "save and - continue". -
    12. -
    -
    -
    - - setAppSetting('uploadThingToken', token)} - /> -
    + setOpen(false)} + title="Required Environment Variable Update" + description="The Fresco update you installed requires a new UploadThing API + key. Until you add it, you will not be able to upload new protocols. Existing protocols will continue to function." + > + + Updating the key should take a matter of minutes, and can be completed + using the following steps: + +
      +
    1. + Visit the{' '} + + UploadThing dashboard + +
    2. +
    3. Select your project.
    4. +
    5. Select the API Keys tab.
    6. +
    7. + Ensure you have the SDK v7+ tab selected. +
    8. +
    9. + Copy the token by clicking the Copy button (see screenshot below).{' '} + UploadThing API key dashboard +
    10. +
    11. + Paste the token into the field below and click "save and + continue". +
    12. +
    +
    ); } diff --git a/app/dashboard/_components/UserMenu.tsx b/app/dashboard/_components/UserMenu.tsx index 213f0694c..587bc826d 100644 --- a/app/dashboard/_components/UserMenu.tsx +++ b/app/dashboard/_components/UserMenu.tsx @@ -1,10 +1,10 @@ import { logout } from '~/actions/auth'; -import SubmitButton from '~/components/ui/SubmitButton'; +import SubmitButton from '~/components/SubmitButton'; const UserMenu = () => { return (
    void logout()}> - + Sign out diff --git a/app/dashboard/interviews/_components/DeleteInterviewsDialog.tsx b/app/dashboard/interviews/_components/DeleteInterviewsDialog.tsx index 13b95a65a..e3a3f5015 100644 --- a/app/dashboard/interviews/_components/DeleteInterviewsDialog.tsx +++ b/app/dashboard/interviews/_components/DeleteInterviewsDialog.tsx @@ -1,23 +1,14 @@ -import type { Interview } from '~/lib/db/generated/client'; -import { AlertCircle, Loader2, Trash2 } from 'lucide-react'; +import { Loader2, Trash2 } from 'lucide-react'; import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; import { deleteInterviews } from '~/actions/interviews'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '~/components/ui/AlertDialog'; -import { Button } from '~/components/ui/Button'; +import { Alert, AlertDescription, AlertTitle } from '@codaco/fresco-ui/Alert'; +import { Button } from '@codaco/fresco-ui/Button'; +import Dialog from '@codaco/fresco-ui/dialogs/Dialog'; type DeleteInterviewsDialog = { open: boolean; setOpen: Dispatch>; - interviewsToDelete: Interview[]; + interviewsToDelete: { id: string; exportTime: Date | null }[]; }; export const DeleteInterviewsDialog = ({ @@ -47,66 +38,60 @@ export const DeleteInterviewsDialog = ({ }; return ( - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete{' '} - - {interviewsToDelete.length}{' '} - {interviewsToDelete.length > 1 ? ( - <>interviews. - ) : ( - <>interview. - )} - - - {hasUnexported && ( - - - Warning - - {interviewsToDelete.length > 1 ? ( - <> - One or more of the selected interviews - has not yet been exported. - - ) : ( - <> - The selected interview - has not yet been exported. - - )} - - - )} - - - + + This action cannot be undone. This will permanently delete{' '} + + {interviewsToDelete.length}{' '} + {interviewsToDelete.length > 1 ? <>interviews. : <>interview.} + + + } + footer={ + <> + + + } + > + {hasUnexported && ( + + Warning + + {interviewsToDelete.length > 1 ? ( <> - Deleting... + One or more of the selected interviews + has not yet been exported. ) : ( <> - Delete + The selected interview + has not yet been exported. )} - - - - +
    +
    + )} + ); }; diff --git a/app/dashboard/interviews/_components/ExportCSVInterviewURLs.tsx b/app/dashboard/interviews/_components/ExportCSVInterviewURLs.tsx index bd3631a60..d07d5d0cf 100644 --- a/app/dashboard/interviews/_components/ExportCSVInterviewURLs.tsx +++ b/app/dashboard/interviews/_components/ExportCSVInterviewURLs.tsx @@ -3,22 +3,24 @@ import { Download } from 'lucide-react'; import { unparse } from 'papaparse'; import { useState } from 'react'; -import { Button } from '~/components/ui/Button'; -import { useToast } from '~/components/ui/use-toast'; +import { Button } from '@codaco/fresco-ui/Button'; +import { useToast } from '@codaco/fresco-ui/Toast'; +import type { IncompleteInterviewUrlData } from '~/actions/interviews'; import { useDownload } from '~/hooks/useDownload'; -import type { GetInterviewsReturnType } from '~/queries/interviews'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; +import type { ProtocolWithInterviews } from '../../_components/ProtocolsTable/ProtocolsTableClient'; function ExportCSVInterviewURLs({ protocol, interviews, + disabled = false, }: { - protocol?: Awaited[number]; - interviews: Awaited; + protocol?: ProtocolWithInterviews; + interviews: IncompleteInterviewUrlData[]; + disabled?: boolean; }) { const download = useDownload(); const [isExporting, setIsExporting] = useState(false); - const { toast } = useToast(); + const { add } = useToast(); const handleExport = () => { try { @@ -26,8 +28,7 @@ function ExportCSVInterviewURLs({ if (!protocol?.id) return; const csvData = interviews.map((interview) => ({ - participant_id: interview.participantId, - identifier: interview.participant.identifier, + identifier: interview.identifier, interview_url: `${window.location.origin}/interview/${interview.id}`, })); @@ -42,13 +43,13 @@ function ExportCSVInterviewURLs({ download(url, fileName); // Clean up the URL object URL.revokeObjectURL(url); - toast({ + add({ + title: 'Success', description: 'Incomplete interview URLs CSV exported successfully', variant: 'success', - duration: 3000, }); } catch (error) { - toast({ + add({ title: 'Error', description: 'An error occurred while exporting incomplete interview URLs', @@ -64,12 +65,13 @@ function ExportCSVInterviewURLs({ return ( ); } diff --git a/app/dashboard/interviews/_components/ExportInterviewsDialog.tsx b/app/dashboard/interviews/_components/ExportInterviewsDialog.tsx index df7229d89..101f1652f 100644 --- a/app/dashboard/interviews/_components/ExportInterviewsDialog.tsx +++ b/app/dashboard/interviews/_components/ExportInterviewsDialog.tsx @@ -1,58 +1,20 @@ -import type { Interview } from '~/lib/db/generated/client'; -import { DialogDescription } from '@radix-ui/react-dialog'; -import { FileWarning, Loader2, XCircle } from 'lucide-react'; -import { useState } from 'react'; -import { exportInterviews, updateExportTime } from '~/actions/interviews'; -import { deleteZipFromUploadThing } from '~/actions/uploadThing'; -import { Button } from '~/components/ui/Button'; -import { cardClasses } from '~/components/ui/card'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '~/components/ui/dialog'; -import Heading from '~/components/ui/typography/Heading'; -import { useToast } from '~/components/ui/use-toast'; -import { useDownload } from '~/hooks/useDownload'; -import useSafeLocalStorage from '~/hooks/useSafeLocalStorage'; -import trackEvent from '~/lib/analytics'; -import { ExportOptionsSchema } from '~/lib/network-exporters/utils/types'; -import { ensureError } from '~/utils/ensureError'; -import { cn } from '~/utils/shadcn'; +import { useExportProgress } from '~/components/ExportProgressProvider'; +import { Button } from '@codaco/fresco-ui/Button'; +import useSafeLocalStorage from '@codaco/fresco-ui/hooks/useSafeLocalStorage'; +import Dialog from '@codaco/fresco-ui/dialogs/Dialog'; +import { ExportOptionsSchema } from '@codaco/network-exporters/options'; import ExportOptionsView from './ExportOptionsView'; -const ExportingStateAnimation = () => { - return ( -
    -
    - - - Exporting and zipping files. Please wait... - -
    -
    - ); -}; - export const ExportInterviewsDialog = ({ open, handleCancel, - interviewsToExport, + interviewIds, }: { open: boolean; handleCancel: () => void; - interviewsToExport: Interview[]; + interviewIds: string[]; }) => { - const download = useDownload(); - const { toast } = useToast(); - const [isExporting, setIsExporting] = useState(false); + const { startExport } = useExportProgress(); const [exportOptions, setExportOptions] = useSafeLocalStorage( 'exportOptions', @@ -68,123 +30,30 @@ export const ExportInterviewsDialog = ({ }, ); - const handleConfirm = async () => { - let exportFilename = null; // Used to track the filename of the temp file uploaded to UploadThing - - // start export process - setIsExporting(true); - try { - const interviewIds = interviewsToExport.map((interview) => interview.id); - - const { zipUrl, zipKey, status, error } = await exportInterviews( - interviewIds, - exportOptions, - ); - - if (status === 'error' || !zipUrl || !zipKey) { - throw new Error(error ?? 'An error occured during export.'); - } - - exportFilename = zipKey; - - // update export time of interviews - await updateExportTime(interviewIds); - - const responseAsBlob = await fetch(zipUrl).then((res) => { - if (!res.ok) { - throw new Error('HTTP error ' + res.status); - } - return res.blob(); - }); - - // create a download link - const url = URL.createObjectURL(responseAsBlob); - - // Download the zip file - download(url, 'Network Canvas Export.zip'); - // clean up the URL object - URL.revokeObjectURL(url); - } catch (error) { - const e = ensureError(error); - - toast({ - icon: , - title: 'Error', - description: - 'Failed to export, please try again. The error was: ' + e.message, - variant: 'destructive', - }); - - void trackEvent({ - type: 'Error', - name: 'FailedToExportInterviews', - message: e.message, - stack: e.stack, - metadata: { - error: e.name, - string: e.toString(), - path: '/dashboard/interviews/_components/ExportInterviewsDialog.tsx', - }, - }); - } finally { - if (exportFilename) { - // Attempt to delete the zip file from UploadThing. - void deleteZipFromUploadThing(exportFilename).catch((error) => { - const e = ensureError(error); - void trackEvent({ - type: 'Error', - name: 'FailedToDeleteTempFile', - message: e.message, - stack: e.stack, - metadata: { - error: e.name, - string: e.toString(), - path: '/dashboard/interviews/_components/ExportInterviewsDialog.tsx', - }, - }); - - toast({ - icon: , - duration: Infinity, - variant: 'default', - title: 'Could not delete temporary file', - description: - 'We were unable to delete the temporary file containing your exported data, which is stored on your UploadThing account. Although extremely unlikely, it is possible that this file could be accessed by someone else. You can delete the file manually by visiting uploadthing.com and logging in with your GitHub account. Please use the feedback button to report this issue.', - }); - }); - } - - setIsExporting(false); - handleCancel(); // Close the dialog - } + const handleConfirm = () => { + startExport(interviewIds, exportOptions); + handleCancel(); }; return ( - <> - {isExporting && } - - - - Confirm File Export Options - - Before exporting, please confirm the export options that you wish - to use. These options are identical to those found in Interviewer. - - - - - - - - - - + + + + + } + > + + ); }; diff --git a/app/dashboard/interviews/_components/ExportOptionsView.tsx b/app/dashboard/interviews/_components/ExportOptionsView.tsx index 23f5fb163..8005b39f1 100644 --- a/app/dashboard/interviews/_components/ExportOptionsView.tsx +++ b/app/dashboard/interviews/_components/ExportOptionsView.tsx @@ -1,15 +1,13 @@ import { type Dispatch, type SetStateAction } from 'react'; -import { cardClasses } from '~/components/ui/card'; -import { Switch } from '~/components/ui/switch'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import type { ExportOptions } from '~/lib/network-exporters/utils/types'; -import { cn } from '~/utils/shadcn'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import Switch from '@codaco/fresco-ui/form/fields/ToggleField'; +import type { ExportOptions } from '@codaco/network-exporters/options'; +import { cx } from '@codaco/fresco-ui/utils/cva'; -const sectionClasses = cn( - cardClasses, - 'p-4 flex gap-4', - '[&_div]:[flex-basis:fit-content]', +const sectionClasses = cx( + 'flex gap-4 p-4', + '[&_div]:basis-[fit-content]', '[&_div:nth-child(2)]:flex [&_div:nth-child(2)]:items-center [&_div:nth-child(2)]:justify-center [&_div:nth-child(2)]:p-4', ); @@ -76,8 +74,10 @@ const ExportOptionsView = ({
    - Export GraphML Files - + + Export GraphML Files + + GraphML is the main file format used by the Network Canvas software. GraphML files can be used to manually import your data into Server, and can be opened by many other pieces of network analysis software. @@ -85,15 +85,17 @@ const ExportOptionsView = ({
    handleGraphMLSwitch(v ?? false)} />
    - Export CSV Files - + + Export CSV Files + + CSV is a widely used format for storing network data, but this wider compatibility comes at the expense of robustness. If you enable this format, your networks will be exported as an{' '} @@ -104,15 +106,17 @@ const ExportOptionsView = ({
    handleCSVSwitch(v ?? false)} />
    - Use Screen Layout Coordinates - + + Use Screen Layout Coordinates + + By default Interviewer exports sociogram node coordinates as normalized X/Y values (a number between 0 and 1 for each axis, with the origin in the top left). Enabling this option will store @@ -121,8 +125,8 @@ const ExportOptionsView = ({
    handleScreenLayoutCoordinatesSwitch(v ?? false)} />
    diff --git a/app/dashboard/interviews/_components/GenerateInterviewURLs.tsx b/app/dashboard/interviews/_components/GenerateInterviewURLs.tsx index 5b774954c..6fdd86619 100644 --- a/app/dashboard/interviews/_components/GenerateInterviewURLs.tsx +++ b/app/dashboard/interviews/_components/GenerateInterviewURLs.tsx @@ -1,122 +1,110 @@ 'use client'; import { FileUp } from 'lucide-react'; -import { use, useEffect, useState } from 'react'; -import { Button } from '~/components/ui/Button'; +import { use, useState, useTransition } from 'react'; +import superjson from 'superjson'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Button } from '@codaco/fresco-ui/Button'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '~/components/ui/dialog'; + Popover, + PopoverContent, + PopoverTrigger, +} from '@codaco/fresco-ui/Popover'; +import { Skeleton } from '@codaco/fresco-ui/Skeleton'; +import SelectField from '@codaco/fresco-ui/form/fields/Select/Native'; +import { useToast } from '@codaco/fresco-ui/Toast'; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '~/components/ui/select'; -import { Skeleton } from '~/components/ui/skeleton'; -import type { GetInterviewsReturnType } from '~/queries/interviews'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; + getIncompleteInterviewUrlData, + type IncompleteInterviewUrlData, +} from '~/actions/interviews'; +import type { + GetProtocolsQuery, + GetProtocolsReturnType, +} from '~/queries/protocols'; import ExportCSVInterviewURLs from './ExportCSVInterviewURLs'; export const GenerateInterviewURLs = ({ - interviews, protocolsPromise, + className, }: { - interviews: Awaited; protocolsPromise: GetProtocolsReturnType; + className?: string; }) => { - const protocols = use(protocolsPromise); + const rawProtocols = use(protocolsPromise); + const protocols = superjson.parse(rawProtocols); + const { add } = useToast(); const [interviewsToExport, setInterviewsToExport] = useState< - typeof interviews + IncompleteInterviewUrlData[] >([]); const [selectedProtocol, setSelectedProtocol] = useState<(typeof protocols)[number]>(); - // Only export interviews that are 1. incomplete and 2. belong to the selected protocol - useEffect(() => { - if (interviews) { - setInterviewsToExport( - interviews.filter( - (interview) => - !interview.finishTime && - selectedProtocol?.id === interview.protocolId, - ), - ); - } - }, [interviews, selectedProtocol]); + const [isLoading, startLoading] = useTransition(); - const [open, setOpen] = useState(false); + const handleSelectProtocol = (protocolId: string | number) => { + const protocol = protocols.find((p) => p.id === protocolId); + setSelectedProtocol(protocol); + setInterviewsToExport([]); - const handleOpenChange = () => { - setOpen(!open); + if (!protocol) return; + + startLoading(async () => { + const result = await getIncompleteInterviewUrlData(protocol.id); + if (result.error) { + add({ + title: 'Error', + description: result.error, + variant: 'destructive', + }); + return; + } + setInterviewsToExport(result.data); + }); }; return ( - <> - - - - - Generate Incomplete Interview URLs - - Generate a CSV that contains unique interview URLs for all{' '} - incomplete interviews by protocol. These URLs - can be shared with participants to allow them to finish their - interviews. - - -
    - {!protocols ? ( - - ) : ( - - )} -
    - - - - -
    -
    - + {!protocols ? ( + + ) : ( + ({ value: p.id, label: p.name }))} + onChange={(value) => { + if (value) handleSelectProtocol(value); + }} + value={selectedProtocol?.id} + placeholder="Select a Protocol..." + /> + )} +
    + +
    + + ); }; diff --git a/app/dashboard/interviews/loading.tsx b/app/dashboard/interviews/loading.tsx deleted file mode 100644 index 1f3fefd2b..000000000 --- a/app/dashboard/interviews/loading.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; -import Section from '~/components/layout/Section'; -import PageHeader from '~/components/ui/typography/PageHeader'; - -export default function Loading() { - return ( - <> - - - - -
    - -
    -
    - - ); -} diff --git a/app/dashboard/interviews/page.tsx b/app/dashboard/interviews/page.tsx index e5bac4325..49a23da82 100644 --- a/app/dashboard/interviews/page.tsx +++ b/app/dashboard/interviews/page.tsx @@ -1,27 +1,49 @@ -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import Section from '~/components/layout/Section'; -import PageHeader from '~/components/ui/typography/PageHeader'; +import { type SearchParams } from 'nuqs/server'; +import { Suspense } from 'react'; +import { DataTableSkeleton } from '@codaco/fresco-ui/DataTable/DataTableSkeleton'; +import ResponsiveContainer from '@codaco/fresco-ui/layout/ResponsiveContainer'; +import PageHeader from '@codaco/fresco-ui/typography/PageHeader'; +import { requirePageAuth } from '~/lib/auth/guards'; import { requireAppNotExpired } from '~/queries/appSettings'; -import { requirePageAuth } from '~/utils/auth'; +import { searchParamsCache } from '../_components/InterviewsTable/searchParams'; import InterviewsTableServer from '../_components/InterviewsTable/InterviewsTableServer'; -export default async function InterviewPage() { - await requireAppNotExpired(); - await requirePageAuth(); - +export default function InterviewPage({ + searchParams, +}: { + searchParams: Promise; +}) { return ( <> - - - - -
    - -
    + + + + } + > + + ); } + +async function AuthenticatedInterviews({ + searchParams, +}: { + searchParams: Promise; +}) { + await requireAppNotExpired(); + await requirePageAuth(); + const parsed = await searchParamsCache.parse(searchParams); + return ; +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 335a60321..0cba8745b 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -1,31 +1,43 @@ +import { type Metadata } from 'next'; +import { connection } from 'next/server'; +import { Suspense } from 'react'; import NetlifyBadge from '~/components/NetlifyBadge'; -import { getAppSetting, requireAppNotExpired } from '~/queries/appSettings'; -import { requirePageAuth } from '~/utils/auth'; +import { ExportProgressProvider } from '~/components/ExportProgressProvider'; +import { env } from '~/env'; +import { getAppSetting } from '~/queries/appSettings'; +import { getStorageProvider } from '~/queries/storageProvider'; import { NavigationBar } from './_components/NavigationBar'; import UploadThingModal from './_components/UploadThingModal'; -export const metadata = { +export const metadata: Metadata = { title: 'Network Canvas Fresco - Dashboard', description: 'Fresco.', }; -export const dynamic = 'force-dynamic'; - -const Layout = async ({ children }: { children: React.ReactNode }) => { - await requireAppNotExpired(); - await requirePageAuth(); - - const uploadThingToken = await getAppSetting('uploadThingToken'); - +const Layout = ({ children }: { children: React.ReactNode }) => { return ( - <> +
    - {!uploadThingToken && } - - {children} + + + + {children} - +
    ); }; +async function UploadThingTokenGate() { + await connection(); + const storageProvider = await getStorageProvider(); + if (storageProvider === 's3') return null; + const uploadThingToken = + env.UPLOADTHING_TOKEN ?? (await getAppSetting('uploadThingToken')); + if (!uploadThingToken) return ; + return null; +} + export default Layout; diff --git a/app/dashboard/loading.tsx b/app/dashboard/loading.tsx deleted file mode 100644 index 80702414c..000000000 --- a/app/dashboard/loading.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; -import Section from '~/components/layout/Section'; -import Heading from '~/components/ui/typography/Heading'; -import PageHeader from '~/components/ui/typography/PageHeader'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { SummaryStatisticsSkeleton } from './_components/SummaryStatistics/SummaryStatistics'; - -export default function Loading() { - return ( - <> - - - - - - Recent Activity - - This table summarizes the most recent activity within Fresco. Use it - to keep track of new protocols, interviews, and participants. - - - -
    - -
    -
    - - ); -} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index f6a26bea8..bee5a5607 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,54 +1,112 @@ +import Image from 'next/image'; +import { type SearchParams } from 'nuqs/server'; import { Suspense } from 'react'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import Section from '~/components/layout/Section'; -import Heading from '~/components/ui/typography/Heading'; -import PageHeader from '~/components/ui/typography/PageHeader'; -import Paragraph from '~/components/ui/typography/Paragraph'; +import { DataTableSkeleton } from '@codaco/fresco-ui/DataTable/DataTableSkeleton'; +import ResponsiveContainer from '@codaco/fresco-ui/layout/ResponsiveContainer'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import PageHeader from '@codaco/fresco-ui/typography/PageHeader'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { requirePageAuth } from '~/lib/auth/guards'; +import { fetchActivities } from '~/queries/activityFeed'; import { requireAppNotExpired } from '~/queries/appSettings'; -import { requirePageAuth } from '~/utils/auth'; +import { getSummaryStatistics } from '~/queries/summaryStatistics'; import ActivityFeed from './_components/ActivityFeed/ActivityFeed'; import { searchParamsCache } from './_components/ActivityFeed/SearchParams'; +import { + InterviewIcon, + ProtocolIcon, +} from './_components/SummaryStatistics/Icons'; +import { StatCardSkeleton } from './_components/SummaryStatistics/StatCard'; import SummaryStatistics from './_components/SummaryStatistics/SummaryStatistics'; import UpdateUploadThingTokenAlert from './_components/UpdateUploadThingTokenAlert'; import AnonymousRecruitmentWarning from './protocols/_components/AnonymousRecruitmentWarning'; -export default async function Home({ - searchParams, +export default function Home(props: { searchParams: Promise }) { + return ( + <> + + }> + + + + ); +} + +function DashboardContentSkeleton() { + return ( + <> + + } /> + + } + /> + } /> + + + + Recent Activity + + This table summarizes the most recent activity within Fresco. Use it + to keep track of new protocols, interviews, and participants. + + + + + + + ); +} + +async function DashboardContent({ + searchParams: searchParamsPromise, }: { - searchParams: Record; + searchParams: Promise; }) { await requireAppNotExpired(); await requirePageAuth(); - searchParamsCache.parse(searchParams); + const cache = await searchParamsCache.parse(searchParamsPromise); + + const summaryPromise = getSummaryStatistics(); + const activitiesPromise = fetchActivities(cache); return ( <> - - - - - - - - - - + + + + + + + + + - - Recent Activity + + Recent Activity This table summarizes the most recent activity within Fresco. Use it to keep track of new protocols, interviews, and participants. - -
    - -
    + + ); diff --git a/app/dashboard/participants/_components/AddParticipantButton.tsx b/app/dashboard/participants/_components/AddParticipantButton.tsx index 40f3b77fc..432ada2ce 100644 --- a/app/dashboard/participants/_components/AddParticipantButton.tsx +++ b/app/dashboard/participants/_components/AddParticipantButton.tsx @@ -1,9 +1,9 @@ -import { Button } from '~/components/ui/Button'; +import { Button } from '@codaco/fresco-ui/Button'; -import { type Participant } from '~/lib/db/generated/client'; +import { Plus } from 'lucide-react'; import { useState } from 'react'; import ParticipantModal from '~/app/dashboard/participants/_components/ParticipantModal'; -import { Plus } from 'lucide-react'; +import { type Participant } from '~/lib/db/generated/client'; type AddParticipantButtonProps = { existingParticipants: Participant[]; @@ -15,17 +15,16 @@ function AddParticipantButton({ const [isOpen, setOpen] = useState(false); return ( -
    + <> - -
    + ); } diff --git a/app/dashboard/participants/_components/DeleteParticipantsDialog.tsx b/app/dashboard/participants/_components/DeleteParticipantsDialog.tsx index 292d88a41..313ba6f49 100644 --- a/app/dashboard/participants/_components/DeleteParticipantsDialog.tsx +++ b/app/dashboard/participants/_components/DeleteParticipantsDialog.tsx @@ -1,16 +1,8 @@ -import { AlertCircle, Loader2, Trash2 } from 'lucide-react'; +import { Trash2 } from 'lucide-react'; import { useMemo, useState } from 'react'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '~/components/ui/AlertDialog'; -import { Button } from '~/components/ui/Button'; +import { Alert, AlertDescription, AlertTitle } from '@codaco/fresco-ui/Alert'; +import { Button } from '@codaco/fresco-ui/Button'; +import Dialog from '@codaco/fresco-ui/dialogs/Dialog'; type DeleteParticipantsDialog = { open: boolean; @@ -39,7 +31,6 @@ export const DeleteParticipantsDialog = ({ if (haveUnexportedInterviews) { return ( - Warning {participantCount > 1 ? ( @@ -61,19 +52,20 @@ export const DeleteParticipantsDialog = ({ } return ( - - + Warning {participantCount > 1 ? ( <> One or more of the selected participants have interview data that - will also be deleted. + will also be deleted. This data is marked as having been exported, + but you may wish to confirm this before proceeding. ) : ( <> The selected participant has interview data that will also be - deleted. + deleted. This data is marked as having been exported, but you may + wish to confirm this before proceeding. )} @@ -82,25 +74,17 @@ export const DeleteParticipantsDialog = ({ }, [haveInterviews, haveUnexportedInterviews, participantCount]); return ( - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete{' '} - - {`${participantCount} participant${ - participantCount > 1 ? 's' : '' - }`} - - . - - {dialogContent} - - - + 1 ? 's' : ''}.`} + footer={ + <> + - - - + + } + > + {dialogContent} + ); }; diff --git a/app/dashboard/participants/_components/DropzoneField.tsx b/app/dashboard/participants/_components/DropzoneField.tsx deleted file mode 100644 index d6e39d608..000000000 --- a/app/dashboard/participants/_components/DropzoneField.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { FileCheck, FileText } from 'lucide-react'; -import { useId } from 'react'; -import { useDropzone } from 'react-dropzone'; -import { useController, type Control } from 'react-hook-form'; -import { Label } from '~/components/ui/Label'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { type FormSchema } from '~/schemas/participant'; -import parseCSV from '~/utils/parseCSV'; -import { cn } from '~/utils/shadcn'; - -const accept = { - 'text/csv': [], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [], - 'application/vnd.ms-excel': [], -}; -const maxFiles = 1; -const maxSize = 1024 * 5000; // 5MB - -export default function DropzoneField({ - control, - name, - label, - hint, -}: { - control: Control; - name: 'csvFile'; - label?: string; - hint?: string; -}) { - const id = useId(); - - const controller = useController({ - name, - control, - rules: { - required: 'No CSV file selected. Please select a file.', - validate: { - hasCorrectFields: (value) => { - if (!value) { - return 'No CSV file selected. Please select a file.'; - } - - if (!Array.isArray(value)) { - return 'Invalid CSV. Please select a valid CSV file.'; - } - - // Check that every row has either a label or an identifier - const valid = value.every( - (row) => - (row.label !== undefined && row.label !== '') || row.identifier, - ); - - if (!valid) { - return 'Invalid CSV. Every row must have either a label or an identifier'; - } - }, - }, - }, - }); - - const { getRootProps, getInputProps } = useDropzone({ - accept, - multiple: false, - maxFiles, - maxSize, - onDrop: async (acceptedFiles, _fileRejections) => { - if (acceptedFiles?.length && acceptedFiles[0]) { - const csvData = await parseCSV(acceptedFiles[0]); - controller.field.onChange(csvData); - } - }, - }); - - return ( -
    - {label && ( - - )} - {hint && ( - {hint} - )} -
    - - {!controller.field.value && ( - <> - - - Drag & drop file here, or click to select. - - - )} - {controller.field.value && ( - <> - - - File selected. Click import to continue, or drop a new file here - to replace. - - - )} -
    - {controller.fieldState.error && ( - - {controller.fieldState.error.message} - - )} -
    - ); -} diff --git a/app/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs.tsx b/app/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs.tsx deleted file mode 100644 index 37d49c149..000000000 --- a/app/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import { Download } from 'lucide-react'; -import { unparse } from 'papaparse'; -import { useState } from 'react'; -import { Button } from '~/components/ui/Button'; -import { useToast } from '~/components/ui/use-toast'; -import { useDownload } from '~/hooks/useDownload'; -import type { GetParticipantsReturnType } from '~/queries/participants'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; - -function ExportCSVParticipantURLs({ - protocol, - participants, -}: { - protocol?: Awaited[0]; - participants: Awaited; -}) { - const download = useDownload(); - const [isExporting, setIsExporting] = useState(false); - const { toast } = useToast(); - - const handleExport = () => { - try { - setIsExporting(true); - if (!participants) return; - if (!protocol?.id) return; - - // CSV file format - const csvData = participants.map((participant) => ({ - id: participant.id, - identifier: participant.identifier, - interview_url: `${window.location.origin}/onboard/${protocol.id}/?participantId=${participant.id}`, - })); - - const csv = unparse(csvData, { header: true }); - - // Create a download link - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - // trigger the download - const protocolNameWithoutExtension = protocol.name.split('.')[0]; - const fileName = `participation_urls_${protocolNameWithoutExtension}.csv`; - download(url, fileName); - // Clean up the URL object - URL.revokeObjectURL(url); - toast({ - description: 'Participation URLs CSV exported successfully', - variant: 'success', - duration: 3000, - }); - } catch (error) { - toast({ - title: 'Error', - description: 'An error occurred while exporting participation URLs', - variant: 'destructive', - }); - throw new Error('An error occurred while exporting participation URLs'); - } - - setIsExporting(false); - }; - - return ( - - ); -} - -export default ExportCSVParticipantURLs; diff --git a/app/dashboard/participants/_components/ExportParticipants/ExportParticipants.tsx b/app/dashboard/participants/_components/ExportParticipants/ExportParticipants.tsx index 87cd130a0..6285431c2 100644 --- a/app/dashboard/participants/_components/ExportParticipants/ExportParticipants.tsx +++ b/app/dashboard/participants/_components/ExportParticipants/ExportParticipants.tsx @@ -1,72 +1,54 @@ 'use client'; -import { Check, FileUp } from 'lucide-react'; import { unparse } from 'papaparse'; -import { use, useState } from 'react'; -import { Button } from '~/components/ui/Button'; -import { useToast } from '~/components/ui/use-toast'; +import { useCallback } from 'react'; +import type { ParticipantExportRow } from '~/actions/participants'; +import type { ProtocolWithInterviews } from '~/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient'; +import { useToast } from '@codaco/fresco-ui/Toast'; import { useDownload } from '~/hooks/useDownload'; -import type { GetParticipantsReturnType } from '~/queries/participants'; - -function ExportParticipants({ - participantsPromise, -}: { - participantsPromise: GetParticipantsReturnType; -}) { - const participants = use(participantsPromise); +export function useExportParticipants(protocols: ProtocolWithInterviews[]) { const download = useDownload(); - const [isExporting, setIsExporting] = useState(false); - const { toast } = useToast(); - - const handleExport = () => { - try { - setIsExporting(true); - if (!participants) return; - - // CSV file format - const csvData = participants.map((participant) => ({ - identifier: participant.identifier, - label: participant.label, - })); - - const csv = unparse(csvData, { header: true }); - - // Create a download link - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - // trigger the download - download(url, 'participants.csv'); - // Clean up the URL object - URL.revokeObjectURL(url); - toast({ - title: 'Success', - icon: , - description: 'Participant CSV exported successfully', - variant: 'success', - }); - } catch (error) { - toast({ - title: 'Error', - description: 'An error occurred while exporting participants', - variant: 'destructive', - }); - throw new Error('An error occurred while exporting participants'); - } - - setIsExporting(false); - }; - - return ( - + const { add } = useToast(); + + return useCallback( + (participants: ParticipantExportRow[]) => { + try { + const csvData = participants.map((participant) => { + const row: Record = { + id: participant.id, + identifier: participant.identifier, + label: participant.label ?? '', + }; + + for (const protocol of protocols) { + const name = protocol.name.split('.')[0] ?? protocol.id; + row[`interview_url_${name}`] = + `${window.location.origin}/onboard/${protocol.id}/?participantId=${participant.id}`; + } + + return row; + }); + + const csv = unparse(csvData, { header: true }); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + download(url, 'participants.csv'); + URL.revokeObjectURL(url); + + add({ + title: 'Success', + description: 'Participants exported successfully', + variant: 'success', + }); + } catch (error) { + add({ + title: 'Error', + description: 'An error occurred while exporting participants', + variant: 'destructive', + }); + } + }, + [protocols, download, add], ); } - -export default ExportParticipants; diff --git a/app/dashboard/participants/_components/ExportParticipants/GenerateParticipantURLsButton.tsx b/app/dashboard/participants/_components/ExportParticipants/GenerateParticipantURLsButton.tsx deleted file mode 100644 index f08ccf574..000000000 --- a/app/dashboard/participants/_components/ExportParticipants/GenerateParticipantURLsButton.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client'; -import { useState, useEffect } from 'react'; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '~/components/ui/select'; - -import ExportCSVParticipantURLs from './ExportCSVParticipantURLs'; -import FancyBox from '~/components/ui/FancyBox'; -import { Button } from '~/components/ui/Button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '~/components/ui/dialog'; -import { FileUp } from 'lucide-react'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; -import type { GetParticipantsReturnType } from '~/queries/participants'; - -export const GenerateParticipantURLs = ({ - protocols, - participants, -}: { - protocols: Awaited; - participants: Awaited; -}) => { - const [selectedParticipants, setSelectedParticipants] = useState( - [], - ); - - const [selectedProtocol, setSelectedProtocol] = - useState[0]>(); - - // Default to all participants selected - useEffect(() => { - if (participants) { - setSelectedParticipants(participants.map((p) => p.id)); - } - }, [participants]); - - const [open, setOpen] = useState(false); - - const handleOpenChange = () => { - setOpen(!open); - }; - - return ( - <> - - - - - Generate Participation URLs - - Generate a CSV that contains{' '} - unique participation URLs for all participants by - protocol. These URLs can be shared with participants to allow them - to take your interview. - - -
    - - ({ - id: participant.id, - label: participant.identifier, - value: participant.id, - }))} - placeholder="Select Participants..." - singular="Participant" - plural="Participants" - value={selectedParticipants} - onValueChange={setSelectedParticipants} - /> -
    - - - participants.find((p) => p.id === id)!, - )} - /> - -
    -
    - - ); -}; diff --git a/app/dashboard/participants/_components/ExportParticipants/ImportExportSection.tsx b/app/dashboard/participants/_components/ExportParticipants/ImportExportSection.tsx deleted file mode 100644 index 1b323af4d..000000000 --- a/app/dashboard/participants/_components/ExportParticipants/ImportExportSection.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Suspense } from 'react'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import SettingsSection from '~/components/layout/SettingsSection'; -import { ButtonSkeleton } from '~/components/ui/Button'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { getParticipants } from '~/queries/participants'; -import ImportCSVModal from '../ImportCSVModal'; -import ExportParticipants from './ExportParticipants'; - -export default function ImportExportSection() { - const participantsPromise = getParticipants(); - return ( - - - - }> - - -
    - } - > - - Import or export participants in bulk using the options to the right. - Refer to our documentation for information about the formats used. - - - - ); -} diff --git a/app/dashboard/participants/_components/ImportCSVModal.tsx b/app/dashboard/participants/_components/ImportCSVModal.tsx deleted file mode 100644 index 3fd688d89..000000000 --- a/app/dashboard/participants/_components/ImportCSVModal.tsx +++ /dev/null @@ -1,178 +0,0 @@ -'use client'; - -import { AlertCircle, FileDown, Loader2 } from 'lucide-react'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { ZodError } from 'zod'; -import { importParticipants } from '~/actions/participants'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import { Button } from '~/components/ui/Button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '~/components/ui/dialog'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import UnorderedList from '~/components/ui/typography/UnorderedList'; -import { useToast } from '~/components/ui/use-toast'; -import { FormSchema } from '~/schemas/participant'; -import DropzoneField from './DropzoneField'; - -const ImportCSVModal = ({ - onImportComplete, -}: { - onImportComplete?: () => void; -}) => { - const { toast } = useToast(); - const { control, handleSubmit, reset, formState } = useForm({ - shouldUnregister: true, - mode: 'onChange', - }); - - const { isSubmitting, isValid } = formState; - - const [showImportDialog, setShowImportDialog] = useState(false); - - const onSubmit = async (data: unknown) => { - try { - const safeData = FormSchema.parse(data); - const result = await importParticipants(safeData.csvFile); - - if ( - result.existingParticipants && - result.existingParticipants.length > 0 - ) { - toast({ - title: 'Import completed with collisions', - description: ( - <> -

    - Your participants were imported successfully, but some - identifiers collided with existing participants and were not - imported. -

    - {result.existingParticipants.length < 5 && ( -
      - {result.existingParticipants.map((item) => ( -
    • {item.identifier}
    • - ))} -
    - )} - - ), - variant: 'destructive', - }); - } else { - toast({ - title: 'Participants imported', - description: 'Participants have been imported successfully', - variant: 'success', - }); - } - - onImportComplete?.(); - - reset(); - setShowImportDialog(false); - } catch (e) { - // if it's a validation error, show the error message - if (e instanceof ZodError) { - toast({ - title: 'Error', - description: e.errors[0] - ? `Invalid CSV File: ${e.errors[0].message}` - : 'Invalid CSV file. Please check the file requirements and try again.', - variant: 'destructive', - }); - return; - } - // eslint-disable-next-line no-console - console.log(e); - toast({ - title: 'Error', - description: 'An error occurred while importing participants', - variant: 'destructive', - }); - } - }; - - return ( - <> - - - - - - - Import participants - - - - CSV file requirements - - - Your CSV file can contain the following columns: - - -
  • - identifier - must be a unique string, and{' '} - should not be easy to guess. Used to - generate the onboarding URL to allow integration with - other survey tools. -
  • -
  • - label - can be any text or number. Used to provide a human - readable label for the participant. -
  • -
    - - Either an identifier column or a label column{' '} - must be provided for each participant. - - - Note: The identifier and label column headers must be - lowercase. - -
    -
    -
    -
    -
    await onSubmit(data))} - className="flex flex-col" - > - - - - - - -
    -
    - - ); -}; - -export default ImportCSVModal; diff --git a/app/dashboard/participants/_components/ImportParticipants.tsx b/app/dashboard/participants/_components/ImportParticipants.tsx new file mode 100644 index 000000000..3aa365110 --- /dev/null +++ b/app/dashboard/participants/_components/ImportParticipants.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { FileDown, Upload } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { importParticipants } from '~/actions/participants'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Button } from '@codaco/fresco-ui/Button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@codaco/fresco-ui/Popover'; +import { useToast } from '@codaco/fresco-ui/Toast'; +import { csvDataSchema } from '~/schemas/participant'; +import { cx } from '@codaco/fresco-ui/utils/cva'; +import parseCSV from '~/utils/parseCSV'; + +export default function ImportParticipants() { + const [open, setOpen] = useState(false); + const { add } = useToast(); + + const handleFilesAccepted = useCallback( + async (files: File[]) => { + const file = files[0]; + if (!file) return; + + try { + const csvData = await parseCSV(file); + const parsed = csvDataSchema.safeParse(csvData); + + if (!parsed.success) { + add({ + title: 'Error', + description: + 'File must be a valid CSV with label or identifier columns', + variant: 'destructive', + }); + return; + } + + const result = await importParticipants(parsed.data); + + if (result.error) { + add({ + title: 'Error', + description: result.error, + variant: 'destructive', + }); + return; + } + + if ( + result.existingParticipants && + result.existingParticipants.length > 0 + ) { + add({ + title: 'Import completed with collisions', + description: ( + <> +

    + Your participants were imported successfully, but some + identifiers collided with existing participants and were not + imported. +

    + {result.existingParticipants.length < 5 && ( +
      + {result.existingParticipants.map((item) => ( +
    • {item.identifier}
    • + ))} +
    + )} + + ), + variant: 'destructive', + }); + } else { + add({ + title: 'Participants imported', + description: 'Participants have been imported successfully', + variant: 'success', + }); + } + + setOpen(false); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + add({ + title: 'Error', + description: 'An error occurred while importing participants', + variant: 'destructive', + }); + } + }, + [add], + ); + + const { + getRootProps, + getInputProps, + isDragActive, + open: openFileDialog, + } = useDropzone({ + onDropAccepted: handleFilesAccepted, + accept: { + 'text/csv': [], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [], + 'application/vnd.ms-excel': [], + }, + noClick: true, + multiple: false, + maxFiles: 1, + maxSize: 1024 * 5000, + }); + + return ( + + } />}> + Import Participants + + +
    + +
    + +
    +
    + + {isDragActive ? 'Drop file here' : 'Import participants'} + + + Drag & drop a .csv file here + +
    + +
    +
    +
    + ); +} diff --git a/app/dashboard/participants/_components/ParticipantModal.tsx b/app/dashboard/participants/_components/ParticipantModal.tsx index 792ac3d12..314546fe9 100644 --- a/app/dashboard/participants/_components/ParticipantModal.tsx +++ b/app/dashboard/participants/_components/ParticipantModal.tsx @@ -1,26 +1,23 @@ 'use client'; import { createId } from '@paralleldrive/cuid2'; -import type { Participant } from '~/lib/db/generated/client'; -import { HelpCircle, Loader2 } from 'lucide-react'; +import { HelpCircle, WandSparkles } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; -import { z } from 'zod'; +import { useState, type Dispatch, type SetStateAction } from 'react'; import { createParticipant, updateParticipant } from '~/actions/participants'; import ActionError from '~/components/ActionError'; import InfoTooltip from '~/components/InfoTooltip'; -import { Button } from '~/components/ui/Button'; -import { Input } from '~/components/ui/Input'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '~/components/ui/dialog'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import useZodForm from '~/hooks/useZodForm'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Button } from '@codaco/fresco-ui/Button'; +import type { Participant } from '~/lib/db/generated/client'; +import Dialog from '@codaco/fresco-ui/dialogs/Dialog'; +import Field from '@codaco/fresco-ui/form/Field/Field'; +import { FormWithoutProvider } from '@codaco/fresco-ui/form/Form'; +import SubmitButton from '@codaco/fresco-ui/form/SubmitButton'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import useFormStore from '@codaco/fresco-ui/form/hooks/useFormStore'; +import FormStoreProvider from '@codaco/fresco-ui/form/store/formStoreProvider'; +import { z } from 'zod/mini'; import { participantIdentifierSchema, participantLabelSchema, @@ -42,172 +39,189 @@ function ParticipantModal({ existingParticipants, }: ParticipantModalProps) { const [error, setError] = useState(null); - const [working, setWorking] = useState(false); - const router = useRouter(); - const formSchema = z - .object({ - identifier: participantIdentifierSchema, - label: participantLabelSchema, - }) - .refine( - (data) => { - const existingParticipant = existingParticipants.find( - (p) => p.identifier === data.identifier, - ); - // Allow the current identifier if editing - return ( - !existingParticipant || - (existingParticipant.id === editingParticipant?.id) - ); - }, - { - path: ['identifier'], - message: 'This identifier is already in use.', - }, - ); - - type ValidationSchema = z.infer; - - const { - register, - handleSubmit, - reset, - setValue, - formState: { errors }, - } = useZodForm({ - schema: formSchema, - shouldUnregister: true, - }); - - const onSubmit = async (data: ValidationSchema) => { + + const handleSubmit = async (data: unknown) => { setError(null); - setWorking(true); + + const typedData = data as { + identifier: string; + label?: string | null; + }; if (editingParticipant) { await updateParticipant({ existingIdentifier: editingParticipant.identifier, formData: data, }); + router.refresh(); + setOpen(false); + return { success: true }; } - if (!editingParticipant) { - const result = await createParticipant([data]); + const result = await createParticipant([typedData]); - if (result.error) { - setError(result.error); - } else { - router.refresh(); - setOpen(false); - } + if (result.error) { + setError(result.error); + return { + success: false, + errors: { form: [result.error] }, + }; } - setWorking(false); + router.refresh(); + setOpen(false); + return { success: true }; }; - useEffect(() => { - if (editingParticipant) { - setValue('identifier', editingParticipant.identifier); - setValue('label', editingParticipant.label ?? ''); - } - }, [editingParticipant, setValue]); - const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen); if (!isOpen) { setEditingParticipant?.(null); setError(null); - reset(); } }; + // Use initialValues to set values when editing + const initialValues = editingParticipant + ? { + identifier: editingParticipant.identifier, + label: editingParticipant.label ?? '', + } + : undefined; + return ( - - - - - {editingParticipant ? 'Edit Participant' : 'Add Participant'} - - + + handleOpenChange(false)} + title={editingParticipant ? 'Edit Participant' : 'Add Participant'} + footer={ + <> + + + {editingParticipant ? 'Update' : 'Submit'} + + + } + > {error && (
    )} -
    await onSubmit(data))} - className="flex flex-col gap-2" + - - This could be a study ID, a number, or any other unique - identifier. It should be unique for each participant, and should - not be easy to guess{' '} - } - content={ - <> - - Participant Identifiers - - - Participant identifiers are used by Fresco to onboard - participants. They might be exposed to the participant - during this process via the participation URL, and so - must not contain any sensitive - information, and must not be easy for other participants - to guess (e.g. sequential numbers, or easily guessable - strings). - - - } - /> - . - - } - placeholder="Enter an identifier..." - error={errors.identifier?.message} - // Add an adornment to the right to allow automatically generating an ID - inputClassName="pr-28" - rightAdornment={ - - } + - - - - - - -
    -
    + + + + ); +} + +// Separate component to handle the identifier field with generate button +function IdentifierField({ + existingParticipants, + editingParticipant, + initialValue, +}: { + existingParticipants: Participant[]; + editingParticipant?: Participant | null; + initialValue?: string; +}) { + const setFieldValue = useFormStore((state) => state.setFieldValue); + + // Create validation that includes the uniqueness check + const identifierValidation = participantIdentifierSchema.check( + z.refine( + (data) => { + const existingParticipant = existingParticipants.find( + (p) => p.identifier === data, + ); + // Allow the current identifier if editing + return ( + !existingParticipant || + existingParticipant.id === editingParticipant?.id + ); + }, + { + message: 'This identifier is already in use.', + }, + ), + ); + + const hint = ( + <> + This could be a study ID, a number, or any other unique identifier. It + should be unique for each participant, and should not be easy to guess{' '} + } + title="Participant Identifiers" + description={(props) => ( + + Participant identifiers are used by Fresco to onboard participants. + They might be exposed to the participant during this process via the + participation URL, and so must not contain any + sensitive information, and must not be easy for other participants + to guess (e.g. sequential numbers, or easily guessable strings). + + )} + /> + . + + ); + + return ( + { + setFieldValue('identifier', `p-${createId()}`); + }} + icon={} + > + Generate + + } + initialValue={initialValue} + /> ); } diff --git a/app/dashboard/participants/loading.tsx b/app/dashboard/participants/loading.tsx deleted file mode 100644 index d1548c68d..000000000 --- a/app/dashboard/participants/loading.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; -import Section from '~/components/layout/Section'; -import { SettingsSectionSkeleton } from '~/components/layout/SettingsSection'; -import { ButtonSkeleton } from '~/components/ui/Button'; -import PageHeader from '~/components/ui/typography/PageHeader'; - -export default function Loading() { - return ( - <> - - - - - - - -
    - } - /> - - - -
    - -
    -
    - - ); -} diff --git a/app/dashboard/participants/page.tsx b/app/dashboard/participants/page.tsx index 8ee130ff7..de900b33c 100644 --- a/app/dashboard/participants/page.tsx +++ b/app/dashboard/participants/page.tsx @@ -1,29 +1,57 @@ +import { type SearchParams } from 'nuqs/server'; +import { Suspense } from 'react'; import ParticipantsTable from '~/app/dashboard/_components/ParticipantsTable/ParticipantsTable'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import Section from '~/components/layout/Section'; -import PageHeader from '~/components/ui/typography/PageHeader'; +import { searchParamsCache } from '~/app/dashboard/_components/ParticipantsTable/searchParams'; +import { DataTableSkeleton } from '@codaco/fresco-ui/DataTable/DataTableSkeleton'; +import ResponsiveContainer from '@codaco/fresco-ui/layout/ResponsiveContainer'; +import PageHeader from '@codaco/fresco-ui/typography/PageHeader'; +import { requirePageAuth } from '~/lib/auth/guards'; import { requireAppNotExpired } from '~/queries/appSettings'; -import { requirePageAuth } from '~/utils/auth'; -import ImportExportSection from './_components/ExportParticipants/ImportExportSection'; - -export default async function ParticipantPage() { - await requireAppNotExpired(); - await requirePageAuth(); +export default function ParticipantPage({ + searchParams, +}: { + searchParams: Promise; +}) { return ( <> - - - - - -
    - -
    -
    + + + + + } + > + + ); } + +async function AuthenticatedParticipants({ + searchParams, +}: { + searchParams: Promise; +}) { + await requireAppNotExpired(); + await requirePageAuth(); + const parsed = await searchParamsCache.parse(searchParams); + return ( + + + + ); +} diff --git a/app/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx b/app/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx index 2ff664da7..54ebe038e 100644 --- a/app/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx +++ b/app/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx @@ -1,6 +1,6 @@ -import { AlertCircle } from 'lucide-react'; +import ResponsiveContainer from '@codaco/fresco-ui/layout/ResponsiveContainer'; +import { Alert, AlertDescription, AlertTitle } from '@codaco/fresco-ui/Alert'; import Link from '~/components/Link'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; import { getAppSetting } from '~/queries/appSettings'; export default async function AnonymousRecruitmentWarning() { @@ -11,15 +11,17 @@ export default async function AnonymousRecruitmentWarning() { if (!allowAnonymousRecruitment) return null; return ( - - - Please Note - - Anonymous recruitment is enabled. This means that participants can - self-enroll in your study without needing to be invited, by visiting the - protocol-specific onboarding link. To disable anonymous recruitment, - visit the settings page. - - + + + Please Note + + Anonymous recruitment is enabled. This means that participants can + self-enroll in your study without needing to be invited, by visiting + the protocol-specific onboarding link. To disable anonymous + recruitment, visit{' '} + the settings page. + + + ); } diff --git a/app/dashboard/protocols/_components/DeleteProtocolsDialog.tsx b/app/dashboard/protocols/_components/DeleteProtocolsDialog.tsx index 5d959472f..243f06303 100644 --- a/app/dashboard/protocols/_components/DeleteProtocolsDialog.tsx +++ b/app/dashboard/protocols/_components/DeleteProtocolsDialog.tsx @@ -1,19 +1,11 @@ -import { AlertCircle, Loader2, Trash2 } from 'lucide-react'; +import { Trash2 } from 'lucide-react'; import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useState } from 'react'; import { deleteProtocols } from '~/actions/protocols'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '~/components/ui/AlertDialog'; -import { Button } from '~/components/ui/Button'; -import type { ProtocolWithInterviews } from '~/types/types'; +import { Alert, AlertDescription, AlertTitle } from '@codaco/fresco-ui/Alert'; +import { Button } from '@codaco/fresco-ui/Button'; +import Dialog from '@codaco/fresco-ui/dialogs/Dialog'; +import type { ProtocolWithInterviews } from '../../_components/ProtocolsTable/ProtocolsTableClient'; type DeleteProtocolsDialogProps = { open: boolean; @@ -62,80 +54,68 @@ export const DeleteProtocolsDialog = ({ }; return ( - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete{' '} - - {protocolsToDelete.length}{' '} - {protocolsToDelete.length > 1 ? <>protocols. : <>protocol.} - - - {protocolsInfo.hasInterviews && - !protocolsInfo.hasUnexportedInterviews && ( - - - Warning - - {protocolsToDelete.length > 1 ? ( - <> - One or more of the selected protocols have interview data - that will also be deleted. - - ) : ( - <> - The selected protocol has interview data that will also be - deleted. - - )} - - - )} - {protocolsInfo.hasUnexportedInterviews && ( - - - Warning - - {protocolsToDelete.length > 1 ? ( - <> - One or more of the selected protocols have interview data - that has not yet been exported. Deleting - these protocols will also delete its interview data. - - ) : ( - <> - The selected protocol has interview data that - has not yet been exported. Deleting this - protocol will also delete its interview data. - - )} - - - )} - - - + handleCancelDialog()} + title="Are you absolutely sure?" + description="This action cannot be undone. This will permanently delete the selected protocols." + footer={ + <> + + + } + > + {protocolsInfo.hasInterviews && + !protocolsInfo.hasUnexportedInterviews && ( + + Warning + + {protocolsToDelete.length > 1 ? ( + <> + One or more of the selected protocols have interview data that + will also be deleted. This data is marked as having been + exported, but you may wish to confirm this before proceeding. + + ) : ( + <> + The selected protocol has interview data that will also be + deleted. This data is marked as having been exported, but you + may wish to confirm this before proceeding. + + )} + + + )} + {protocolsInfo.hasUnexportedInterviews && ( + + Warning + + {protocolsToDelete.length > 1 ? ( <> - Deleting... + One or more of the selected protocols have interview data that{' '} + has not yet been exported. Deleting these + protocols will also delete its interview data. ) : ( <> - Permanently Delete + The selected protocol has interview data that + has not yet been exported. Deleting this + protocol will also delete its interview data. )} - - - - + + + )} + ); }; diff --git a/app/dashboard/protocols/loading.tsx b/app/dashboard/protocols/loading.tsx deleted file mode 100644 index c5b8fbd95..000000000 --- a/app/dashboard/protocols/loading.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; -import Section from '~/components/layout/Section'; -import PageHeader from '~/components/ui/typography/PageHeader'; - -export default function Loading() { - return ( - <> - - - - -
    - -
    -
    - - ); -} diff --git a/app/dashboard/protocols/page.tsx b/app/dashboard/protocols/page.tsx index 75ed794d5..3cf46c11f 100644 --- a/app/dashboard/protocols/page.tsx +++ b/app/dashboard/protocols/page.tsx @@ -1,31 +1,51 @@ import { Suspense } from 'react'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import Section from '~/components/layout/Section'; -import PageHeader from '~/components/ui/typography/PageHeader'; +import { DataTableSkeleton } from '@codaco/fresco-ui/DataTable/DataTableSkeleton'; +import ResponsiveContainer from '@codaco/fresco-ui/layout/ResponsiveContainer'; +import PageHeader from '@codaco/fresco-ui/typography/PageHeader'; +import { requirePageAuth } from '~/lib/auth/guards'; import { requireAppNotExpired } from '~/queries/appSettings'; -import { requirePageAuth } from '~/utils/auth'; import ProtocolsTable from '../_components/ProtocolsTable/ProtocolsTable'; import UpdateUploadThingTokenAlert from '../_components/UpdateUploadThingTokenAlert'; -export default async function ProtocolsPage() { +export default function ProtocolsPage() { + return ( + <> + + + + + } + > + + + + ); +} + +async function AuthenticatedProtocols() { await requireAppNotExpired(); await requirePageAuth(); - return ( <> - - - - - - - -
    - -
    + + + + + ); diff --git a/app/dashboard/settings/ReadOnlyEnvAlert.tsx b/app/dashboard/settings/ReadOnlyEnvAlert.tsx index 04ffda13e..7bf7ba97c 100644 --- a/app/dashboard/settings/ReadOnlyEnvAlert.tsx +++ b/app/dashboard/settings/ReadOnlyEnvAlert.tsx @@ -1,9 +1,8 @@ -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; +import { Alert, AlertDescription } from '@codaco/fresco-ui/Alert'; export default function ReadOnlyEnvAlert() { return ( - - Note: + This setting is controlled by your .env file, and so can only be changed by modifying that file. diff --git a/app/dashboard/settings/_components/ApiTokensSection.tsx b/app/dashboard/settings/_components/ApiTokensSection.tsx new file mode 100644 index 000000000..e58171acf --- /dev/null +++ b/app/dashboard/settings/_components/ApiTokensSection.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'react'; +import ApiTokenManagement from '~/components/ApiTokenManagement'; +import InterviewDataApiSwitch from '~/components/InterviewDataApiSwitch'; +import SettingsCard from '~/components/settings/SettingsCard'; +import SettingsField from '~/components/settings/SettingsField'; +import { ToggleFieldSkeleton } from '@codaco/fresco-ui/form/fields/ToggleFieldSkeleton'; +import { getApiTokens } from '~/queries/apiTokens'; + +export default function ApiTokensSection() { + const apiTokensPromise = getApiTokens(); + + return ( + + }> + + + } + /> + + + + + ); +} diff --git a/app/dashboard/settings/_components/ConfigurationSection.tsx b/app/dashboard/settings/_components/ConfigurationSection.tsx new file mode 100644 index 000000000..f72770b95 --- /dev/null +++ b/app/dashboard/settings/_components/ConfigurationSection.tsx @@ -0,0 +1,30 @@ +import { Suspense } from 'react'; +import SettingsCard from '~/components/settings/SettingsCard'; +import SettingsField from '~/components/settings/SettingsField'; +import VersionSection, { + VersionSectionSkeleton, +} from '~/components/VersionSection'; +import { env } from '~/env'; +import { getInstallationId } from '~/queries/appSettings'; +import UpdateInstallationId from './UpdateInstallationId'; + +export default async function ConfigurationSection() { + const installationId = await getInstallationId(); + + return ( + + }> + + + + + + + ); +} diff --git a/app/dashboard/settings/_components/DeveloperToolsSection.tsx b/app/dashboard/settings/_components/DeveloperToolsSection.tsx new file mode 100644 index 000000000..73d3ada4c --- /dev/null +++ b/app/dashboard/settings/_components/DeveloperToolsSection.tsx @@ -0,0 +1,22 @@ +import SettingsCard from '~/components/settings/SettingsCard'; +import SettingsField from '~/components/settings/SettingsField'; +import RecruitmentTestSectionServer from '../../_components/RecruitmentTestSectionServer'; +import ResetButton from '../../_components/ResetButton'; + +export default function DeveloperToolsSection() { + return ( + + } + /> + + + ); +} diff --git a/app/dashboard/settings/_components/InterviewSettingsSection.tsx b/app/dashboard/settings/_components/InterviewSettingsSection.tsx new file mode 100644 index 000000000..14b8f23e9 --- /dev/null +++ b/app/dashboard/settings/_components/InterviewSettingsSection.tsx @@ -0,0 +1,84 @@ +import { Suspense } from 'react'; +import AnonymousRecruitmentSwitch from '~/components/AnonymousRecruitmentSwitch'; +import FreezeInterviewsSwitch from '~/components/FreezeInterviewsSwitch'; +import LimitInterviewsSwitch from '~/components/LimitInterviewsSwitch'; +import SettingsCard from '~/components/settings/SettingsCard'; +import SettingsField from '~/components/settings/SettingsField'; +import ToggleSmallScreenWarning from '~/components/ToggleSmallScreenWarning'; +import { Alert, AlertDescription } from '@codaco/fresco-ui/Alert'; +import { ToggleFieldSkeleton } from '@codaco/fresco-ui/form/fields/ToggleFieldSkeleton'; +import { getAppSetting } from '~/queries/appSettings'; + +export default async function InterviewSettingsSection() { + const disableSmallScreenOverlay = await getAppSetting( + 'disableSmallScreenOverlay', + ); + + return ( + + }> + + + } + /> + + If this option is enabled, each participant will only be able to + submit a single completed interview for each + protocol (although they may have multiple incomplete interviews). + Once an interview has been completed, attempting to start a new + interview or to resume any other in-progress interview, will be + prevented. + + } + control={ + }> + + + } + /> + }> + + + } + /> + }> + + + } + > + {disableSmallScreenOverlay && ( + + + Ensure that you test your interview thoroughly on a small screen + when disabling this warning. Fresco is designed to work best on + larger screens, and using it on a small screen may lead to a poor + user experience for participants. + + + )} + + + ); +} diff --git a/app/dashboard/settings/_components/PasskeySettings.tsx b/app/dashboard/settings/_components/PasskeySettings.tsx new file mode 100644 index 000000000..a04c18cd7 --- /dev/null +++ b/app/dashboard/settings/_components/PasskeySettings.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { startRegistration } from '@simplewebauthn/browser'; +import { KeyRound, Plus, Trash } from 'lucide-react'; +import { useState } from 'react'; +import { + generateRegistrationOptions, + removePasskey, + verifyRegistration, +} from '~/actions/webauthn'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import SettingsField from '~/components/settings/SettingsField'; +import { Badge } from '@codaco/fresco-ui/Badge'; +import { Button } from '@codaco/fresco-ui/Button'; +import useDialog from '@codaco/fresco-ui/dialogs/useDialog'; + +type Passkey = { + id: string; + friendlyName: string | null; + deviceType: string; + createdAt: Date; + lastUsedAt: Date | null; + backedUp: boolean; +}; + +type PasskeySettingsProps = { + initialPasskeys: Passkey[]; + sandboxMode: boolean; + hasPassword: boolean; +}; + +function formatDate(date: Date | null) { + if (!date) return 'Never'; + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export default function PasskeySettings({ + initialPasskeys, + sandboxMode, + hasPassword, +}: PasskeySettingsProps) { + const [passkeys, setPasskeys] = useState(initialPasskeys); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { confirm } = useDialog(); + + const handleAddPasskey = async () => { + setError(null); + setLoading(true); + + try { + const { error: genError, data } = await generateRegistrationOptions(); + if (genError || !data) { + setError(genError ?? 'Failed to start registration'); + return; + } + + // IMMEDIATELY call startRegistration — preserves Safari user gesture + const credential = await startRegistration({ + optionsJSON: data.options, + }); + + const result = await verifyRegistration({ credential }); + if (result.error) { + setError(result.error); + return; + } + + if (result.data) { + setPasskeys((prev) => [ + { + id: result.data.id, + friendlyName: result.data.friendlyName, + deviceType: result.data.deviceType, + createdAt: result.data.createdAt, + lastUsedAt: null, + backedUp: false, + }, + ...prev, + ]); + } + } catch (e) { + if (e instanceof Error && e.name === 'NotAllowedError') { + return; + } + setError('Passkey registration failed'); + } finally { + setLoading(false); + } + }; + + const handleRemovePasskey = (passkey: Passkey) => { + void confirm({ + title: 'Remove Passkey', + description: `Remove "${passkey.friendlyName ?? 'Unnamed passkey'}"? You won't be able to sign in with it anymore.`, + confirmLabel: 'Remove', + onConfirm: async () => { + const result = await removePasskey(passkey.id); + if (result.error) { + setError(result.error); + } else { + setPasskeys((prev) => prev.filter((p) => p.id !== passkey.id)); + } + }, + }); + }; + + return ( + void handleAddPasskey()} + disabled={sandboxMode || loading} + color="primary" + icon={} + > + {loading ? 'Registering...' : 'Add passkey'} + + } + > + {error &&

    {error}

    } + + {passkeys.length > 0 && ( +
    + {passkeys.map((passkey) => ( + +
    + +
    +
    + + {passkey.friendlyName ?? 'Unnamed passkey'} + + + {passkey.deviceType === 'multiDevice' + ? 'Synced' + : 'Device-bound'} + +
    +
    + + Added {formatDate(passkey.createdAt)} + + + Last used {formatDate(passkey.lastUsedAt)} + +
    +
    +
    + +
    + ))} +
    + )} +
    + ); +} diff --git a/app/dashboard/settings/_components/PrivacySection.tsx b/app/dashboard/settings/_components/PrivacySection.tsx new file mode 100644 index 000000000..3c3bdb532 --- /dev/null +++ b/app/dashboard/settings/_components/PrivacySection.tsx @@ -0,0 +1,26 @@ +import { Suspense } from 'react'; +import DisableAnalyticsSwitch from '~/components/DisableAnalyticsSwitch'; +import SettingsCard from '~/components/settings/SettingsCard'; +import SettingsField from '~/components/settings/SettingsField'; +import { ToggleFieldSkeleton } from '@codaco/fresco-ui/form/fields/ToggleFieldSkeleton'; +import { env } from '~/env'; +import ReadOnlyEnvAlert from '../ReadOnlyEnvAlert'; + +export default function PrivacySection() { + return ( + + }> + + + } + > + {!!env.DISABLE_ANALYTICS && } + + + ); +} diff --git a/app/dashboard/settings/_components/StorageProviderSection.tsx b/app/dashboard/settings/_components/StorageProviderSection.tsx new file mode 100644 index 000000000..345290372 --- /dev/null +++ b/app/dashboard/settings/_components/StorageProviderSection.tsx @@ -0,0 +1,92 @@ +import { Alert, AlertDescription } from '@codaco/fresco-ui/Alert'; +import SettingsCard from '~/components/settings/SettingsCard'; +import SettingsField from '~/components/settings/SettingsField'; +import Link from '~/components/Link'; +import { getStorageEnvStatus } from '~/lib/storage/config'; +import { getAppSetting } from '~/queries/appSettings'; +import { getStorageProvider } from '~/queries/storageProvider'; +import UpdateUploadThingToken from './UpdateUploadThingToken'; +import UpdateS3Settings from './UpdateS3Settings'; + +export default async function StorageProviderSection() { + const [provider, s3Endpoint, s3PublicUrl, s3Bucket, s3Region] = + await Promise.all([ + getStorageProvider(), + getAppSetting('s3Endpoint'), + getAppSetting('s3PublicUrl'), + getAppSetting('s3Bucket'), + getAppSetting('s3Region'), + ]); + + const envStatus = getStorageEnvStatus(); + const providerLabel = + provider === 's3' ? 'S3 / S3-Compatible' : 'UploadThing'; + + const activeProviderEnvManaged = + provider === 's3' + ? envStatus.s3EnvManaged + : envStatus.uploadThingEnvManaged; + + if (activeProviderEnvManaged) { + return ( + + + + + Storage is configured via environment variables ( + {envStatus.setVariables.join(', ')}) and cannot be edited here. + Remove these variables to manage storage from this dashboard. + + + + + ); + } + + return ( + + + + + The storage provider type cannot be changed once the application has + been deployed. You can update the credentials below. + + + + + {provider === 'uploadthing' && ( + + The API key used to communicate with UploadThing. See the{' '} + + deployment documentation + {' '} + for details. + + } + > + + + )} + + {provider === 's3' && ( + + )} + + ); +} diff --git a/app/dashboard/settings/_components/SyntheticInterviewDataSection.tsx b/app/dashboard/settings/_components/SyntheticInterviewDataSection.tsx new file mode 100644 index 000000000..297acdda1 --- /dev/null +++ b/app/dashboard/settings/_components/SyntheticInterviewDataSection.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { use, useState } from 'react'; +import { SuperJSON } from 'superjson'; +import { + deleteSyntheticData, + revalidateSyntheticData, +} from '~/actions/synthetic-interviews'; +import SettingsCard from '~/components/settings/SettingsCard'; +import SettingsField from '~/components/settings/SettingsField'; +import { Button } from '@codaco/fresco-ui/Button'; +import ProgressBar from '@codaco/fresco-ui/ProgressBar'; +import { useToast } from '@codaco/fresco-ui/Toast'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import SelectField from '@codaco/fresco-ui/form/fields/Select/Native'; +import ToggleField from '@codaco/fresco-ui/form/fields/ToggleField'; +import { + type GetProtocolsQuery, + type GetProtocolsReturnType, +} from '~/queries/protocols'; +import { MAX_SYNTHETIC_INTERVIEWS } from '~/schemas/synthetic-interviews'; + +type SyntheticInterviewDataSectionProps = { + protocolsPromise: GetProtocolsReturnType; + initialCounts: { interviewCount: number; participantCount: number }; +}; + +export default function SyntheticInterviewDataSection({ + protocolsPromise, + initialCounts, +}: SyntheticInterviewDataSectionProps) { + const rawProtocols = use(protocolsPromise); + const protocols = SuperJSON.parse(rawProtocols); + + const [selectedProtocolId, setSelectedProtocolId] = useState(); + const [count, setCount] = useState(10); + const [simulateDropOut, setSimulateDropOut] = useState(true); + const [respectSkipLogicAndFiltering, setRespectSkipLogicAndFiltering] = + useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + const [syntheticCounts, setSyntheticCounts] = useState(initialCounts); + const { toast } = useToast(); + const router = useRouter(); + + const handleGenerate = async () => { + if (!selectedProtocolId) return; + + setIsGenerating(true); + setProgress({ current: 0, total: count }); + + try { + const response = await fetch('/api/generate-test-interviews', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + protocolId: selectedProtocolId, + count, + simulateDropOut, + respectSkipLogicAndFiltering, + }), + }); + + if (!response.ok || !response.body) { + const errorBody: unknown = await response.json().catch(() => null); + const description = + errorBody && + typeof errorBody === 'object' && + 'error' in errorBody && + typeof errorBody.error === 'string' + ? errorBody.error + : 'Could not generate synthetic interviews.'; + toast({ + title: 'Generation failed', + description, + variant: 'destructive', + }); + setIsGenerating(false); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split('\n\n'); + buffer = events.pop() ?? ''; + + for (const event of events) { + const dataLine = event + .split('\n') + .find((line) => line.startsWith('data: ')); + if (!dataLine) continue; + + const data = JSON.parse(dataLine.slice(6)) as { + type: string; + current?: number; + total?: number; + created?: number; + message?: string; + }; + + if (data.type === 'progress' && data.current !== undefined) { + setProgress({ + current: data.current, + total: data.total ?? count, + }); + } else if (data.type === 'error' && data.message) { + toast({ + title: 'Generation failed', + description: data.message, + variant: 'destructive', + }); + } else if (data.type === 'complete' && data.created !== undefined) { + const created = data.created; + setSyntheticCounts((prev) => ({ + interviewCount: prev.interviewCount + created, + participantCount: prev.participantCount + created, + })); + toast({ + title: 'Generation complete', + description: `Successfully generated ${String(created)} synthetic interviews.`, + variant: 'success', + }); + } + } + } + } finally { + setIsGenerating(false); + await revalidateSyntheticData(); + router.refresh(); + } + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + const result = await deleteSyntheticData(); + if (!result.error) { + setSyntheticCounts({ interviewCount: 0, participantCount: 0 }); + } + } finally { + setIsDeleting(false); + } + }; + + const progressPercent = + progress.total > 0 + ? Math.round((progress.current / progress.total) * 100) + : 0; + + return ( + + +
    + ({ + value: p.id, + label: p.name, + }))} + onChange={(value) => { + if (typeof value === 'string') { + setSelectedProtocolId(value); + } + }} + value={selectedProtocolId} + placeholder="Select a Protocol..." + className="min-w-auto" + /> + { + const parsed = Number(value); + if (Number.isNaN(parsed)) return; + setCount(Math.min(Math.max(parsed, 1), MAX_SYNTHETIC_INTERVIEWS)); + }} + disabled={isGenerating} + className="shrink-0" + /> + +
    + {isGenerating && ( +
    + +

    + {progress.current} / {progress.total} interviews generated +

    +
    + )} +
    + setSimulateDropOut(value ?? true)} + disabled={isGenerating} + /> + } + /> + + setRespectSkipLogicAndFiltering(value ?? false) + } + disabled={isGenerating} + /> + } + /> + + {isDeleting ? 'Deleting...' : 'Delete All'} + + } + /> +
    + ); +} diff --git a/app/dashboard/settings/_components/SyntheticInterviewDataServer.tsx b/app/dashboard/settings/_components/SyntheticInterviewDataServer.tsx new file mode 100644 index 000000000..b64d50cce --- /dev/null +++ b/app/dashboard/settings/_components/SyntheticInterviewDataServer.tsx @@ -0,0 +1,18 @@ +import { Suspense } from 'react'; +import { getProtocols } from '~/queries/protocols'; +import { getSyntheticInterviewCount } from '~/queries/synthetic-interviews'; +import SyntheticInterviewDataSection from '~/app/dashboard/settings/_components/SyntheticInterviewDataSection'; + +export default async function SyntheticInterviewDataServer() { + const protocolsPromise = getProtocols(); + const initialCounts = await getSyntheticInterviewCount(); + + return ( + + + + ); +} diff --git a/app/dashboard/settings/_components/TwoFactorSettings.tsx b/app/dashboard/settings/_components/TwoFactorSettings.tsx new file mode 100644 index 000000000..b362fdbd0 --- /dev/null +++ b/app/dashboard/settings/_components/TwoFactorSettings.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { RefreshCw } from 'lucide-react'; +import { useState } from 'react'; +import { disableTotp, regenerateRecoveryCodes } from '~/actions/totp'; +import RecoveryCodes from '~/components/RecoveryCodes'; +import SettingsField from '~/components/settings/SettingsField'; +import { useTwoFactorSetup } from '~/components/TwoFactorSetup'; +import TwoFactorVerify from '~/components/TwoFactorVerify'; +import { Alert, AlertDescription } from '@codaco/fresco-ui/Alert'; +import { Button } from '@codaco/fresco-ui/Button'; +import ToggleField from '@codaco/fresco-ui/form/fields/ToggleField'; +import Dialog from '@codaco/fresco-ui/dialogs/Dialog'; +import SubmitButton from '@codaco/fresco-ui/form/SubmitButton'; +import FormStoreProvider from '@codaco/fresco-ui/form/store/formStoreProvider'; + +type TwoFactorSettingsProps = { + hasTwoFactor: boolean; + userCount: number; + sandboxMode?: boolean; +}; + +export default function TwoFactorSettings({ + hasTwoFactor: initialHasTwoFactor, + userCount, + sandboxMode = false, +}: TwoFactorSettingsProps) { + const [hasTwoFactor, setHasTwoFactor] = useState(initialHasTwoFactor); + const [showDisable, setShowDisable] = useState(false); + const [showRegenerateVerify, setShowRegenerateVerify] = useState(false); + const [showRecoveryCodes, setShowRecoveryCodes] = useState(false); + const [recoveryCodes, setRecoveryCodes] = useState([]); + + const startTwoFactorSetup = useTwoFactorSetup(userCount); + + const handleToggle = async (checked: boolean) => { + if (checked) { + const completed = await startTwoFactorSetup(); + if (completed) { + setHasTwoFactor(true); + } + } else { + setShowDisable(true); + } + }; + + return ( + <> + void handleToggle(checked ?? false)} + disabled={sandboxMode} + aria-label="Toggle two-factor authentication" + /> + } + > + {hasTwoFactor && ( + + )} + + + + setShowDisable(false)} + title="Disable Two-Factor Authentication" + description="Enter your current authenticator code or a recovery code to disable two-factor authentication." + footer={ + <> + + + Disable + + + } + > + + + If you can't access your authenticator app, you need to use a + recovery code to disable two-factor authentication. If you + don't have any valid recovery codes, you will need another + user to disable two-factor authentication for you. + + + { + const result = await disableTotp({ code }); + if (result.error) throw new Error(result.error); + setHasTwoFactor(false); + setShowDisable(false); + }} + allowRecoveryCodes + /> + + + + + setShowRegenerateVerify(false)} + title="Regenerate Recovery Codes" + description="Enter your current authenticator code to generate new recovery codes. Your existing codes will be invalidated." + footer={ + <> + + + Regenerate + + + } + > + + + If you can't access your authenticator app, you need to + disable two-factor authentication using an existing recovery code + before you generate new codes. If you don't have any valid + recovery codes, you will need another user to disable two-factor + authentication for you. + + + { + const result = await regenerateRecoveryCodes({ code }); + if (result.error) throw new Error(result.error); + if (result.data) { + setShowRegenerateVerify(false); + setRecoveryCodes(result.data.recoveryCodes); + setShowRecoveryCodes(true); + } + }} + /> + + + + { + setShowRecoveryCodes(false); + setRecoveryCodes([]); + }} + title="New Recovery Codes" + description="Your previous recovery codes have been invalidated. Save these new codes." + footer={ + + } + > + + + + ); +} diff --git a/app/dashboard/settings/_components/UpdateInstallationId.tsx b/app/dashboard/settings/_components/UpdateInstallationId.tsx index 93d6a500c..33a29e483 100644 --- a/app/dashboard/settings/_components/UpdateInstallationId.tsx +++ b/app/dashboard/settings/_components/UpdateInstallationId.tsx @@ -1,7 +1,10 @@ 'use client'; -import { setAppSetting } from '~/actions/appSettings'; -import { appSettingsSchema } from '~/schemas/appSettings'; +import { Loader2, RefreshCw } from 'lucide-react'; +import { useState } from 'react'; +import { z } from 'zod/mini'; +import { regenerateInstallationId } from '~/actions/appSettings'; +import { Button } from '@codaco/fresco-ui/Button'; import UpdateSettingsValue from '../../_components/UpdateSettingsValue'; export default function UpdateInstallationId({ @@ -11,14 +14,41 @@ export default function UpdateInstallationId({ installationId?: string; readOnly?: boolean; }) { + const [currentId, setCurrentId] = useState(installationId); + const [isRegenerating, setIsRegenerating] = useState(false); + + const handleRegenerate = async () => { + setIsRegenerating(true); + try { + const newId = await regenerateInstallationId(); + setCurrentId(newId); + } finally { + setIsRegenerating(false); + } + }; + return ( { - await setAppSetting('installationId', value); - }} - schema={appSettingsSchema.shape.installationId} + settingsKey="installationId" + initialValue={currentId} readOnly={readOnly} + schema={z + .string() + .check(z.minLength(1, 'Installation ID cannot be empty'))} + suffixComponent={ + + } /> ); } diff --git a/app/dashboard/settings/_components/UpdateS3Settings.tsx b/app/dashboard/settings/_components/UpdateS3Settings.tsx new file mode 100644 index 000000000..6f99c39f1 --- /dev/null +++ b/app/dashboard/settings/_components/UpdateS3Settings.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState } from 'react'; +import SettingsField from '~/components/settings/SettingsField'; +import { setAppSetting } from '~/actions/appSettings'; +import { Button } from '@codaco/fresco-ui/Button'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import { type AppSetting } from '~/schemas/appSettings'; + +type S3Field = { + key: Extract; + label: string; + type: 'text' | 'password'; +}; + +const s3Fields: S3Field[] = [ + { key: 's3Endpoint', label: 'Endpoint URL', type: 'text' }, + { key: 's3PublicUrl', label: 'Public URL', type: 'text' }, + { key: 's3Bucket', label: 'Bucket Name', type: 'text' }, + { key: 's3Region', label: 'Region', type: 'text' }, + { key: 's3AccessKeyId', label: 'Access Key ID', type: 'password' }, + { key: 's3SecretAccessKey', label: 'Secret Access Key', type: 'password' }, +]; + +export default function UpdateS3Settings({ + initialValues, +}: { + initialValues: Partial>; +}) { + return ( + <> + {s3Fields.map((field) => ( + + + + ))} + + ); +} + +function S3FieldEditor({ + settingsKey, + inputType, + initialValue, +}: { + settingsKey: S3Field['key']; + inputType: 'text' | 'password'; + initialValue: string; +}) { + const [value, setValue] = useState(initialValue); + const [isSaving, setSaving] = useState(false); + const [savedValue, setSavedValue] = useState(initialValue); + const [error, setError] = useState(null); + + // Secret values are write-only: they are never sent to the client, so the + // input starts empty and saving an empty value is disallowed to prevent + // accidentally blanking the stored secret. + const isWriteOnly = inputType === 'password'; + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + await setAppSetting(settingsKey, value); + setSavedValue(value); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : 'Failed to save setting', + ); + } finally { + setSaving(false); + } + }; + + return ( +
    + setValue(v ?? '')} + type={inputType} + placeholder={isWriteOnly ? '••••••••' : undefined} + className="w-full" + disabled={isSaving} + /> + {error &&

    {error}

    } + {value !== savedValue && ( +
    + + +
    + )} +
    + ); +} diff --git a/app/dashboard/settings/_components/UpdateUploadThingToken.tsx b/app/dashboard/settings/_components/UpdateUploadThingToken.tsx index 57ee5d34c..ebe273468 100644 --- a/app/dashboard/settings/_components/UpdateUploadThingToken.tsx +++ b/app/dashboard/settings/_components/UpdateUploadThingToken.tsx @@ -1,21 +1,16 @@ 'use client'; -import { setAppSetting } from '~/actions/appSettings'; import { createUploadThingTokenSchema } from '~/schemas/appSettings'; import UpdateSettingsValue from '../../_components/UpdateSettingsValue'; -export default function UpdateUploadThingToken({ - uploadThingKey, -}: { - uploadThingKey?: string; -}) { +// The saved token is write-only: it is never sent back to the client, so the +// editor starts empty and only allows saving a new value. +export default function UpdateUploadThingToken() { return ( { - await setAppSetting('uploadThingToken', value); - }} + settingsKey="uploadThingToken" schema={createUploadThingTokenSchema} + placeholder="•••••••• (saved token is hidden)" /> ); } diff --git a/app/dashboard/settings/_components/UserManagement.tsx b/app/dashboard/settings/_components/UserManagement.tsx new file mode 100644 index 000000000..85829fffe --- /dev/null +++ b/app/dashboard/settings/_components/UserManagement.tsx @@ -0,0 +1,937 @@ +'use client'; + +import { + startAuthentication, + startRegistration, +} from '@simplewebauthn/browser'; +import { type StrictColumnDef } from '@codaco/fresco-ui/DataTable/types'; +import { Plus, Trash, User } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { use, useCallback, useState } from 'react'; +import { z } from 'zod/mini'; +import { + changePassword, + checkUsernameAvailable, + createUser, + deleteUsers, +} from '~/actions/users'; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + resetAuthForUser, + switchToPasskeyMode, + switchToPasswordMode, + verifyPasskeyReauth, +} from '~/actions/webauthn'; +import PasskeySettings from '~/app/dashboard/settings/_components/PasskeySettings'; +import TwoFactorSettings from '~/app/dashboard/settings/_components/TwoFactorSettings'; +import { DataTableColumnHeader } from '@codaco/fresco-ui/DataTable/ColumnHeader'; +import { DataTable } from '@codaco/fresco-ui/DataTable/DataTable'; +import { DataTableFloatingBar } from '@codaco/fresco-ui/DataTable/DataTableFloatingBar'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import SettingsField from '~/components/settings/SettingsField'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Alert, AlertDescription, AlertTitle } from '@codaco/fresco-ui/Alert'; +import { Button } from '@codaco/fresco-ui/Button'; +import { useClientDataTable } from '~/hooks/useClientDataTable'; +import Dialog from '@codaco/fresco-ui/dialogs/Dialog'; +import useDialog from '@codaco/fresco-ui/dialogs/useDialog'; +import Field from '@codaco/fresco-ui/form/Field/Field'; +import { FormWithoutProvider } from '@codaco/fresco-ui/form/Form'; +import SubmitButton from '@codaco/fresco-ui/form/SubmitButton'; +import Checkbox from '@codaco/fresco-ui/form/fields/Checkbox'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import PasswordField from '@codaco/fresco-ui/form/fields/PasswordField'; +import FormStoreProvider from '@codaco/fresco-ui/form/store/formStoreProvider'; +import { type FormSubmissionResult } from '@codaco/fresco-ui/form/store/types'; +import { type GetUsersReturnType } from '~/queries/users'; + +type UserRow = GetUsersReturnType[number]; + +type Passkey = { + id: string; + friendlyName: string | null; + deviceType: string; + createdAt: Date; + lastUsedAt: Date | null; + backedUp: boolean; +}; + +type UserManagementProps = { + usersPromise: Promise; + currentUserId: string; + currentUsername: string; + hasTwoFactorPromise: Promise; + passkeysPromise: Promise; + hasPasswordPromise: Promise; + sandboxMode: boolean; +}; + +const usernameSchema = z + .string() + .check(z.minLength(4, 'Username must be at least 4 characters')) + .check(z.refine((s) => !s.includes(' '), 'Username cannot contain spaces')); + +const usernameUniqueSchema = z.string().check( + z.refine(async (username) => { + if (!username || username.length < 4 || username.includes(' ')) { + return true; // Let the basic validation handle these cases + } + const result = await checkUsernameAvailable(username); + return result.available; + }, 'Username is already taken'), +); + +const passwordSchema = z + .string() + .check(z.minLength(8, 'Password must be at least 8 characters')) + .check(z.regex(/[a-z]/, 'Password must contain at least 1 lowercase letter')) + .check(z.regex(/[A-Z]/, 'Password must contain at least 1 uppercase letter')) + .check(z.regex(/[0-9]/, 'Password must contain at least 1 number')) + .check(z.regex(/[^a-zA-Z0-9]/, 'Password must contain at least 1 symbol')); + +function makeUserColumns( + currentUserId: string, + userCount: number, + onDeleteUser: (user: UserRow) => void, + onResetAuth: (user: UserRow) => void, +): StrictColumnDef[] { + return [ + { + id: 'select', + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="Select all" + /> + ), + cell: ({ row }) => { + const isCurrentUser = row.original.id === currentUserId; + return ( + row.toggleSelected(!!value)} + aria-label="Select row" + disabled={isCurrentUser} + /> + ); + }, + enableSorting: false, + enableHiding: false, + }, + { + id: 'username', + accessorKey: 'username', + sortingFn: 'text', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const isCurrentUser = row.original.id === currentUserId; + return ( +
    + {row.original.username} + {isCurrentUser && ( + (you) + )} +
    + ); + }, + }, + { + id: 'authMethod', + enableSorting: false, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const hasPasskeys = row.original.webAuthnCredentials.length > 0; + const has2FA = row.original.totpCredential?.verified === true; + + if (hasPasskeys) return 'Passkey'; + if (has2FA) return 'Password + 2FA'; + return 'Password'; + }, + }, + { + id: 'actions', + enableSorting: false, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const isCurrentUser = row.original.id === currentUserId; + const isLastUser = userCount <= 1; + const hasAuth = + row.original.totpCredential?.verified === true || + row.original.webAuthnCredentials.length > 0; + return ( +
    + {hasAuth && !isCurrentUser && ( + + )} + +
    + ); + }, + }, + ]; +} + +export default function UserManagement({ + usersPromise, + currentUserId, + currentUsername, + hasTwoFactorPromise, + passkeysPromise, + hasPasswordPromise, + sandboxMode, +}: UserManagementProps) { + // TanStack Table: consumers must also opt out so React Compiler doesn't memoize JSX that depends on the table ref. + 'use no memo'; + const router = useRouter(); + const users = use(usersPromise); + const hasTwoFactor = use(hasTwoFactorPromise); + const initialPasskeys = use(passkeysPromise); + const hasPassword = use(hasPasswordPromise); + const [isCreating, setIsCreating] = useState(false); + const [isChangingPassword, setIsChangingPassword] = useState(false); + const [passwordChangeSuccess, setPasswordChangeSuccess] = useState(false); + const [error, setError] = useState(null); + const [showSwitchToPasskey, setShowSwitchToPasskey] = useState(false); + const [showSwitchToPassword, setShowSwitchToPassword] = useState(false); + const [switchToPasswordReauthed, setSwitchToPasswordReauthed] = + useState(false); + const [switchToPasswordReauthError, setSwitchToPasswordReauthError] = + useState(null); + const [switchToPasswordReauthLoading, setSwitchToPasswordReauthLoading] = + useState(false); + + const { confirm } = useDialog(); + + const doDeleteUsers = useCallback( + async (usersToDelete: UserRow[]) => { + const ids = usersToDelete.map((u) => u.id); + const result = await deleteUsers({ ids }); + + if (result.error) { + setError(result.error); + return; + } + + router.refresh(); + }, + [router], + ); + + const handleDeleteUser = useCallback( + (user: UserRow) => { + void confirm({ + title: 'Delete User', + description: `Are you sure you want to delete the user "${user.username}"? This action cannot be undone.`, + confirmLabel: 'Delete User', + intent: 'destructive', + onConfirm: () => doDeleteUsers([user]), + }); + }, + [confirm, doDeleteUsers], + ); + + const [tempPassword, setTempPassword] = useState(null); + + const handleResetAuth = useCallback( + (user: UserRow) => { + void confirm({ + title: 'Reset Authentication', + description: `This will remove all passkeys, 2FA, and recovery codes for ${user.username}, and set a temporary password. They will need to set up their authentication again.`, + confirmLabel: 'Reset Auth', + intent: 'destructive', + onConfirm: async () => { + const result = await resetAuthForUser(user.id); + if (result.error) { + setError(result.error); + } else if (result.data?.temporaryPassword) { + setTempPassword(result.data.temporaryPassword); + } + }, + }); + }, + [confirm], + ); + + const columns = makeUserColumns( + currentUserId, + users.length, + handleDeleteUser, + handleResetAuth, + ); + + const handleDeleteSelected = useCallback( + (selectedUsers: UserRow[]) => { + const deletableUsers = selectedUsers.filter( + (user) => user.id !== currentUserId, + ); + + if (deletableUsers.length === 0) { + setError('You cannot delete your own account'); + return; + } + + const isSingle = deletableUsers.length === 1; + void confirm({ + title: isSingle ? 'Delete User' : 'Delete Multiple Users', + description: isSingle + ? `Are you sure you want to delete the user "${deletableUsers[0]?.username}"? This action cannot be undone.` + : `Are you sure you want to delete ${deletableUsers.length} users? This action cannot be undone.`, + confirmLabel: isSingle + ? 'Delete User' + : `Delete ${deletableUsers.length} Users`, + intent: 'destructive', + onConfirm: () => doDeleteUsers(deletableUsers), + }); + }, + [currentUserId, confirm, doDeleteUsers], + ); + + const { table } = useClientDataTable({ + data: users, + columns, + enablePagination: false, + enableRowSelection: (row) => row.original.id !== currentUserId, + }); + + const handleCreateUser = async ( + values: unknown, + ): Promise => { + setError(null); + + const { username, password, confirmPassword } = values as { + username: string; + password: string; + confirmPassword: string; + }; + + if (password !== confirmPassword) { + return { + success: false, + formErrors: ['Passwords do not match'], + }; + } + + const result = await createUser({ username, password, confirmPassword }); + + if (result.error) { + return { + success: false, + formErrors: [result.error], + }; + } + + setIsCreating(false); + router.refresh(); + return { success: true }; + }; + + const handleChangePassword = async ( + values: unknown, + ): Promise => { + const { currentPassword, newPassword, confirmNewPassword } = values as { + currentPassword: string; + newPassword: string; + confirmNewPassword: string; + }; + + if (newPassword !== confirmNewPassword) { + return { + success: false, + formErrors: ['New passwords do not match'], + }; + } + + const result = await changePassword({ + currentPassword, + newPassword, + confirmNewPassword, + }); + + if (result.error) { + return { + success: false, + formErrors: [result.error], + }; + } + + setPasswordChangeSuccess(true); + setTimeout(() => { + setIsChangingPassword(false); + setPasswordChangeSuccess(false); + }, 1500); + + return { success: true }; + }; + + const handleSwitchToPasskey = async ( + values: unknown, + ): Promise => { + const { currentPassword } = values as { currentPassword: string }; + + const { error: genError, data } = await generateRegistrationOptions(); + if (genError || !data) { + return { + success: false, + formErrors: [genError ?? 'Failed to start registration'], + }; + } + + let credential; + try { + credential = await startRegistration({ optionsJSON: data.options }); + } catch (e) { + if (e instanceof Error && e.name === 'NotAllowedError') { + return { success: false, formErrors: ['Passkey creation cancelled.'] }; + } + return { success: false, formErrors: ['Passkey creation failed.'] }; + } + + const result = await switchToPasskeyMode({ currentPassword, credential }); + + if (result.error) { + return { success: false, formErrors: [result.error] }; + } + + setShowSwitchToPasskey(false); + router.refresh(); + return { success: true }; + }; + + const handleSwitchToPasswordReauth = async () => { + setSwitchToPasswordReauthError(null); + setSwitchToPasswordReauthLoading(true); + + try { + const { error: genError, data: regData } = + await generateAuthenticationOptions(); + if (genError || !regData) { + setSwitchToPasswordReauthError( + genError ?? 'Failed to start verification', + ); + setSwitchToPasswordReauthLoading(false); + return; + } + + const credential = await startAuthentication({ + optionsJSON: regData.options, + }); + + const result = await verifyPasskeyReauth({ credential }); + + if (result.error) { + setSwitchToPasswordReauthError(result.error); + setSwitchToPasswordReauthLoading(false); + return; + } + + setSwitchToPasswordReauthed(true); + setSwitchToPasswordReauthLoading(false); + } catch (e) { + if (e instanceof Error && e.name === 'NotAllowedError') { + setSwitchToPasswordReauthLoading(false); + return; + } + setSwitchToPasswordReauthError('Verification failed'); + setSwitchToPasswordReauthLoading(false); + } + }; + + const handleSwitchToPassword = async ( + values: unknown, + ): Promise => { + const { newPassword, confirmNewPassword } = values as { + newPassword: string; + confirmNewPassword: string; + }; + + if (newPassword !== confirmNewPassword) { + return { + success: false, + formErrors: ['Passwords do not match'], + }; + } + + const result = await switchToPasswordMode(newPassword); + + if (result.error) { + return { success: false, formErrors: [result.error] }; + } + + setShowSwitchToPassword(false); + setSwitchToPasswordReauthed(false); + router.refresh(); + return { success: true }; + }; + + return ( +
    + +
    +
    +
    +
    + +
    +
    + + Logged in as: + + + {currentUsername} + +
    +
    + {hasPassword && !sandboxMode && ( + + )} +
    + {hasPassword && !hasTwoFactor && !sandboxMode && ( + + Security Warning + + Your account is only protected by a password. Enable two-factor + authentication for stronger security. + + + )} +
    + + {hasPassword ? ( + <> + + {!sandboxMode && ( + setShowSwitchToPasskey(true)} + size="sm" + color="destructive" + > + Switch to Passkey + + } + /> + )} + + ) : ( + <> + + {users.length === 1 && ( +
    + + + You are the only user. If you lose access to your passkey, + you will be locked out. Consider adding another user or + backing up your passkey. + + +
    + )} + {!sandboxMode && ( + setShowSwitchToPassword(true)} + size="sm" + color="destructive" + > + Switch to Password + + } + /> + )} + + )} +
    +
    +
    + All Users + +
    + + + + + } + /> +
    + + { + setIsChangingPassword(false); + setPasswordChangeSuccess(false); + }} + title="Change Password" + description="Update your account password." + footer={ + passwordChangeSuccess ? null : ( + <> + + + Update Password + + + ) + } + > + {passwordChangeSuccess ? ( +
    + Password updated successfully! +
    + ) : ( + + + + + + + )} +
    +
    + {/* Create User Dialog */} + + { + setIsCreating(false); + setError(null); + }} + title="Add User" + footer={ + <> + + Create User + + } + > + + {error && ( +
    {error}
    + )} + + + +
    +
    +
    + setTempPassword(null)} + title="Temporary Password" + description="The user's authentication has been reset. Share this temporary password with them so they can sign in and set up their account again." + footer={ + + } + > +
    + + {tempPassword} + +
    +
    + {/* Switch to Passkey Dialog */} + + setShowSwitchToPasskey(false)} + title="Switch to Passkey Authentication" + description="Enter your current password, then register a passkey. Your password and two-factor authentication will be removed." + footer={ + <> + + + Switch to Passkey + + + } + > + + + + + + + {/* Switch to Password Dialog */} + + { + setShowSwitchToPassword(false); + setSwitchToPasswordReauthed(false); + setSwitchToPasswordReauthError(null); + setSwitchToPasswordReauthLoading(false); + }} + title="Switch to Password Authentication" + description="All your passkeys will be removed and replaced with a password." + footer={ + switchToPasswordReauthed ? ( + <> + + + Switch to Password + + + ) : null + } + > + {switchToPasswordReauthed ? ( + + + + + + ) : ( +
    + + Verify your identity with a passkey to continue. + + {switchToPasswordReauthError && ( +

    + {switchToPasswordReauthError} +

    + )} + +
    + )} +
    +
    +
    + ); +} diff --git a/app/dashboard/settings/_components/UserManagementSection.tsx b/app/dashboard/settings/_components/UserManagementSection.tsx new file mode 100644 index 000000000..ac6745c54 --- /dev/null +++ b/app/dashboard/settings/_components/UserManagementSection.tsx @@ -0,0 +1,65 @@ +import SettingsCard from '~/components/settings/SettingsCard'; +import { env } from '~/env'; +import { prisma } from '~/lib/db'; +import { getUsers } from '~/queries/users'; +import UserManagement from './UserManagement'; + +async function getHasTwoFactor(userId: string) { + const result = await prisma.totpCredential.findFirst({ + where: { user_id: userId, verified: true }, + select: { id: true }, + }); + + return !!result; +} + +async function getPasskeys(userId: string) { + return prisma.webAuthnCredential.findMany({ + where: { user_id: userId }, + select: { + id: true, + friendlyName: true, + deviceType: true, + createdAt: true, + lastUsedAt: true, + backedUp: true, + }, + orderBy: { createdAt: 'desc' }, + }); +} + +async function getHasPassword(userId: string) { + const key = await prisma.key.findFirst({ + where: { user_id: userId }, + select: { hashed_password: true }, + }); + + return !!key?.hashed_password; +} + +export default function UserManagementSection({ + userId, + username, +}: { + userId: string; + username: string; +}) { + const usersPromise = getUsers(); + const hasTwoFactorPromise = getHasTwoFactor(userId); + const passkeysPromise = getPasskeys(userId); + const hasPasswordPromise = getHasPassword(userId); + + return ( + + + + ); +} diff --git a/app/dashboard/settings/loading.tsx b/app/dashboard/settings/loading.tsx deleted file mode 100644 index b8a488ac0..000000000 --- a/app/dashboard/settings/loading.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { SettingsSectionSkeleton } from '~/components/layout/SettingsSection'; -import { ButtonSkeleton } from '~/components/ui/Button'; -import { Skeleton } from '~/components/ui/skeleton'; -import { SwitchSkeleton } from '~/components/ui/switch'; -import PageHeader from '~/components/ui/typography/PageHeader'; -import { env } from '~/env'; - -export default function Loading() { - return ( - <> - - - - - - } - /> - } /> - } /> - {!env.SANDBOX_MODE && ( - - } - /> - )} - - {env.NODE_ENV === 'development' && ( - <> - } - /> - } - /> - - )} - - - ); -} diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx index 16827e8a6..be8330fcd 100644 --- a/app/dashboard/settings/page.tsx +++ b/app/dashboard/settings/page.tsx @@ -1,168 +1,119 @@ import { Suspense } from 'react'; -import AnonymousRecruitmentSwitch from '~/components/AnonymousRecruitmentSwitch'; -import DisableAnalyticsSwitch from '~/components/DisableAnalyticsSwitch'; -import SettingsSection from '~/components/layout/SettingsSection'; -import LimitInterviewsSwitch from '~/components/LimitInterviewsSwitch'; -import Link from '~/components/Link'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import ToggleSmallScreenWarning from '~/components/ToggleSmallScreenWarning'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import PageHeader from '~/components/ui/typography/PageHeader'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import VersionSection, { - VersionSectionSkeleton, -} from '~/components/VersionSection'; +import { SettingsCardSkeleton } from '~/components/settings/SettingsCard'; +import SettingsNavigation, { + type SettingsSection, +} from '~/components/settings/SettingsNavigation'; +import PageHeader from '@codaco/fresco-ui/typography/PageHeader'; import { env } from '~/env'; -import { - getAppSetting, - getInstallationId, - requireAppNotExpired, -} from '~/queries/appSettings'; -import { requirePageAuth } from '~/utils/auth'; -import AnalyticsButton from '../_components/AnalyticsButton'; -import RecruitmentTestSectionServer from '../_components/RecruitmentTestSectionServer'; -import ResetButton from '../_components/ResetButton'; -import UpdateUploadThingTokenAlert from '../_components/UpdateUploadThingTokenAlert'; -import UpdateInstallationId from './_components/UpdateInstallationId'; -import UpdateUploadThingToken from './_components/UpdateUploadThingToken'; -import ReadOnlyEnvAlert from './ReadOnlyEnvAlert'; +import { requirePageAuth } from '~/lib/auth/guards'; +import { requireAppNotExpired } from '~/queries/appSettings'; +import ApiTokensSection from './_components/ApiTokensSection'; +import ConfigurationSection from './_components/ConfigurationSection'; +import DeveloperToolsSection from './_components/DeveloperToolsSection'; +import InterviewSettingsSection from './_components/InterviewSettingsSection'; +import PrivacySection from './_components/PrivacySection'; +import StorageProviderSection from './_components/StorageProviderSection'; +import SyntheticInterviewDataServer from './_components/SyntheticInterviewDataServer'; +import UserManagementSection from './_components/UserManagementSection'; -export default async function Settings() { - await requireAppNotExpired(); - await requirePageAuth(); +function getSettingsSections(): SettingsSection[] { + const sections: SettingsSection[] = [ + { id: 'app-details', title: 'App Details' }, + { id: 'user-management', title: 'User Management' }, + { id: 'storage', title: 'Storage' }, + { id: 'interview-settings', title: 'Interview Settings' }, + { id: 'privacy', title: 'Privacy' }, + { id: 'api-tokens', title: 'API Tokens' }, + { id: 'synthetic-interview-data', title: 'Synthetic Interview Data' }, + ]; + + if (env.NODE_ENV === 'development' || !env.SANDBOX_MODE) { + sections.push({ + id: 'developer-tools', + title: 'Developer Tools', + variant: 'destructive', + }); + } + + return sections; +} - const installationId = await getInstallationId(); - const uploadThingKey = await getAppSetting('uploadThingToken'); +function SettingsContentSkeleton() { + const sections = getSettingsSections(); + + return ( +
    +
    + +
    + + + + + + + + {(env.NODE_ENV === 'development' || !env.SANDBOX_MODE) && ( + + )} +
    +
    +
    + ); +} +export default function Settings() { return ( <> - - - - - }> - - - - - This is the unique identifier for your installation of Fresco. This - ID is used to track analytics data and for other internal purposes. - - - - - - This is the API key used to communicate with the UploadThing - service. See our{' '} - - deployment documentation - {' '} - for information about how to obtain this key. - - - - - - - - } - > - - If anonymous recruitment is enabled, you may generate an anonymous - participation URL. This URL can be shared with participants to allow - them to self-enroll in your study. - - - - - - } - > - - If this option is enabled, the warning about using Fresco on a small - screen will be disabled. - - - Important - - Ensure that you test your interview thoroughly on a small screen - before disabling this warning. Fresco is designed to work best on - larger screens, and using it on a small screen may lead to a poor - user experience for participants. - - - - - - - } - > - - If this option is enabled, each participant will only be able to - submit a single completed interview for each - protocol (although they may have multiple incomplete interviews). - Once an interview has been completed, attempting to start a new - interview or to resume any other in-progress interview, will be - prevented. - - - - - - } - > - - If this option is enabled, no anonymous analytics data will be sent - to the Network Canvas team. - - {!!env.DISABLE_ANALYTICS && } - - {(env.NODE_ENV === 'development' || !env.SANDBOX_MODE) && ( - } - > - - Delete all data and reset Fresco to its default state. - - - )} - {env.NODE_ENV === 'development' && ( - // Only show the Analytics and Recruitment test sections in development - <> - } - > - - This will send a test analytics event to the Fresco analytics - server. - - - - - )} - + + }> + + ); } + +async function SettingsContent() { + await requireAppNotExpired(); + const session = await requirePageAuth(); + const sections = getSettingsSections(); + + return ( +
    +
    + +
    + }> + + + }> + + + }> + + + }> + + + + }> + + + }> + + + {(env.NODE_ENV === 'development' || !env.SANDBOX_MODE) && ( + + )} +
    +
    +
    + ); +} diff --git a/app/error.tsx b/app/error.tsx index 3c970c033..c768c037a 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -1,15 +1,13 @@ 'use client'; import { ClipboardCopy } from 'lucide-react'; -import Image from 'next/image'; -import ErrorReportNotifier from '~/components/ErrorReportNotifier'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { Button } from '~/components/ui/Button'; -import { cardClasses } from '~/components/ui/card'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { useToast } from '~/components/ui/use-toast'; -import { cn } from '~/utils/shadcn'; +import posthog from 'posthog-js'; +import { useEffect } from 'react'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Button } from '@codaco/fresco-ui/Button'; +import { useToast } from '@codaco/fresco-ui/Toast'; export default function Error({ error, @@ -19,7 +17,7 @@ export default function Error({ reset: () => void; heading?: string; }) { - const { toast } = useToast(); + const { add } = useToast(); const handleReset = () => { reset(); @@ -34,55 +32,43 @@ Stack Trace: ${error.stack}`; await navigator.clipboard.writeText(debugInfo); - toast({ + add({ title: 'Success', description: 'Debug information copied to clipboard', variant: 'success', - duration: 3000, }); }; + useEffect(() => { + posthog.captureException(error); + }, [error]); + return ( -
    - - -
    - Error robot - - Something went wrong. - -
    - +
    + + + Something went wrong. + + Fresco encountered an error while trying to load the page, and could not continue. This error has been automatically reported to us, but if you would like to provide further information that you think might be useful - please use the feedback button. You can also use the rety button to - attempt to load the page again. + please contact us. You can also use the retry button to attempt to + load the page again. -
    - -
    - +
    ); } diff --git a/app/global-error.tsx b/app/global-error.tsx index f69f4e3bf..234f4fb1f 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -2,15 +2,13 @@ import { ClipboardCopy } from 'lucide-react'; import Image from 'next/image'; -import ErrorReportNotifier from '~/components/ErrorReportNotifier'; +import posthog from 'posthog-js'; +import { useEffect, useState } from 'react'; +import Surface from '@codaco/fresco-ui/layout/Surface'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Button } from '@codaco/fresco-ui/Button'; import Link from '~/components/Link'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { Button } from '~/components/ui/Button'; -import { cardClasses } from '~/components/ui/card'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { useToast } from '~/components/ui/use-toast'; -import { cn } from '~/utils/shadcn'; export default function Error({ error, @@ -20,7 +18,7 @@ export default function Error({ reset: () => void; heading?: string; }) { - const { toast } = useToast(); + const [copied, setCopied] = useState(false); const handleReset = () => { reset(); @@ -35,24 +33,17 @@ Stack Trace: ${error.stack}`; await navigator.clipboard.writeText(debugInfo); - toast({ - title: 'Success', - description: 'Debug information copied to clipboard', - variant: 'success', - duration: 3000, - }); + setCopied(true); + setTimeout(() => setCopied(false), 2000); }; + useEffect(() => { + posthog.captureException(error); + }, [error]); + return ( -
    - - +
    +
    Error robot - + There's a problem with Fresco.
    - + Fresco encountered a serious error and is unable to continue. @@ -77,15 +68,15 @@ ${error.stack}`; .
    - -
    - +
    ); } diff --git a/app/layout.tsx b/app/layout.tsx index 075cb6a57..333d67694 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,24 +1,60 @@ -import { Quicksand } from 'next/font/google'; -import { Toaster } from '~/components/ui/toaster'; +import { type Metadata, type Viewport } from 'next'; +import { connection } from 'next/server'; +import { Suspense } from 'react'; +import Providers from '~/components/Providers'; +import { PostHogIdentify } from '~/components/Providers/PosthogIdentify'; +import { env } from '~/env'; +import { getDisableAnalytics, getInstallationId } from '~/queries/appSettings'; +import '@codaco/tailwind-config/fonts/inclusive-sans.css'; +import '@codaco/tailwind-config/fonts/nunito.css'; import '~/styles/globals.css'; -export const metadata = { +export const metadata: Metadata = { title: 'Network Canvas Fresco', description: 'Fresco.', }; -const quicksand = Quicksand({ - weight: ['300', '400', '500', '600', '700'], - subsets: ['latin', 'latin-ext'], - display: 'swap', -}); +export const viewport: Viewport = { + viewportFit: 'cover', +}; + +async function AnalyticsLoader() { + // Opt this subtree out of prerendering — getInstallationId and + // getDisableAnalytics can fall back to the database, which isn't + // available at build time (e.g. when building the distributable + // Docker image). The boundary in RootLayout lets Next + // stream this in at request time instead. + await connection(); + + try { + const [installationId, disableAnalytics] = await Promise.all([ + getInstallationId(), + getDisableAnalytics(), + ]); + + return ( + + ); + } catch { + return null; + } +} function RootLayout({ children }: { children: React.ReactNode }) { return ( - - {children} - + +
    + + + + + {children} + +
    ); diff --git a/app/not-found.tsx b/app/not-found.tsx index cffcac8d5..43f33771e 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,13 +1,13 @@ import { FileWarning } from 'lucide-react'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; export default function NotFound() { return ( -
    - - 404 - Page not found. +
    + + 404 + Page not found.
    ); } diff --git a/auth.d.ts b/auth.d.ts deleted file mode 100644 index 45e9b1630..000000000 --- a/auth.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-imports */ -/// -declare namespace Lucia { - type Auth = import('./utils/auth').Auth; - type DatabaseUserAttributes = { - username: string; - }; - // type DatabaseSessionAttributes = {}; -} diff --git a/chromatic.config.json b/chromatic.config.json new file mode 100644 index 000000000..74ff7ea6f --- /dev/null +++ b/chromatic.config.json @@ -0,0 +1,5 @@ +{ + "onlyChanged": true, + "projectId": "Project:68b1958ee9350657446b5406", + "zip": true +} diff --git a/components.json b/components.json deleted file mode 100644 index cebfc325f..000000000 --- a/components.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "tailwind.config.ts", - "css": "styles/globals.css", - "baseColor": "slate", - "cssVariables": true - }, - "aliases": { - "components": "~/components", - "utils": "~/utils/shadcn" - } -} diff --git a/components/ActionError.tsx b/components/ActionError.tsx index ca41f994c..997c82061 100644 --- a/components/ActionError.tsx +++ b/components/ActionError.tsx @@ -1,5 +1,4 @@ -import { AlertCircle } from 'lucide-react'; -import { Alert, AlertTitle, AlertDescription } from '~/components/ui/Alert'; +import { Alert, AlertDescription, AlertTitle } from '@codaco/fresco-ui/Alert'; const ActionError = ({ errorTitle, @@ -9,8 +8,7 @@ const ActionError = ({ errorDescription: string; }) => { return ( - - + {errorTitle} {errorDescription} diff --git a/components/AnonymousRecruitmentSwitchClient.tsx b/components/AnonymousRecruitmentSwitchClient.tsx deleted file mode 100644 index ec6372240..000000000 --- a/components/AnonymousRecruitmentSwitchClient.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client'; -import { setAppSetting } from '~/actions/appSettings'; -import SwitchWithOptimisticUpdate from './SwitchWithOptimisticUpdate'; - -const AnonymousRecruitmentSwitchClient = ({ - allowAnonymousRecruitment, -}: { - allowAnonymousRecruitment: boolean; -}) => { - return ( - { - await setAppSetting('allowAnonymousRecruitment', value); - return value; - }} - /> - ); -}; - -export default AnonymousRecruitmentSwitchClient; diff --git a/components/ApiTokenManagement.tsx b/components/ApiTokenManagement.tsx new file mode 100644 index 000000000..ba08d1af0 --- /dev/null +++ b/components/ApiTokenManagement.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { type Row } from '@tanstack/react-table'; +import { type StrictColumnDef } from '@codaco/fresco-ui/DataTable/types'; +import { Clipboard } from 'lucide-react'; +import { use, useState } from 'react'; +import { + createApiToken, + deleteApiToken, + updateApiToken, +} from '~/actions/apiTokens'; +import { DataTable } from '@codaco/fresco-ui/DataTable/DataTable'; +import { useClientDataTable } from '~/hooks/useClientDataTable'; +import Dialog from '@codaco/fresco-ui/dialogs/Dialog'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import { type GetApiTokensReturnType } from '~/queries/apiTokens'; +import { DataTableColumnHeader } from '@codaco/fresco-ui/DataTable/ColumnHeader'; +import { Alert, AlertDescription, AlertTitle } from '@codaco/fresco-ui/Alert'; +import { Button } from '@codaco/fresco-ui/Button'; +import { Label } from '@codaco/fresco-ui/Label'; +import ToggleField from '@codaco/fresco-ui/form/fields/ToggleField'; +import TimeAgo from '@codaco/fresco-ui/TimeAgo'; +import { useToast } from '@codaco/fresco-ui/Toast'; + +type ApiToken = GetApiTokensReturnType[number]; + +type ApiTokenManagementProps = { + tokensPromise: Promise; + disabled?: boolean; +}; + +export default function ApiTokenManagement({ + tokensPromise, + disabled, +}: ApiTokenManagementProps) { + // TanStack Table: consumers must also opt out so React Compiler doesn't memoize JSX that depends on the table ref. + 'use no memo'; + const initialTokens = use(tokensPromise); + const [tokens, setTokens] = useState(initialTokens); + const [isCreating, setIsCreating] = useState(false); + const [newTokenDescription, setNewTokenDescription] = useState(''); + const [createdToken, setCreatedToken] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [tokenToDelete, setTokenToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const { add } = useToast(); + + const handleCreateToken = async () => { + setIsLoading(true); + const result = await createApiToken({ + description: newTokenDescription || undefined, + }); + + if (result.error) { + alert(result.error); + } else if (result.data) { + setTokens([ + { + id: result.data.id, + description: result.data.description, + createdAt: result.data.createdAt, + lastUsedAt: result.data.lastUsedAt, + isActive: result.data.isActive, + }, + ...tokens, + ]); + setCreatedToken(result.data.token); + setNewTokenDescription(''); + setIsCreating(false); + } + + setIsLoading(false); + }; + + const handleToggleActive = async (id: string, isActive: boolean) => { + const result = await updateApiToken({ id, isActive: !isActive }); + + if (result.error) { + alert(result.error); + } else if (result.data) { + setTokens( + tokens.map((token) => + token.id === id ? { ...token, isActive: !isActive } : token, + ), + ); + } + }; + + const handleDeleteToken = async (token: ApiToken) => { + setIsDeleting(true); + const result = await deleteApiToken({ id: token.id }); + + if (result.error) { + add({ title: result.error, variant: 'destructive' }); + } else { + setTokens(tokens.filter((t) => t.id !== token.id)); + setTokenToDelete(null); + } + setIsDeleting(false); + }; + + const columns: StrictColumnDef[] = [ + { + accessorKey: 'description', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.description ?? Untitled} + + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'createdAt', + sortingFn: 'datetime', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'lastUsedAt', + sortingFn: 'datetime', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (!row.original.lastUsedAt) { + return 'Never'; + } + + return ( + + ); + }, + }, + { + accessorKey: 'isActive', + sortingFn: 'basic', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + handleToggleActive(row.original.id, row.original.isActive) + } + /> + ), + }, + { + id: 'actions', + enableSorting: false, + cell: ({ row }: { row: Row }) => ( + + ), + }, + ]; + + const { table } = useClientDataTable({ + data: tokens, + columns, + enablePagination: false, + }); + + return ( +
    + + + + {/* Create Token Dialog */} + setIsCreating(false)} + title="Create API Token" + description="Create a new API token for authenticating Interview Data API requests." + footer={ + <> + + + + } + > +
    + + setNewTokenDescription(value ?? '')} + /> +
    +
    + + {/* Show Created Token Dialog */} + setCreatedToken(null)} + title="API Token Created" + description="Your token has been created and is displayed below. Save this token somewhere safe now - you won't be able to see it again after you close this dialog." + footer={ + <> + + + + } + > + + Your API Token + + + {createdToken} + + + + + {/* Delete Token Confirmation Dialog */} + setTokenToDelete(null)} + title="Delete API Token" + description="Are you sure you want to delete this API token? Any applications using this token will no longer be able to authenticate." + footer={ + <> + + + + } + /> +
    + ); +} diff --git a/components/BackgroundBlobs/BackgroundBlobs.tsx b/components/BackgroundBlobs/BackgroundBlobs.tsx index 8bb243772..c6e347422 100644 --- a/components/BackgroundBlobs/BackgroundBlobs.tsx +++ b/components/BackgroundBlobs/BackgroundBlobs.tsx @@ -3,9 +3,20 @@ import * as blobs2 from 'blobs/v2'; import { interpolatePath as interpolate } from 'd3-interpolate-path'; import { memo, useMemo } from 'react'; -import { random, randomInt } from '~/utils/general'; import Canvas from './Canvas'; +const random = (a = 1, b = 0) => { + const lower = Math.min(a, b); + const upper = Math.max(a, b); + return lower + Math.random() * (upper - lower); +}; + +const randomInt = (a = 1, b = 0) => { + const lower = Math.ceil(Math.min(a, b)); + const upper = Math.floor(Math.max(a, b)); + return Math.floor(lower + Math.random() * (upper - lower + 1)); +}; + const gradients = [ ['rgb(237,0,140)', 'rgb(226,33,91)'], ['#00c9ff', '#92fe9d'], diff --git a/components/BackgroundBlobs/Canvas.tsx b/components/BackgroundBlobs/Canvas.tsx index 4a4618b4e..35b6c7c04 100644 --- a/components/BackgroundBlobs/Canvas.tsx +++ b/components/BackgroundBlobs/Canvas.tsx @@ -1,7 +1,6 @@ -"use client"; +'use client'; -import React from "react"; -import useCanvas from "~/hooks/useCanvas"; +import useCanvas from '~/hooks/useCanvas'; type CanvasProps = { draw: (ctx: CanvasRenderingContext2D, time: number) => void; @@ -13,7 +12,7 @@ const Canvas = (props: CanvasProps) => { const { draw, predraw, postdraw } = props; const canvasRef = useCanvas(draw, predraw, postdraw); - return ; + return ; }; export default Canvas; diff --git a/components/ContainerClasses.ts b/components/ContainerClasses.ts index 057b65b76..28a4e2343 100644 --- a/components/ContainerClasses.ts +++ b/components/ContainerClasses.ts @@ -1,6 +1,6 @@ -import { cn } from '~/utils/shadcn'; +import { cx } from '@codaco/fresco-ui/utils/cva'; -export const containerClasses = cn( - 'relative mt-[-60px] flex flex-col rounded-xl min-w-full-[30rem] bg-card p-8', - 'after:absolute after:inset-[-20px] after:z-[-1] after:rounded-3xl after:bg-panel/30 after:shadow-2xl after:backdrop-blur-sm', +export const containerClasses = cx( + 'relative m-6! overflow-visible', + 'before:bg-surface-1/30 mx-0 before:absolute before:inset-[-20px] before:z-[-1] before:rounded before:shadow-2xl before:backdrop-blur-sm', ); diff --git a/components/CopyDebugInfoButton.tsx b/components/CopyDebugInfoButton.tsx deleted file mode 100644 index 1d62d3a2f..000000000 --- a/components/CopyDebugInfoButton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; - -import { cn } from '~/utils/shadcn'; -import { useToast } from './ui/use-toast'; -import { Check, ClipboardCopy } from 'lucide-react'; -import { Button } from './ui/Button'; - -export default function CopyDebugInfoButton({ - debugInfo, - showToast = true, - className, -}: { - debugInfo: string; - showToast?: boolean; - className?: string; -}) { - const { toast } = useToast(); - - const copyDebugInfoToClipboard = async () => { - await navigator.clipboard.writeText(debugInfo); - - if (showToast) { - toast({ - icon: , - title: 'Success', - description: 'Debug information copied to clipboard', - variant: 'success', - }); - } - }; - - return ( - - ); -} diff --git a/components/DataTable/ColumnHeader.tsx b/components/DataTable/ColumnHeader.tsx deleted file mode 100644 index b81749d0f..000000000 --- a/components/DataTable/ColumnHeader.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; -import { type Column } from '@tanstack/react-table'; - -import { Button, buttonVariants } from '~/components/ui/Button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '~/components/ui/dropdown-menu'; -import { cn } from '~/utils/shadcn'; - -type DataTableColumnHeaderProps = { - column: Column; - title: string; -} & React.HTMLAttributes; - -export function DataTableColumnHeader({ - column, - title, - className, -}: DataTableColumnHeaderProps) { - if (!column.getCanSort()) { - return ( -
    - {title} -
    - ); - } - - return ( -
    - - - - - - column.toggleSorting(false)} - > - - Asc - - column.toggleSorting(true)} - > - - Desc - - {/* column.toggleVisibility(false)} - > - */} - - -
    - ); -} diff --git a/components/DataTable/DataTable.tsx b/components/DataTable/DataTable.tsx deleted file mode 100644 index 41b0367c6..000000000 --- a/components/DataTable/DataTable.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import { - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, - type ColumnDef, - type ColumnFiltersState, - type Row, - type SortingState, - type Table as TTable, -} from '@tanstack/react-table'; -import { FileUp, Loader } from 'lucide-react'; -import { useCallback, useState } from 'react'; -import { makeDefaultColumns } from '~/components/DataTable/DefaultColumns'; -import { Button } from '~/components/ui/Button'; -import { Input } from '~/components/ui/Input'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '~/components/ui/table'; - -type CustomTable = TTable & { - options?: { - meta?: { - getRowClasses?: (row: Row) => string | undefined; - navigatorLanguages?: string[]; - }; - }; -}; - -type DataTableProps = { - columns?: ColumnDef[]; - data: TData[]; - filterColumnAccessorKey?: string; - handleDeleteSelected?: (data: TData[]) => Promise | void; - handleExportSelected?: (data: TData[]) => void; - actions?: React.ComponentType<{ - row: Row; - data: TData[]; - deleteHandler: (item: TData) => void; - }>; - actionsHeader?: React.ReactNode; - calculateRowClasses?: (row: Row) => string | undefined; - headerItems?: React.ReactNode; - defaultSortBy?: SortingState[0]; -}; - -export function DataTable({ - columns = [], - data, - handleDeleteSelected, - handleExportSelected, - filterColumnAccessorKey = '', - actions, - actionsHeader, - calculateRowClasses, - headerItems, - defaultSortBy, -}: DataTableProps) { - const [sorting, setSorting] = useState( - defaultSortBy ? [{ ...defaultSortBy }] : [], - ); - const [isDeleting, setIsDeleting] = useState(false); - const [rowSelection, setRowSelection] = useState({}); - const [columnFilters, setColumnFilters] = useState([]); - - if (columns.length === 0) { - columns = makeDefaultColumns(data); - } - - const deleteHandler = async () => { - setIsDeleting(true); - const selectedData = table - .getSelectedRowModel() - .rows.map((r) => r.original); - - try { - await handleDeleteSelected?.(selectedData); - } catch (error) { - if (error instanceof Error) { - throw new Error(error.message); - } - throw new Error('An unknown error occurred.'); - } - - setIsDeleting(false); - setRowSelection({}); - }; - - if (actions) { - const actionsColumn = { - id: 'actions', - header: () => actionsHeader ?? null, - cell: ({ row }: { row: Row }) => { - const cellDeleteHandler = async (item: TData) => { - await handleDeleteSelected?.([item]); - }; - - return flexRender(actions, { - row, - data, - deleteHandler: cellDeleteHandler, - }); - }, - }; - - columns = [...columns, actionsColumn]; - } - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - onRowSelectionChange: setRowSelection, - onColumnFiltersChange: setColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - meta: { - getRowClasses: (row: Row) => calculateRowClasses?.(row), - }, - state: { - sorting, - rowSelection, - columnFilters, - }, - }) as CustomTable; - - const hasSelectedRows = table.getSelectedRowModel().rows.length > 0; - - const exportHandler = useCallback(() => { - const selectedData = table - .getSelectedRowModel() - .rows.map((r) => r.original); - - handleExportSelected?.(selectedData); - - setRowSelection({}); - }, [handleExportSelected, table, setRowSelection]); - - return ( - <> - {(filterColumnAccessorKey || headerItems) && ( -
    - {filterColumnAccessorKey && ( - - table - .getColumn(filterColumnAccessorKey) - ?.setFilterValue(event.target.value) - } - className="mt-0" - /> - )} - {headerItems} -
    - )} -
    - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
    -
    -
    -
    -
    - {table.getFilteredSelectedRowModel().rows.length} of{' '} - {table.getFilteredRowModel().rows.length} row(s) selected. -
    -
    - - -
    -
    - {/** - * TODO: This is garbage. - * - * This shouldn't be part of the data table - it should be a component - * that is passed in to the table that gets given access to the table - * state. See the other data-table for an example. - */} - {hasSelectedRows && ( - - )} - - {hasSelectedRows && handleExportSelected && ( - - )} -
    - - ); -} diff --git a/components/DataTable/DefaultColumns.tsx b/components/DataTable/DefaultColumns.tsx deleted file mode 100644 index 2d8072768..000000000 --- a/components/DataTable/DefaultColumns.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export const makeDefaultColumns = (data: TData[]) => { - const firstRow = data[0]; - - if (!firstRow || typeof firstRow !== 'object') { - throw new Error('Data must be an array of objects.'); - } - - const columnKeys = Object.keys(firstRow); - - const columns = columnKeys.map((key) => { - return { - accessorKey: key, - header: key, - }; - }); - - return columns; -}; diff --git a/components/DataTable/nuqs/NuqsClearFilters.tsx b/components/DataTable/nuqs/NuqsClearFilters.tsx new file mode 100644 index 000000000..c8287af26 --- /dev/null +++ b/components/DataTable/nuqs/NuqsClearFilters.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { X } from 'lucide-react'; +import { parseAsString, useQueryStates } from 'nuqs'; +import { useMemo } from 'react'; +import { Button } from '@codaco/fresco-ui/Button'; +import { nuqsTableUrlKey, useNuqsTable } from './NuqsTableProvider'; + +type NuqsClearFiltersProps = { + /** + * Logical param names (unprefixed) to clear. The provider's `prefix` will + * be applied to derive the URL keys. + */ + paramKeys: readonly string[]; + label?: string; +}; + +/** + * URL-backed "clear all filters" button for server-fetched tables. + * + * Hidden when none of the tracked params are set. Uses a string parser for + * all keys because the only thing we need to know is presence — the actual + * parsing for each key lives in its dedicated filter component. + */ +export default function NuqsClearFilters({ + paramKeys, + label = 'Clear Filters', +}: NuqsClearFiltersProps) { + const { prefix, startTransition } = useNuqsTable(); + + const parsers = useMemo(() => { + const entries = paramKeys.map( + (key) => [key, parseAsString.withOptions({ clearOnDefault: true })] as const, + ); + return Object.fromEntries(entries); + }, [paramKeys]); + + const urlKeys = useMemo(() => { + const entries = paramKeys.map( + (key) => [key, nuqsTableUrlKey(prefix, key)] as const, + ); + return Object.fromEntries(entries); + }, [paramKeys, prefix]); + + const [values, setValues] = useQueryStates(parsers, { + urlKeys, + shallow: false, + startTransition, + }); + + const hasAnyFilter = Object.values(values).some( + (v) => v !== null && v !== '', + ); + if (!hasAnyFilter) return null; + + const cleared = Object.fromEntries(paramKeys.map((k) => [k, null])) as Record< + (typeof paramKeys)[number], + null + >; + + return ( + + ); +} diff --git a/components/DataTable/nuqs/NuqsFacetedFilter.tsx b/components/DataTable/nuqs/NuqsFacetedFilter.tsx new file mode 100644 index 000000000..8c59492a1 --- /dev/null +++ b/components/DataTable/nuqs/NuqsFacetedFilter.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { + parseAsArrayOf, + parseAsStringLiteral, + useQueryState, +} from 'nuqs'; +import { useMemo } from 'react'; +import ComboboxField from '@codaco/fresco-ui/form/fields/Combobox/Combobox'; +import { nuqsTableUrlKey, useNuqsTable } from './NuqsTableProvider'; + +type NuqsFacetedFilterProps = { + /** Logical param name (unprefixed). The provider's `prefix` will be applied. */ + paramKey: string; + /** Whitelist of values this filter accepts. Used for URL parsing + options. */ + values: readonly T[]; + /** Visible label for each option. Defaults to the value itself. */ + getLabel?: (value: T) => string; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; +}; + +/** + * URL-backed multi-select filter for server-fetched tables. + * + * Values are parsed via `parseAsStringLiteral(values)` so unknown URL values + * are rejected. Writes go through the provider's `startTransition`, so the + * table body fades concurrently without unmounting. + */ +export default function NuqsFacetedFilter({ + paramKey, + values, + getLabel = (v) => v, + placeholder = 'Filter...', + searchPlaceholder = 'Search...', + emptyMessage = 'No options found.', + className, +}: NuqsFacetedFilterProps) { + const { prefix, startTransition } = useNuqsTable(); + const urlKey = nuqsTableUrlKey(prefix, paramKey); + + const [selected, setSelected] = useQueryState( + urlKey, + parseAsArrayOf(parseAsStringLiteral(values)).withOptions({ + shallow: false, + clearOnDefault: true, + startTransition, + }), + ); + + const options = useMemo( + () => values.map((v) => ({ value: v, label: getLabel(v) })), + [values, getLabel], + ); + + return ( + { + const next = (newValues as T[] | undefined) ?? []; + void setSelected(next.length > 0 ? next : null); + }} + showSelectAll + showDeselectAll + className={className ?? 'w-auto shrink-0'} + /> + ); +} diff --git a/components/DataTable/nuqs/NuqsSearchFilter.tsx b/components/DataTable/nuqs/NuqsSearchFilter.tsx new file mode 100644 index 000000000..6ae8a2cbc --- /dev/null +++ b/components/DataTable/nuqs/NuqsSearchFilter.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { debounce } from 'es-toolkit'; +import { Search } from 'lucide-react'; +import { parseAsString, useQueryState } from 'nuqs'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import InputField from '@codaco/fresco-ui/form/fields/InputField'; +import { nuqsTableUrlKey, useNuqsTable } from './NuqsTableProvider'; + +type NuqsSearchFilterProps = { + /** Logical param name (unprefixed). The provider's `prefix` will be applied. */ + paramKey: string; + placeholder?: string; + className?: string; + /** Delay between last keystroke and URL commit. Defaults to 300 ms. */ + debounceMs?: number; +}; + +/** + * URL-backed text filter for server-fetched tables. + * + * Holds a transient local buffer for instant keystroke feedback while the + * actual URL write is debounced and routed through the provider's + * `startTransition` — so the table body fades but the input never unmounts + * or loses focus, and concurrent typing isn't dropped. + */ +export default function NuqsSearchFilter({ + paramKey, + placeholder = 'Filter...', + className, + debounceMs = 300, +}: NuqsSearchFilterProps) { + const { prefix, startTransition } = useNuqsTable(); + const urlKey = nuqsTableUrlKey(prefix, paramKey); + + const [value, setValue] = useQueryState( + urlKey, + parseAsString.withOptions({ + shallow: false, + clearOnDefault: true, + startTransition, + }), + ); + + const [local, setLocal] = useState(value ?? ''); + + // Tracks the last value this component wrote to the URL. Distinguishes + // "my own debounced commit arrived back through nuqs" from "URL changed + // externally" (clear button, browser back, navigation), so the external + // case can cancel any in-flight debounce and adopt the new value without + // being clobbered when the pending commit fires. + const lastWrittenRef = useRef(value); + + const debouncedCommit = useMemo( + () => + debounce( + (next: string | null) => { + lastWrittenRef.current = next; + void setValue(next); + }, + debounceMs, + { edges: ['trailing'] }, + ), + [setValue, debounceMs], + ); + + useEffect(() => () => debouncedCommit.cancel(), [debouncedCommit]); + + useEffect(() => { + if (value !== lastWrittenRef.current) { + debouncedCommit.cancel(); + lastWrittenRef.current = value; + setLocal(value ?? ''); + } + }, [value, debouncedCommit]); + + return ( + } + name={urlKey} + className={className} + placeholder={placeholder} + value={local} + onChange={(next) => { + const v = next ?? ''; + setLocal(v); + debouncedCommit(v.length > 0 ? v : null); + }} + /> + ); +} diff --git a/components/DataTable/nuqs/NuqsTableProvider.tsx b/components/DataTable/nuqs/NuqsTableProvider.tsx new file mode 100644 index 000000000..ef59c4389 --- /dev/null +++ b/components/DataTable/nuqs/NuqsTableProvider.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { + createContext, + useContext, + useMemo, + useTransition, + type ReactNode, + type TransitionStartFunction, +} from 'react'; + +/** + * Shared context for URL-driven server-fetched tables. + * + * Provides a single `useTransition` so every filter / pagination / sort + * control that writes to the URL funnels through one concurrent render, + * letting the table body fade consistently while fresh data is fetched. + * + * Tables that want URL param namespacing (so multiple instances can coexist + * on the same page) pass a `prefix` — filter components then map their + * logical param name (`q`, `type`, `page`, …) to `${prefix}_${name}` in the + * URL, while programmatic state keeps the short name. + */ +type NuqsTableContextValue = { + prefix: string; + isPending: boolean; + startTransition: TransitionStartFunction; +}; + +const NuqsTableContext = createContext(null); + +export function useNuqsTable(): NuqsTableContextValue { + const ctx = useContext(NuqsTableContext); + if (!ctx) { + throw new Error('useNuqsTable must be used within a '); + } + return ctx; +} + +/** + * Resolve a logical param name to its URL key given a namespace prefix. + * Exported so row components that read state directly with nuqs hooks can + * stay in sync with the namespace their parent provider uses. + */ +export function nuqsTableUrlKey(prefix: string, paramKey: string): string { + return prefix ? `${prefix}_${paramKey}` : paramKey; +} + +type NuqsTableProviderProps = { + prefix?: string; + children: ReactNode; +}; + +export function NuqsTableProvider({ + prefix = '', + children, +}: NuqsTableProviderProps) { + const [isPending, startTransition] = useTransition(); + const value = useMemo( + () => ({ prefix, isPending, startTransition }), + [prefix, isPending], + ); + return ( + + {children} + + ); +} diff --git a/components/ErrorDetails.tsx b/components/ErrorDetails.tsx deleted file mode 100644 index bcbae1e9a..000000000 --- a/components/ErrorDetails.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { type ReactNode, useState } from 'react'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from './ui/collapsible'; -import Heading from './ui/typography/Heading'; -import { ChevronDown, ChevronUp } from 'lucide-react'; -import CopyDebugInfoButton from './CopyDebugInfoButton'; - -export const ErrorDetails = ({ - errorText, - children, -}: { - errorText: string; - children: ReactNode; -}) => { - const [showStackTrace, setShowStackTrace] = useState(false); - - return ( - - - - {showStackTrace ? 'Hide' : 'Show'} debug information - - {showStackTrace ? ( - - ) : ( - - )} - - - {children} - - - - ); -}; diff --git a/components/ErrorReportNotifier.tsx b/components/ErrorReportNotifier.tsx deleted file mode 100644 index 7b352618d..000000000 --- a/components/ErrorReportNotifier.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { CheckIcon, Loader2, XCircle } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import { useEffect, useRef, useState } from 'react'; -import trackEvent from '~/lib/analytics'; - -const labelAnimationVariants = { - hidden: { opacity: 0, y: '-100%' }, - visible: { opacity: 1, y: 0 }, - exit: { opacity: 0, y: '100%' }, -}; - -type ReportStates = 'idle' | 'loading' | 'success' | 'error'; - -function ReportNotifier({ state = 'idle' }: { state?: ReportStates }) { - return ( -
    - - {state === 'loading' && ( - - - Sending analytics data... - - )} - {state === 'success' && ( - - - Sent analytics data! - - )} - {state === 'error' && ( - - - Error sending analytics data. - - )} - -
    - ); -} - -export default function ErrorReportNotifier({ error }: { error: Error }) { - const initialized = useRef(false); - const [state, setState] = useState('idle'); - - useEffect(() => { - if (initialized.current) return; - setState('loading'); - - trackEvent({ - type: 'Error', - name: error.name, - message: error.message, - stack: error.stack, - metadata: { - path: window?.location?.pathname ?? 'unknown', - userAgent: window?.navigator?.userAgent ?? 'unknown', - }, - }) - .then((result) => { - if (!result.success) { - setState('error'); - return; - } - - setState('success'); - }) - .catch(() => { - setState('error'); - }); - initialized.current = true; - }, [error]); - - return ; -} diff --git a/components/ExportProgress/ExportToastContent.tsx b/components/ExportProgress/ExportToastContent.tsx new file mode 100644 index 000000000..620dccf06 --- /dev/null +++ b/components/ExportProgress/ExportToastContent.tsx @@ -0,0 +1,67 @@ +'use client'; + +import Button from '@codaco/fresco-ui/Button'; +import ProgressBar from '@codaco/fresco-ui/ProgressBar'; +import { Database, FileSearch, FileUp, Package, X } from 'lucide-react'; + +type ExportStage = 'fetching' | 'formatting' | 'generating' | 'outputting'; + +const stageConfig: Record< + ExportStage, + { label: string; icon: React.ElementType } +> = { + fetching: { label: 'Fetching interviews...', icon: FileSearch }, + formatting: { label: 'Formatting...', icon: Package }, + generating: { label: 'Generating networks...', icon: Database }, + outputting: { label: 'Writing files...', icon: FileUp }, +}; + +type ExportToastContentProps = { + stage: ExportStage; + progress: number; + current?: number; + total?: number; + onCancel: () => void; +}; + +export default function ExportToastContent({ + stage, + progress, + current, + total, + onCancel, +}: ExportToastContentProps) { + const config = stageConfig[stage]; + const Icon = config.icon; + + return ( +
    +
    + + {config.label} + {total ? ( + + {current} / {total} + + ) : null} +
    +
    + + {Math.round(progress)}% +
    + +
    + ); +} diff --git a/components/ExportProgressProvider.tsx b/components/ExportProgressProvider.tsx new file mode 100644 index 000000000..0de3c4f3a --- /dev/null +++ b/components/ExportProgressProvider.tsx @@ -0,0 +1,154 @@ +'use client'; + +import posthog from 'posthog-js'; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, +} from 'react'; +import { useToast } from '@codaco/fresco-ui/Toast'; +import type { ExportOptions } from '@codaco/network-exporters/options'; +import { commitInterviewExport } from '~/actions/interviews'; +import ExportToastContent from '~/components/ExportProgress/ExportToastContent'; +import { useDownload } from '~/hooks/useDownload'; +import { runBatchedExport } from '~/lib/export/runBatchedExport'; +import { ensureError } from '~/utils/ensureError'; + +type ExportContextValue = { + startExport: (interviewIds: string[], exportOptions: ExportOptions) => void; +}; + +const ExportContext = createContext(null); + +export function useExportProgress() { + const ctx = useContext(ExportContext); + if (!ctx) { + throw new Error( + 'useExportProgress must be used within ExportProgressProvider', + ); + } + return ctx; +} + +export function ExportProgressProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { add, update, close } = useToast(); + const download = useDownload(); + + // Tracks whether an export is in flight, so the beforeunload warning can + // reflect it without re-registering the listener per render. + const exportingRef = useRef(false); + + useEffect(() => { + const handler = (event: BeforeUnloadEvent) => { + if (!exportingRef.current) return; + event.preventDefault(); + event.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, []); + + const startExport = useCallback( + (interviewIds: string[], exportOptions: ExportOptions) => { + const controller = new AbortController(); + exportingRef.current = true; + + const toastId = add({ + title: 'Exporting interviews', + description: ( + controller.abort()} + /> + ), + timeout: 0, + }); + + void (async () => { + try { + const { blob, exportedIds, failedIds } = await runBatchedExport( + interviewIds, + exportOptions, + controller.signal, + (completed, total) => { + update(toastId, { + description: ( + 0 ? (completed / total) * 100 : 0} + onCancel={() => controller.abort()} + /> + ), + }); + }, + ); + + const date = new Date().toISOString().slice(0, 10); + const objectUrl = URL.createObjectURL(blob); + download(objectUrl, `fresco-export-${date}.zip`); + setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000); + + // Mark exported only after the user has the complete file. + const commit = await commitInterviewExport(exportedIds); + + close(toastId); + if (commit.error) { + add({ + title: 'Export downloaded', + description: + 'Your export downloaded, but its status could not be updated. Refresh to see the latest.', + timeout: 8000, + }); + } else { + add({ + title: 'Export complete', + description: + failedIds.length > 0 + ? `Your export has downloaded. ${String(failedIds.length)} interview(s) could not be exported.` + : 'Your export has downloaded.', + variant: 'success', + timeout: 8000, + }); + } + } catch (error) { + if (controller.signal.aborted) { + close(toastId); + add({ + title: 'Export cancelled', + description: 'The export was cancelled.', + timeout: 5000, + }); + return; + } + const e = ensureError(error); + posthog.captureException(e); + close(toastId); + add({ + variant: 'destructive', + title: 'Export failed', + description: e.message, + timeout: 0, + }); + } finally { + exportingRef.current = false; + } + })(); + }, + [add, update, close, download], + ); + + return ( + + {children} + + ); +} diff --git a/components/FreezeInterviewsSwitch.tsx b/components/FreezeInterviewsSwitch.tsx new file mode 100644 index 000000000..1a87c9500 --- /dev/null +++ b/components/FreezeInterviewsSwitch.tsx @@ -0,0 +1,27 @@ +import 'server-only'; +import { setAppSetting } from '~/actions/appSettings'; +import { getAppSetting } from '~/queries/appSettings'; +import Switch from './SwitchWithOptimisticUpdate'; + +const FreezeInterviewsSwitch = async () => { + const freezeInterviewsAfterCompletion = await getAppSetting( + 'freezeInterviewsAfterCompletion', + ); + + if (freezeInterviewsAfterCompletion === null) { + return null; + } + + return ( + { + 'use server'; + await setAppSetting('freezeInterviewsAfterCompletion', value); + return value; + }} + /> + ); +}; + +export default FreezeInterviewsSwitch; diff --git a/components/InfoTooltip.stories.tsx b/components/InfoTooltip.stories.tsx new file mode 100644 index 000000000..56d3ecf9e --- /dev/null +++ b/components/InfoTooltip.stories.tsx @@ -0,0 +1,208 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { HelpCircle, Star } from 'lucide-react'; +import { FieldLabel } from '@codaco/fresco-ui/form/FieldLabel'; +import InfoTooltip from './InfoTooltip'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { UnorderedList } from '@codaco/fresco-ui/typography/UnorderedList'; +import { Button, IconButton } from '@codaco/fresco-ui/Button'; + +const meta = { + title: 'Components/InfoTooltip', + component: InfoTooltip, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + title: { + control: 'text', + description: 'The title of the tooltip', + }, + description: { + control: 'text', + description: + 'The description content - can be a string or a render function for complex content', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Information', + description: + 'This is a helpful tooltip with information about this feature.', + }, +}; + +export const LongDescription: Story = { + args: { + title: 'Detailed Information', + description: + 'This tooltip contains a much longer description that explains the feature in greater detail. It will wrap appropriately within the maximum width constraint.', + }, +}; + +export const CustomTrigger: Story = { + args: { + title: 'Custom Trigger', + description: + 'This tooltip uses a custom trigger element instead of the default info icon.', + }, + render: (args) => ( + + + Help + + } + /> + ), +}; + +export const ComplexDescription: Story = { + args: { + title: 'Feature Details', + description: 'This will be overridden in render', + }, + render: (args) => ( + ( +
    + + This tooltip contains multiple elements: + + +
  • First item with details
  • +
  • Second item with more information
  • +
  • Third item for completeness
  • +
    + + Use render functions for complex layouts. + +
    + )} + /> + ), +}; + +export const WithFormattedContent: Story = { + args: { + title: 'Markdown-style Content', + description: 'This will be overridden in render', + }, + render: (args) => ( + ( +
    + + Important: This feature requires proper + configuration. + + + You can use{' '} + code snippets{' '} + and other formatting. + + + Note: Hover behavior is automatic. + +
    + )} + /> + ), +}; + +export const DifferentTriggerIcons: Story = { + args: { + title: 'Different Triggers', + description: 'Different icon triggers example', + }, + render: () => ( +
    + + } + /> + } + /> +
    + ), +}; + +export const InContext: Story = { + args: { + title: 'In Context', + description: 'Examples in context', + }, + render: () => ( +
    +
    + + Field Name{' '} + + +
    + +
    + + Section Title + ( +
    + + This section contains important configuration options: + + +
  • Option 1: Enables feature X
  • +
  • Option 2: Controls behavior Y
  • +
  • Option 3: Sets default Z
  • +
    +
    + )} + /> +
    +
    + +
    +
    + Advanced Settings + } + /> + } + /> +
    + + Example content showing how the tooltip integrates with other UI + elements. + +
    +
    + ), +}; diff --git a/components/InfoTooltip.tsx b/components/InfoTooltip.tsx index 8aaa1e2d1..5563cd1fd 100644 --- a/components/InfoTooltip.tsx +++ b/components/InfoTooltip.tsx @@ -1,27 +1,54 @@ +import { Popover as BasePopover } from '@base-ui/react/popover'; import { InfoIcon } from 'lucide-react'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from './ui/tooltip'; -import { type ReactNode } from 'react'; +import { type ComponentProps, type ReactElement } from 'react'; +import Heading from '@codaco/fresco-ui/typography/Heading'; +import Paragraph from '@codaco/fresco-ui/typography/Paragraph'; +import { Popover, PopoverContent, PopoverTrigger } from '@codaco/fresco-ui/Popover'; +type InfoTooltipProps = Omit, 'children'> & { + title: string; + description: + | string + | ((props: ComponentProps<'p'>) => ReactElement>) + | ReactElement>; + trigger?: ReactElement>; + sideOffset?: number; +}; + +/** + * + * InfoTooltip component for displaying informational tooltips. + * + * NOTE: Do not be tempted to use the base-ui tooltip component for this. Base-ui + * specifically says to only use tooltips for things that cause actions + * separate from the trigger itself. + */ export default function InfoTooltip({ - content, - trigger = , - triggerClasses, -}: { - content: ReactNode; - trigger: ReactNode; - triggerClasses?: string; -}) { + title, + description, + trigger = , + sideOffset = 10, + ...rest +}: InfoTooltipProps) { return ( - - - {trigger} - {content} - - + + + {trigger} + + + } + > + {title} + + {typeof description === 'string' ? ( + }> + {description} + + ) : ( + + )} + + ); } diff --git a/components/InterviewDataApiSwitch.tsx b/components/InterviewDataApiSwitch.tsx new file mode 100644 index 000000000..bbf4fb5a4 --- /dev/null +++ b/components/InterviewDataApiSwitch.tsx @@ -0,0 +1,25 @@ +import 'server-only'; +import { setAppSetting } from '~/actions/appSettings'; +import { getAppSetting } from '~/queries/appSettings'; +import Switch from '~/components/SwitchWithOptimisticUpdate'; + +const InterviewDataApiSwitch = async () => { + const enableInterviewDataApi = await getAppSetting('enableInterviewDataApi'); + + if (enableInterviewDataApi === null) { + return null; + } + + return ( + { + 'use server'; + await setAppSetting('enableInterviewDataApi', value); + return value; + }} + /> + ); +}; + +export default InterviewDataApiSwitch; diff --git a/components/LimitInterviewsSwitchClient.tsx b/components/LimitInterviewsSwitchClient.tsx deleted file mode 100644 index facdd487f..000000000 --- a/components/LimitInterviewsSwitchClient.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { setAppSetting } from '~/actions/appSettings'; -import Switch from './SwitchWithOptimisticUpdate'; - -const LimitInterviewsSwitchClient = ({ - limitInterviews, -}: { - limitInterviews: boolean; -}) => { - return ( - { - await setAppSetting('limitInterviews', value); - return value; - }} - /> - ); -}; - -export default LimitInterviewsSwitchClient; diff --git a/components/Link.tsx b/components/Link.tsx index fda187706..0fbd2479e 100644 --- a/components/Link.tsx +++ b/components/Link.tsx @@ -1,14 +1,18 @@ import NextLink from 'next/link'; +import { cx } from '@codaco/fresco-ui/utils/cva'; -export default function Link(props: React.ComponentProps) { +const groupClasses = + 'group text-link focusable rounded-sm font-semibold transition-all duration-300 ease-in-out'; +const spanClasses = + 'from-link to-link bg-linear-to-r bg-[length:0%_2px] bg-bottom-left bg-no-repeat pb-[2px] transition-all duration-200 ease-out group-hover:bg-[length:100%_2px]'; + +export default function Link({ + className, + ...props +}: React.ComponentProps) { return ( - - - {props.children} - + + {props.children} ); } diff --git a/components/NetlifyBadge.tsx b/components/NetlifyBadge.tsx index d6f9315ca..5fb4d3fdc 100644 --- a/components/NetlifyBadge.tsx +++ b/components/NetlifyBadge.tsx @@ -7,7 +7,11 @@ export default function NetlifyBadge() { return (