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/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 000000000..a03446848 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,29 @@ +name: 'Chromatic' +permissions: + contents: read + +on: push + +jobs: + chromatic: + name: Run Chromatic + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install pnpm + uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + cache: 'pnpm' + - name: Install dependencies + # ⚠️ See your package manager's documentation for the correct command to install dependencies in a CI environment. + run: pnpm install --frozen-lockfile + - name: Run Chromatic + uses: chromaui/action@latest + with: + # ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + exitOnceUploaded: true diff --git a/.github/workflows/docker-build-pr.yml b/.github/workflows/docker-build-pr.yml index c0e1c022d..df28647a1 100644 --- a/.github/workflows/docker-build-pr.yml +++ b/.github/workflows/docker-build-pr.yml @@ -3,6 +3,11 @@ 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' @@ -11,17 +16,14 @@ on: description: 'Name to use when loading the image' value: ${{ jobs.build.outputs.image-name }} - # Also run directly on PRs + # Also run directly on PRs (all branches) pull_request: - branches: - - main - - 'v*' permissions: contents: read env: - IMAGE_NAME: fresco-pr + IMAGE_NAME: fresco-test jobs: build: @@ -54,6 +56,8 @@ jobs: 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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..33693f4bf --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,181 @@ +name: E2E Tests + +permissions: + contents: read + pages: write + id-token: write + pull-requests: write + +on: + push: + branches: [main, next] + pull_request: + +# Only allow one deployment at a time for GitHub Pages +concurrency: + group: e2e-pages-${{ github.ref }} + cancel-in-progress: true + +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-test + 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:latest + 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') + outputs: + test-result: ${{ steps.test.outcome }} + + 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 + id: test + run: pnpm test:e2e + env: + CI: true + + - 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 + + # Prepare report for GitHub Pages deployment (only on failure) + - name: Upload Pages artifact + if: failure() && github.event_name == 'pull_request' + uses: actions/upload-pages-artifact@v3 + with: + path: tests/e2e/playwright-report/ + + # Deploy report to GitHub Pages on test failure + deploy-report: + if: failure() && github.event_name == 'pull_request' && needs.e2e.outputs.test-result == 'failure' + needs: e2e + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + - name: Comment on PR with report link + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + ## 🎭 Playwright E2E Test Report + + ❌ **Tests failed.** View the full report here: + + 👉 **[${{ steps.deployment.outputs.page_url }}](${{ steps.deployment.outputs.page_url }})** + +
+ Report details + + - **Workflow run:** [View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + - **Commit:** ${{ github.event.pull_request.head.sha }} + - **Branch:** `${{ github.head_ref }}` + +
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index 19d6ff665..000000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Playwright Tests - -on: - push: - branches: [next] - pull_request: - branches: [next] - workflow_dispatch: # Allows manual triggering of the workflow - -permissions: - contents: read - -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: postgres - ports: - - 5433:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - 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: Check if Playwright is installed - id: check-playwright - run: | - if pnpm list @playwright/test --depth=0 2>/dev/null | grep -q "@playwright/test"; then - echo "playwright_installed=true" >> $GITHUB_OUTPUT - else - echo "playwright_installed=false" >> $GITHUB_OUTPUT - echo "::notice::Playwright is not installed in this branch. Skipping tests." - fi - - - name: Install Playwright Browsers - if: steps.check-playwright.outputs.playwright_installed == 'true' - run: pnpm exec playwright install --with-deps - - - name: Run Playwright tests - if: steps.check-playwright.outputs.playwright_installed == 'true' - run: pnpm exec playwright test - env: - CI: true - - - name: Upload Playwright Report - uses: actions/upload-artifact@v4 - if: always() && steps.check-playwright.outputs.playwright_installed == 'true' - with: - name: playwright-report - path: tests/e2e/playwright-report/ - retention-days: 30 - - - name: Upload Test Results - uses: actions/upload-artifact@v4 - if: always() && steps.check-playwright.outputs.playwright_installed == 'true' - with: - name: test-results - path: tests/e2e/test-results/ - retention-days: 30 diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml deleted file mode 100644 index 4986e2bcc..000000000 --- a/.github/workflows/update-snapshots.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Update Snapshots on Comment -on: - issue_comment: - types: [created] -jobs: - update-snapshots: - name: Update Snapshots - if: github.event.issue.pull_request && contains(github.event.comment.body, '/approve-snapshots') - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Get branch of PR - uses: xt0rted/pull-request-comment-branch@v2 - id: comment-branch - - name: Checkout PR branch - uses: actions/checkout@v4 - with: - ref: ${{ steps.comment-branch.outputs.head_ref }} - - name: Extract URL from comment - id: extract-url - env: - COMMENT: ${{ github.event.comment.body }} - run: | - URL=$(echo "$COMMENT" | grep -o 'https://[^ ]*' | head -1) - echo "preview_url=$URL" >> $GITHUB_OUTPUT - - name: Comment action started - uses: thollander/actions-comment-pull-request@v3 - with: - message: | - ### Updating snapshots. Click [here](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) to see the status. - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm install -g pnpm && pnpm install - - name: Install Playwright Browsers - run: pnpm exec playwright install --with-deps - - name: Run Playwright update snapshots - run: pnpm exec playwright test --update-snapshots - env: - VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} - E2E_UPLOADTHING_TOKEN: ${{ secrets.E2E_UPLOADTHING_TOKEN }} - BASE_URL: ${{ steps.extract-url.outputs.preview_url }} - - name: Commit and push updated snapshots - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: 'Update e2e snapshots' - - name: Comment success - uses: thollander/actions-comment-pull-request@v3 - with: - message: | - ### 🎉 Successfully updated and committed Playwright snapshots! 🎉 diff --git a/.gitignore b/.gitignore index b0861c59f..29cf34f22 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,17 @@ yarn-error.log* *storybook.log storybook-static + +# e2e testing +/tests/e2e/playwright-report/ +/tests/e2e/test-results/ +/tests/e2e/screenshots/ +/tests/e2e/.auth/ +/tests/e2e/.context-data.json +/playwright-report/ +/test-results/ +/playwright/.cache/ + +# Serena +.serena +.pnpm-store diff --git a/.playwright-mcp/page-2025-12-04T13-07-07-849Z.png b/.playwright-mcp/page-2025-12-04T13-07-07-849Z.png new file mode 100644 index 000000000..fd51a38d2 Binary files /dev/null and b/.playwright-mcp/page-2025-12-04T13-07-07-849Z.png differ diff --git a/.prettierrc b/.prettierrc index 30dc564a1..6808ed907 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,7 @@ "plugins": ["prettier-plugin-tailwindcss"], "printWidth": 80, "quoteProps": "consistent", - "singleQuote": true + "singleQuote": true, + "tabWidth": 2, + "useTabs": false } 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..d5f65d99f 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,16 +1,15 @@ -import type { StorybookConfig } from '@storybook/nextjs'; +import type { StorybookConfig } from '@storybook/nextjs-vite'; const config: StorybookConfig = { - "stories": [ - "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)" + stories: [ + '../(app|components|lib)/**/*.mdx', + '../(app|components|lib)/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], - "addons": [], - "framework": { - "name": "@storybook/nextjs", - "options": {} + addons: [], + framework: { + name: '@storybook/nextjs-vite', + options: {}, }, - "staticDirs": [ - "../public" - ] + staticDirs: ['../public'], }; -export default config; \ No newline at end of file +export default 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..e8d91a04e --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,43 @@ +import type { Preview } from '@storybook/nextjs-vite'; +import Providers from '../components/Providers'; +import '../styles/globals.css'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + backgrounds: { + options: { + light: { + name: 'light', + value: '#ffffff', + }, + + dark: { + name: 'dark', + value: '#1f1f1f', + } + } + }, + }, + + decorators: [ + (Story) => ( + + + + ), + ], + + initialGlobals: { + backgrounds: { + value: 'light' + } + } +}; + +export default preview; 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..ef7d766b1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,20 @@ { - "css.customData": [ - "./.vscode/css-data.json" - ], + "editor.tabSize": 2, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "css.customData": ["./.vscode/css-data.json"], "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "editor.codeActionsOnSave": { "source.organizeImports": "always", "source.fixAll": "always", "source.fixAll.eslint": "always", - "source.fixAll.typescript": "always", + "source.fixAll.typescript": "always" }, - "editor.formatOnSave": true -} \ No newline at end of file + "editor.formatOnSave": true, + "tailwindCSS.experimental.classRegex": [ + ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index 37a968ca8..4b66288a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,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,6 +115,7 @@ console.log('Debug info'); ### Server Actions Located in `/actions/`. Pattern: + - Mark with `'use server'` directive - Use `requireApiAuth()` for authentication - Return `{ error, data }` pattern @@ -164,6 +166,7 @@ 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 @@ -234,11 +237,13 @@ const interviews = await prisma.interview.findMany({ ## Formatting Prettier configuration (`.prettierrc`): + - Single quotes - 80 character print width - Tailwind class sorting plugin ESLint: + - TypeScript strict type checking - Next.js Core Web Vitals - No unused variables (except `_` prefix) @@ -252,6 +257,7 @@ ESLint: - **Load Tests**: k6 (`pnpm load-test`) Run tests: + ```bash pnpm test # Unit tests pnpm storybook # Component testing @@ -328,3 +334,7 @@ pnpm storybook # Component testing - [shadcn/ui](https://ui.shadcn.com/) - [Prisma](https://www.prisma.io/docs) - [Tailwind CSS](https://tailwindcss.com/docs) + +## Debugging and Development Tips + +- Use the Playwright MCP to debug errors and view console output directly. Do NOT start the development server or the storybook server. Instead, prompt the user to start these for you. diff --git a/Dockerfile b/Dockerfile index ea30844e0..d4e2a8985 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,10 +35,14 @@ COPY --from=deps /app/node_modules ./node_modules # Copy source code COPY . . +# Build arg to disable image optimization (useful for test environments) +ARG DISABLE_IMAGE_OPTIMIZATION=false +ENV DISABLE_IMAGE_OPTIMIZATION=$DISABLE_IMAGE_OPTIMIZATION + # Set environment variables for build - they are provided at runtime ENV SKIP_ENV_VALIDATION=true ENV NODE_ENV=production -ENV NODE_OPTIONS="--max-old-space-size=4096" +ENV NODE_OPTIONS="--max-old-space-size=4096 --no-network-family-autoselection" # Enable pnpm, generate Prisma client, and build # Note: prisma generate must run here because the generated client (lib/db/generated/) @@ -81,6 +85,16 @@ COPY --from=builder --chown=nextjs:nodejs /app/package.json ./ COPY --from=builder --chown=nextjs:nodejs /app/env.js ./ COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./ +# Install Prisma CLI for database migrations (required for setup-database.js) +# The standalone build doesn't include node_modules, so we install prisma via pnpm +# Read version from package.json to stay in sync +COPY --from=builder /app/package.json /tmp/package.json +RUN corepack enable pnpm && \ + PRISMA_VERSION=$(node -p "require('/tmp/package.json').devDependencies?.prisma || require('/tmp/package.json').dependencies?.prisma") && \ + echo "Installing prisma@$PRISMA_VERSION" && \ + pnpm add --force "prisma@$PRISMA_VERSION" && \ + rm /tmp/package.json + # Switch to non-root user USER nextjs diff --git a/actions/activityFeed.ts b/actions/activityFeed.ts index 40c2f4eba..49f3bd38e 100644 --- a/actions/activityFeed.ts +++ b/actions/activityFeed.ts @@ -1,7 +1,7 @@ 'use server'; +import type { Activity, ActivityType } from '~/components/DataTable/types'; import { safeRevalidateTag } from '~/lib/cache'; -import type { Activity, ActivityType } from '~/lib/data-table/types'; import { prisma } from '~/lib/db'; export async function addEvent( diff --git a/actions/apiTokens.ts b/actions/apiTokens.ts new file mode 100644 index 000000000..6428347b3 --- /dev/null +++ b/actions/apiTokens.ts @@ -0,0 +1,115 @@ +'use server'; + +import { randomBytes } from 'crypto'; +import { safeRevalidateTag } from '~/lib/cache'; +import { + createApiTokenSchema, + deleteApiTokenSchema, + updateApiTokenSchema, +} from '~/schemas/apiTokens'; +import { requireApiAuth } from '~/utils/auth'; +import { prisma } from '~/lib/db'; +import { addEvent } from './activityFeed'; + +// Generate a secure random token +function generateToken(): string { + return randomBytes(32).toString('base64url'); +} + +export async function createApiToken(data: unknown) { + await requireApiAuth(); + + const { description } = createApiTokenSchema.parse(data); + const token = generateToken(); + + try { + const apiToken = await prisma.apiToken.create({ + data: { + token, + description, + }, + }); + + void addEvent( + 'API Token Created', + `Created API token: ${description ?? 'Untitled'}`, + ); + safeRevalidateTag('getApiTokens'); + + // Return the token only once, on creation + return { error: null, data: { ...apiToken, token } }; + } catch (error) { + return { error: 'Failed to create API token', data: null }; + } +} + +export async function updateApiToken(data: unknown) { + await requireApiAuth(); + + const { id, ...updateData } = updateApiTokenSchema.parse(data); + + try { + const apiToken = await prisma.apiToken.update({ + where: { id }, + data: updateData, + select: { + id: true, + description: true, + createdAt: true, + lastUsedAt: true, + isActive: true, + }, + }); + + void addEvent('API Token Updated', `Updated API token: ${id}`); + safeRevalidateTag('getApiTokens'); + + return { error: null, data: apiToken }; + } catch (error) { + return { error: 'Failed to update API token', data: null }; + } +} + +export async function deleteApiToken(data: unknown) { + await requireApiAuth(); + + const { id } = deleteApiTokenSchema.parse(data); + + try { + await prisma.apiToken.delete({ + where: { id }, + }); + + void addEvent('API Token Deleted', `Deleted API token: ${id}`); + safeRevalidateTag('getApiTokens'); + + return { error: null, data: { id } }; + } catch (error) { + return { error: 'Failed to delete API token', data: null }; + } +} + +// Verify an API token and update lastUsedAt +export async function verifyApiToken( + token: string, +): Promise<{ valid: boolean }> { + try { + const apiToken = await prisma.apiToken.findUnique({ + where: { token, isActive: true }, + }); + + if (!apiToken) { + return { valid: false }; + } + + // Update lastUsedAt + await prisma.apiToken.update({ + where: { id: apiToken.id }, + data: { lastUsedAt: new Date() }, + }); + + return { valid: true }; + } catch (error) { + return { valid: false }; + } +} diff --git a/actions/appSettings.ts b/actions/appSettings.ts index 5f62d208a..b78e8793c 100644 --- a/actions/appSettings.ts +++ b/actions/appSettings.ts @@ -3,31 +3,37 @@ import { redirect } from 'next/navigation'; import { type z } from 'zod'; import { safeRevalidateTag } from '~/lib/cache'; -import { type AppSetting, appSettingsSchema } from '~/schemas/appSettings'; +import { + type AppSetting, + appSettingPreprocessedSchema, +} from '~/schemas/appSettings'; import { requireApiAuth } from '~/utils/auth'; import { prisma } from '~/lib/db'; import { ensureError } from '~/utils/ensureError'; - -// Convert string | boolean | Date to string -const getStringValue = (value: string | boolean | Date) => { - if (typeof value === 'boolean') return value.toString(); - if (value instanceof Date) return value.toISOString(); - return value; -}; +import { getStringValue } from '~/utils/getStringValue'; export async function setAppSetting< Key extends AppSetting, - V extends z.infer[Key], + V extends z.infer[Key], >(key: Key, value: V): Promise { await requireApiAuth(); - if (!appSettingsSchema.shape[key]) { + if (!appSettingPreprocessedSchema.shape[key]) { throw new Error(`Invalid app setting: ${key}`); } try { - const result = appSettingsSchema.shape[key].parse(value); - const stringValue = getStringValue(result); + // Null values are not supported - caller should not pass null + if (value === null) { + throw new Error('Cannot set app setting to null'); + } + + // Convert the typed value to a database string + // Filter out undefined values as they're not supported by getStringValue + if (value === undefined) { + throw new Error('Cannot set app setting to undefined'); + } + const stringValue = getStringValue(value); await prisma.appSettings.upsert({ where: { key }, diff --git a/actions/interviews.ts b/actions/interviews.ts index 26bca785c..4d05d40b9 100644 --- a/actions/interviews.ts +++ b/actions/interviews.ts @@ -1,11 +1,14 @@ 'use server'; +import { type NcNetwork } from '@codaco/shared-consts'; import { createId } from '@paralleldrive/cuid2'; -import { Prisma, type Interview, type Protocol } from '~/lib/db/generated/client'; +import { type Interview } from '~/lib/db/generated/client'; +import { revalidatePath } from 'next/cache'; import { cookies } from 'next/headers'; +import superjson from 'superjson'; import trackEvent from '~/lib/analytics'; import { safeRevalidateTag } from '~/lib/cache'; -import type { InstalledProtocols } from '~/lib/interviewer/store'; +import { createInitialNetwork } from '~/lib/interviewer/ducks/modules/session'; import { formatExportableSessions } from '~/lib/network-exporters/formatters/formatExportableSessions'; import archive from '~/lib/network-exporters/formatters/session/archive'; import { generateOutputFiles } from '~/lib/network-exporters/formatters/session/generateOutputFiles'; @@ -18,13 +21,11 @@ import type { FormattedSession, } from '~/lib/network-exporters/utils/types'; import { getAppSetting } from '~/queries/appSettings'; -import { getInterviewsForExport } from '~/queries/interviews'; -import type { - CreateInterview, - DeleteInterviews, - SyncInterview, -} from '~/schemas/interviews'; -import { type NcNetwork } from '~/schemas/network-canvas'; +import { + getInterviewsForExport, + type GetInterviewsForExportQuery, +} from '~/queries/interviews'; +import type { CreateInterview, DeleteInterviews } from '~/schemas/interviews'; import { requireApiAuth } from '~/utils/auth'; import { prisma } from '~/lib/db'; import { ensureError } from '~/utils/ensureError'; @@ -86,26 +87,36 @@ export const updateExportTime = async (interviewIds: Interview['id'][]) => { } }; +export type ExportedProtocol = + Awaited[number]['protocol']; + export const prepareExportData = async (interviewIds: Interview['id'][]) => { await requireApiAuth(); - const interviewsSessions = await getInterviewsForExport(interviewIds); + const interviewsSessionsRaw = await getInterviewsForExport(interviewIds); + const interviewsSessions = superjson.parse( + interviewsSessionsRaw, + ); - const protocolsMap = new Map(); + const protocolsMap = new Map(); interviewsSessions.forEach((session) => { protocolsMap.set(session.protocol.hash, session.protocol); }); - const formattedProtocols: InstalledProtocols = - Object.fromEntries(protocolsMap); + const formattedProtocols = Object.fromEntries(protocolsMap); + const formattedSessions = formatExportableSessions(interviewsSessions); - return { formattedSessions, formattedProtocols }; + return { + formattedSessions: superjson.stringify(formattedSessions), + formattedProtocols: superjson.stringify(formattedProtocols), + }; }; +export type FormattedProtocols = Record; export const exportSessions = async ( formattedSessions: FormattedSession[], - formattedProtocols: InstalledProtocols, + formattedProtocols: FormattedProtocols, interviewIds: Interview['id'][], exportOptions: ExportOptions, ): Promise => { @@ -200,7 +211,7 @@ export async function createInterview(data: CreateInterview) { id: true, }, data: { - network: Prisma.JsonNull, + network: createInitialNetwork(), participant: participantStatement, protocol: { connect: { @@ -248,35 +259,6 @@ export async function createInterview(data: CreateInterview) { } } -export async function syncInterview(data: SyncInterview) { - const { id, network, currentStep, stageMetadata } = data; - - try { - await prisma.interview.update({ - where: { - id, - }, - data: { - network, - currentStep, - stageMetadata, - lastUpdated: new Date(), - }, - }); - - safeRevalidateTag(`getInterviewById-${id}`); - - // eslint-disable-next-line no-console - console.log(`🚀 Interview synced with server! (${id})`); - return { success: true }; - } catch (error) { - const message = ensureError(error).message; - return { success: false, error: message }; - } -} - -export type SyncInterviewType = typeof syncInterview; - export async function finishInterview(interviewId: Interview['id']) { try { const updatedInterview = await prisma.interview.update({ @@ -309,6 +291,8 @@ export async function finishInterview(interviewId: Interview['id']) { safeRevalidateTag('getInterviews'); safeRevalidateTag('summaryStatistics'); + safeRevalidateTag('activityFeed'); + revalidatePath('/dashboard'); return { error: null }; } catch (error) { diff --git a/actions/protocols.ts b/actions/protocols.ts index 823c60baa..4d0ed7c76 100644 --- a/actions/protocols.ts +++ b/actions/protocols.ts @@ -1,12 +1,11 @@ 'use server'; -import { type Protocol } from '@codaco/shared-consts'; import { Prisma } from '~/lib/db/generated/client'; -import { safeRevalidateTag } from 'lib/cache'; +import { safeRevalidateTag } from '~/lib/cache'; import { hash } from 'ohash'; import { type z } from 'zod'; -import { getUTApi } from '~/lib/uploadthing-server-helpers'; -import { protocolInsertSchema } from '~/schemas/protocol'; +import { getUTApi } from '~/lib/uploadthing/server-helpers'; +import { type protocolInsertSchema } from '~/schemas/protocol'; import { requireApiAuth } from '~/utils/auth'; import { prisma } from '~/lib/db'; import { addEvent } from './activityFeed'; @@ -125,14 +124,7 @@ export async function insertProtocol( ) { await requireApiAuth(); - const { - protocol: inputProtocol, - protocolName, - newAssets, - existingAssetIds, - } = protocolInsertSchema.parse(input); - - const protocol = inputProtocol as Protocol; + const { protocol, protocolName, newAssets, existingAssetIds } = input; try { const protocolHash = hash(protocol); @@ -140,16 +132,17 @@ export async function insertProtocol( await prisma.protocol.create({ data: { hash: protocolHash, - lastModified: protocol.lastModified, + lastModified: protocol.lastModified ?? new Date(), name: protocolName, schemaVersion: protocol.schemaVersion, - stages: protocol.stages as unknown as Prisma.JsonArray, // The Stage interface needs to be changed to be a type: https://www.totaltypescript.com/type-vs-interface-which-should-you-use#index-signatures-in-types-vs-interfaces + stages: protocol.stages, codebook: protocol.codebook, description: protocol.description, assets: { create: newAssets, - connect: existingAssetIds.map((assetId) => ({ assetId })), + connect: existingAssetIds.map((assetId: string) => ({ assetId })), }, + experiments: protocol.experiments ?? Prisma.JsonNull, }, }); @@ -162,7 +155,9 @@ export async function insertProtocol( } catch (e) { // Attempt to delete any assets we uploaded to storage if (newAssets.length > 0) { - void deleteFilesFromUploadThing(newAssets.map((a) => a.key)); + void deleteFilesFromUploadThing( + newAssets.map((a: { key: string }) => a.key), + ); } // Check for protocol already existing if (e instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/actions/reset.ts b/actions/reset.ts index 5262d5740..42ec4536d 100644 --- a/actions/reset.ts +++ b/actions/reset.ts @@ -2,8 +2,8 @@ import { revalidatePath } from 'next/cache'; import { env } from 'process'; -import { safeRevalidateTag } from '~/lib/cache'; -import { getUTApi } from '~/lib/uploadthing-server-helpers'; +import { CacheTags, safeRevalidateTag } from '~/lib/cache'; +import { getUTApi } from '~/lib/uploadthing/server-helpers'; import { requireApiAuth } from '~/utils/auth'; import { prisma } from '~/lib/db'; @@ -32,12 +32,7 @@ export const resetAppSettings = async () => { }); revalidatePath('/'); - safeRevalidateTag('appSettings'); - safeRevalidateTag('activityFeed'); - safeRevalidateTag('summaryStatistics'); - safeRevalidateTag('getProtocols'); - safeRevalidateTag('getParticipants'); - safeRevalidateTag('getInterviews'); + safeRevalidateTag(CacheTags); const utapi = await getUTApi(); diff --git a/actions/uploadThing.ts b/actions/uploadThing.ts index 66f54af33..b65310fb2 100644 --- a/actions/uploadThing.ts +++ b/actions/uploadThing.ts @@ -6,7 +6,7 @@ import type { ArchiveResult, ExportReturn, } from '~/lib/network-exporters/utils/types'; -import { getUTApi } from '~/lib/uploadthing-server-helpers'; +import { getUTApi } from '~/lib/uploadthing/server-helpers'; import { requireApiAuth } from '~/utils/auth'; import { ensureError } from '~/utils/ensureError'; diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/ConnectUploadThing.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/ConnectUploadThing.tsx index 80cdccd3f..31e8893fa 100644 --- a/app/(blobs)/(setup)/_components/OnboardSteps/ConnectUploadThing.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/ConnectUploadThing.tsx @@ -1,8 +1,8 @@ import { submitUploadThingForm } from '~/actions/appSettings'; import Link from '~/components/Link'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; 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() { diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx index 3a832143b..79d02dc70 100644 --- a/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx @@ -1,7 +1,7 @@ import { SignUpForm } from '~/app/(blobs)/(setup)/_components/SignUpForm'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; function CreateAccount() { return ( diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx index 28aabc5df..eb0d1f5e6 100644 --- a/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx @@ -3,10 +3,10 @@ import { FileText } from 'lucide-react'; import { redirect } from 'next/navigation'; import { setAppSetting } from '~/actions/appSettings'; import Section from '~/components/layout/Section'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; 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'; diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx index 6d29ec2e0..e7faee6d8 100644 --- a/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx @@ -2,8 +2,8 @@ import ImportCSVModal from '~/app/dashboard/participants/_components/ImportCSVMo 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 Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; import OnboardContinue from '../OnboardContinue'; function ManageParticipants({ diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx index 653d40db6..08a7d1b5d 100644 --- a/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx @@ -1,9 +1,9 @@ 'use client'; import { parseAsInteger, useQueryState } from 'nuqs'; import ProtocolUploader from '~/app/dashboard/_components/ProtocolUploader'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; import { Button } from '~/components/ui/Button'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; function ConfigureStudy() { const [currentStep, setCurrentStep] = useQueryState( diff --git a/app/(blobs)/(setup)/_components/Sidebar.tsx b/app/(blobs)/(setup)/_components/Sidebar.tsx index bbf8ed3f4..841de1c6d 100644 --- a/app/(blobs)/(setup)/_components/Sidebar.tsx +++ b/app/(blobs)/(setup)/_components/Sidebar.tsx @@ -2,7 +2,7 @@ import { Check } from 'lucide-react'; import { parseAsInteger, useQueryState } from 'nuqs'; -import Heading from '~/components/ui/typography/Heading'; +import Heading from '~/components/typography/Heading'; import { cn } from '~/utils/shadcn'; function OnboardSteps({ steps }: { steps: string[] }) { diff --git a/app/(blobs)/(setup)/_components/SignInForm.tsx b/app/(blobs)/(setup)/_components/SignInForm.tsx index b6c163a70..75eb1a166 100644 --- a/app/(blobs)/(setup)/_components/SignInForm.tsx +++ b/app/(blobs)/(setup)/_components/SignInForm.tsx @@ -3,9 +3,9 @@ import { Loader2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { login } from '~/actions/auth'; +import UnorderedList from '~/components/typography/UnorderedList'; 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 { loginSchema } from '~/schemas/auth'; diff --git a/app/(blobs)/(setup)/layout.tsx b/app/(blobs)/(setup)/layout.tsx index 1faf603c1..5f7bb2e7f 100644 --- a/app/(blobs)/(setup)/layout.tsx +++ b/app/(blobs)/(setup)/layout.tsx @@ -1,6 +1,9 @@ import type { ReactNode } from 'react'; import { requireAppNotExpired } from '~/queries/appSettings'; +// Force dynamic rendering because requireAppNotExpired queries the database +export const dynamic = 'force-dynamic'; + export default async function Layout({ children }: { children: ReactNode }) { await requireAppNotExpired(true); return children; diff --git a/app/(blobs)/(setup)/setup/page.tsx b/app/(blobs)/(setup)/setup/page.tsx index 44bab7dff..66ac4c47f 100644 --- a/app/(blobs)/(setup)/setup/page.tsx +++ b/app/(blobs)/(setup)/setup/page.tsx @@ -44,7 +44,7 @@ export default async function Page() { return ( } + fallback={} > diff --git a/app/(blobs)/expired/page.tsx b/app/(blobs)/expired/page.tsx index e93b2aa8e..23bb987a1 100644 --- a/app/(blobs)/expired/page.tsx +++ b/app/(blobs)/expired/page.tsx @@ -1,17 +1,9 @@ -import { redirect } from 'next/navigation'; import { resetAppSettings } from '~/actions/reset'; import { containerClasses } from '~/components/ContainerClasses'; import SubmitButton from '~/components/ui/SubmitButton'; import { env } from '~/env'; -import { isAppExpired } from '~/queries/appSettings'; - -export default async function Page() { - const isExpired = await isAppExpired(); - - if (!isExpired) { - redirect('/'); - } +export default function Page() { return (

Installation expired

@@ -24,7 +16,7 @@ export default async function Page() {

{env.NODE_ENV === 'development' && (
void resetAppSettings()}> - + Dev mode: Reset Configuration diff --git a/app/(blobs)/layout.tsx b/app/(blobs)/layout.tsx index 8e3bdf00e..2f94548d0 100644 --- a/app/(blobs)/layout.tsx +++ b/app/(blobs)/layout.tsx @@ -23,7 +23,7 @@ export default function Layout({ children }: PropsWithChildren) { {children}
-
+
diff --git a/app/(interview)/interview/[interviewId]/layout.tsx b/app/(interview)/interview/[interviewId]/layout.tsx new file mode 100644 index 000000000..3af4d7ef1 --- /dev/null +++ b/app/(interview)/interview/[interviewId]/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react'; +import SmallScreenOverlay from '../_components/SmallScreenOverlay'; + +export default function InterviewSessionLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + <> + + {children} + + ); +} diff --git a/app/(interview)/interview/[interviewId]/page.tsx b/app/(interview)/interview/[interviewId]/page.tsx index e1e03afc6..441820b80 100644 --- a/app/(interview)/interview/[interviewId]/page.tsx +++ b/app/(interview)/interview/[interviewId]/page.tsx @@ -1,8 +1,11 @@ import { cookies } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; -import { syncInterview } from '~/actions/interviews'; +import SuperJSON from 'superjson'; import { getAppSetting } from '~/queries/appSettings'; -import { getInterviewById } from '~/queries/interviews'; +import { + getInterviewById, + type GetInterviewByIdQuery, +} from '~/queries/interviews'; import { getServerSession } from '~/utils/auth'; import InterviewShell from '../_components/InterviewShell'; @@ -19,31 +22,35 @@ export default async function Page({ 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(); } + const interview = + SuperJSON.parse>(rawInterview); + const session = await getServerSession(); + // if limitInterviews is enabled // Check cookies for interview already completed for this user for this protocol // and redirect to finished page const limitInterviews = await getAppSetting('limitInterviews'); - if (limitInterviews && cookies().get(interview?.protocol?.id ?? '')) { + if (limitInterviews && 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 the interview is finished, redirect to the finish page, unless we are + // logged in as an admin + if (!session && interview?.finishTime) { redirect('/interview/finished'); } 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..c903eb926 --- /dev/null +++ b/app/(interview)/interview/[interviewId]/sync/route.ts @@ -0,0 +1,69 @@ +import { NcNetworkSchema, type NcNetwork } from '@codaco/shared-consts'; +import { NextResponse, type NextRequest } from 'next/server'; +import { z } from 'zod'; +import { StageMetadataSchema } from '~/lib/interviewer/ducks/modules/session'; +import { prisma } from '~/lib/db'; +import { ensureError } from '~/utils/ensureError'; + +/** + * Handle post requests from the client to store the current interview state. + */ +const routeHandler = async ( + request: NextRequest, + { params }: { params: { interviewId: string } }, +) => { + const interviewId = params.interviewId; + + const rawPayload = await request.json(); + + // Cast NcNetworkSchema to handle Zod version compatibility + const Schema = z.object({ + id: z.string(), + network: NcNetworkSchema as unknown as z.ZodType, + currentStep: z.number(), + stageMetadata: StageMetadataSchema.optional(), + lastUpdated: z.string(), + }); + + const validatedRequest = Schema.safeParse(rawPayload); + + if (!validatedRequest.success) { + // eslint-disable-next-line no-console + console.log(validatedRequest.error); + return NextResponse.json( + { + error: validatedRequest.error, + }, + { status: 400 }, + ); + } + + const { network, currentStep, stageMetadata, lastUpdated } = + validatedRequest.data; + + try { + await prisma.interview.update({ + where: { + id: interviewId, + }, + data: { + network, + currentStep, + stageMetadata: stageMetadata ?? undefined, + lastUpdated: new Date(lastUpdated), + }, + }); + + 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/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx index a1685c2c7..8c50a90b4 100644 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ b/app/(interview)/interview/_components/InterviewShell.tsx @@ -1,72 +1,30 @@ 'use client'; import { Provider } from 'react-redux'; +import SuperJSON from 'superjson'; +import { DndStoreProvider } from '~/lib/dnd'; 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'; +import { type GetInterviewByIdQuery } 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 InterviewShell = (props: { + rawPayload: string; // superjson encoded interview + disableSync?: boolean; // Disable syncing to database (for preview mode) }) => { - 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; - } + const decodedPayload = SuperJSON.parse>( + props.rawPayload, + ); return ( - - + + - - + + ); }; 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..802dc8bf0 100644 --- a/app/(interview)/interview/_components/SmallScreenOverlay.tsx +++ b/app/(interview)/interview/_components/SmallScreenOverlay.tsx @@ -1,7 +1,7 @@ import Image from 'next/image'; import { env } from 'node:process'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; import { getAppSetting } from '~/queries/appSettings'; const SmallScreenOverlay = async () => { diff --git a/app/(interview)/interview/finished/page.tsx b/app/(interview)/interview/finished/page.tsx index ced3d6e59..b33f072b8 100644 --- a/app/(interview)/interview/finished/page.tsx +++ b/app/(interview)/interview/finished/page.tsx @@ -1,13 +1,11 @@ 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 Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; export default function InterviewCompleted() { return ( -
- +
+ Thank you for participating! Your interview has been successfully completed.
diff --git a/app/(interview)/interview/layout.tsx b/app/(interview)/interview/layout.tsx index b36dbda6d..448c21bac 100644 --- a/app/(interview)/interview/layout.tsx +++ b/app/(interview)/interview/layout.tsx @@ -1,18 +1,9 @@ -import SmallScreenOverlay from '~/app/(interview)/interview/_components/SmallScreenOverlay'; -import '~/styles/interview.scss'; +import type { ReactNode } from 'react'; -export const metadata = { - title: 'Network Canvas Fresco - Interview', - description: 'Interview', -}; - -function RootLayout({ children }: { children: React.ReactNode }) { +export default function InterviewLayout({ children }: { children: ReactNode }) { return ( -
- +
{children}
); } - -export default RootLayout; diff --git a/app/(interview)/layout.tsx b/app/(interview)/layout.tsx new file mode 100644 index 000000000..1a080747f --- /dev/null +++ b/app/(interview)/layout.tsx @@ -0,0 +1,16 @@ +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)/preview/[protocolId]/interview/page.tsx b/app/(interview)/preview/[protocolId]/interview/page.tsx new file mode 100644 index 000000000..cad30820c --- /dev/null +++ b/app/(interview)/preview/[protocolId]/interview/page.tsx @@ -0,0 +1,76 @@ +import { + entityAttributesProperty, + entityPrimaryKeyProperty, +} from '@codaco/shared-consts'; +import { notFound } from 'next/navigation'; +import SuperJSON from 'superjson'; +import { v4 as uuid } from 'uuid'; +import InterviewShell from '~/app/(interview)/interview/_components/InterviewShell'; +import { env } from '~/env'; +import { getProtocolForPreview } from '~/queries/protocols'; +import { prisma } from '~/lib/db'; + +export const dynamic = 'force-dynamic'; + +export default async function PreviewInterviewPage({ + params, +}: { + params: { protocolId: string }; +}) { + const { protocolId } = params; + + if (!env.PREVIEW_MODE) { + notFound(); + } + + if (!protocolId) { + notFound(); + } + + const protocol = await getProtocolForPreview(protocolId); + + if (!protocol) { + notFound(); + } + + // Only allow preview protocols + if (!protocol.isPreview) { + notFound(); + } + + // Don't allow pending protocols (still uploading assets) + if (protocol.isPending) { + notFound(); + } + + // Update timestamp to prevent premature pruning + await prisma.protocol.update({ + where: { id: protocolId }, + data: { importedAt: new Date() }, + }); + + // Create an in-memory interview object (not persisted to database) + const now = new Date(); + const previewInterview = { + id: `preview-${uuid()}`, // Temporary ID for the preview session + startTime: now, + finishTime: null, + exportTime: null, + lastUpdated: now, + currentStep: 0, + stageMetadata: null, + network: { + ego: { + [entityPrimaryKeyProperty]: uuid(), + [entityAttributesProperty]: {}, + }, + nodes: [], + edges: [], + }, + protocol, + }; + + const rawPayload = SuperJSON.stringify(previewInterview); + + return ; +} diff --git a/app/(interview)/preview/[protocolId]/route.ts b/app/(interview)/preview/[protocolId]/route.ts new file mode 100644 index 000000000..774aa4747 --- /dev/null +++ b/app/(interview)/preview/[protocolId]/route.ts @@ -0,0 +1,70 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { env } from '~/env'; +import trackEvent from '~/lib/analytics'; +import { prisma } from '~/lib/db'; + +export const dynamic = 'force-dynamic'; + +const handler = async ( + req: NextRequest, + { params }: { params: { protocolId: string } }, +) => { + const protocolId = params.protocolId; + + // Check if preview mode is enabled + if (!env.PREVIEW_MODE) { + const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + url.pathname = '/onboard/error'; + return NextResponse.redirect(url); + } + + const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + + // Validate protocol ID + if (!protocolId || protocolId === 'undefined') { + url.pathname = '/onboard/error'; + return NextResponse.redirect(url); + } + + // Verify that this is actually a preview protocol + const protocol = await prisma.protocol.findUnique({ + where: { id: protocolId }, + select: { isPreview: true, isPending: true, name: true }, + }); + + if (!protocol) { + url.pathname = '/onboard/error'; + return NextResponse.redirect(url); + } + + if (!protocol.isPreview) { + // Not a preview protocol, redirect to regular onboard + url.pathname = `/onboard/${protocolId}`; + return NextResponse.redirect(url); + } + + if (protocol.isPending) { + // Protocol assets are still being uploaded + url.pathname = '/onboard/error'; + return NextResponse.redirect(url); + } + + // eslint-disable-next-line no-console + console.log( + `🎨 Starting preview interview using preview protocol ${protocol.name}...`, + ); + + void trackEvent({ + type: 'InterviewStarted', + metadata: { + protocolId, + isPreview: true, + }, + }); + + // Redirect to the preview interview page (no database persistence) + url.pathname = `/preview/${protocolId}/interview`; + return NextResponse.redirect(url); +}; + +export { handler as GET, handler as POST }; diff --git a/app/(interview)/preview/layout.tsx b/app/(interview)/preview/layout.tsx new file mode 100644 index 000000000..3c08774a4 --- /dev/null +++ b/app/(interview)/preview/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react'; +import SmallScreenOverlay from '../interview/_components/SmallScreenOverlay'; + +export default function PreviewLayout({ children }: { children: ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 000000000..bd6b6aec9 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,134 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +import { env } from '~/env.js'; + +type HealthStatus = 'healthy' | 'degraded' | 'unhealthy'; + +type HealthCheck = { + name: string; + status: HealthStatus; + duration: number; + error?: string; + details?: Record; +}; + +type HealthResponse = { + status: HealthStatus; + timestamp: string; + uptime: number; + version?: string; + checks: HealthCheck[]; +}; + +function checkBasicHealth(): HealthCheck { + const start = performance.now(); + + try { + // Basic health check - just verify the service is running + const nodeVersion = process.version; + const duration = performance.now() - start; + + return { + name: 'basic', + status: 'healthy', + duration: Math.round(duration), + details: { + nodeVersion, + environment: env.NODE_ENV, + uptime: Math.round(process.uptime()), + }, + }; + } catch (error) { + const duration = performance.now() - start; + + return { + name: 'basic', + status: 'unhealthy', + duration: Math.round(duration), + error: + error instanceof Error ? error.message : 'Basic health check failed', + }; + } +} + +function getOverallStatus(checks: HealthCheck[]): HealthStatus { + const hasUnhealthy = checks.some((check) => check.status === 'unhealthy'); + const hasDegraded = checks.some((check) => check.status === 'degraded'); + + if (hasUnhealthy) return 'unhealthy'; + if (hasDegraded) return 'degraded'; + return 'healthy'; +} + +function getStatusCode(status: HealthStatus): number { + switch (status) { + case 'healthy': + return 200; + case 'degraded': + return 200; // Still operational + case 'unhealthy': + return 503; // Service Unavailable + } +} + +export function GET(_request: NextRequest): NextResponse { + const startTime = performance.now(); + + try { + // Run health checks + const basicCheck = checkBasicHealth(); + const checks = [basicCheck]; + + const overallStatus = getOverallStatus(checks); + const statusCode = getStatusCode(overallStatus); + + const response: HealthResponse = { + status: overallStatus, + timestamp: new Date().toISOString(), + uptime: Math.round(process.uptime()), + version: env.APP_VERSION ?? 'unknown', + checks, + }; + + const totalDuration = Math.round(performance.now() - startTime); + + return NextResponse.json( + { + ...response, + duration: totalDuration, + }, + { + status: statusCode, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'X-Health-Check': 'true', + }, + }, + ); + } catch (error) { + // Fallback error response + const response: HealthResponse = { + status: 'unhealthy', + timestamp: new Date().toISOString(), + uptime: Math.round(process.uptime()), + checks: [ + { + name: 'health_check', + status: 'unhealthy', + duration: Math.round(performance.now() - startTime), + error: error instanceof Error ? error.message : 'Health check failed', + }, + ], + }; + + return NextResponse.json(response, { + status: 503, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'X-Health-Check': 'true', + }, + }); + } +} diff --git a/app/api/preview/helpers.ts b/app/api/preview/helpers.ts new file mode 100644 index 000000000..a631fa97e --- /dev/null +++ b/app/api/preview/helpers.ts @@ -0,0 +1,73 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { verifyApiToken } from '~/actions/apiTokens'; +import { env } from '~/env'; +import { getAppSetting } from '~/queries/appSettings'; +import { getServerSession } from '~/utils/auth'; +import type { AuthError, PreviewResponse } from './types'; + +// CORS headers for external client (Architect) +export const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +}; + +// Helper to create JSON responses with CORS headers +export function jsonResponse(data: PreviewResponse, status = 200) { + return NextResponse.json(data, { status, headers: corsHeaders }); +} + +// Check preview mode and authentication +// Returns null if authorized, or error data if not +export async function checkPreviewAuth( + req: NextRequest, +): Promise { + // Check if preview mode is enabled + if (!env.PREVIEW_MODE) { + return { + response: { + status: 'error', + message: 'Preview mode is not enabled', + }, + status: 403, + }; + } + + // Check authentication if required + const requireAuth = await getAppSetting('previewModeRequireAuth'); + + if (requireAuth) { + // Try session-based auth first + const session = await getServerSession(); + + if (!session) { + // Try API token auth + const authHeader = req.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!token) { + return { + response: { + status: 'error', + message: 'Authentication required. Provide session or API token.', + }, + status: 401, + }; + } + + const { valid } = await verifyApiToken(token); + + if (!valid) { + return { + response: { + status: 'error', + message: 'Invalid API token', + }, + status: 401, + }; + } + } + } + + return null; +} diff --git a/app/api/preview/route.ts b/app/api/preview/route.ts new file mode 100644 index 000000000..fff079d94 --- /dev/null +++ b/app/api/preview/route.ts @@ -0,0 +1,297 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { hash } from 'ohash'; +import { addEvent } from '~/actions/activityFeed'; +import { env } from '~/env'; +import { MIN_ARCHITECT_VERSION_FOR_PREVIEW } from '~/fresco.config'; +import trackEvent from '~/lib/analytics'; +import { prunePreviewProtocols } from '~/lib/preview-protocol-pruning'; +import { validateAndMigrateProtocol } from '~/lib/protocol/validateAndMigrateProtocol'; +import { + generatePresignedUploadUrl, + parseUploadThingToken, +} from '~/lib/uploadthing/presigned'; +import { getExistingAssets } from '~/queries/protocols'; +import { prisma } from '~/lib/db'; +import { ensureError } from '~/utils/ensureError'; +import { compareSemver, semverSchema } from '~/utils/semVer'; +import { checkPreviewAuth, corsHeaders, jsonResponse } from './helpers'; +import type { + AbortResponse, + CompleteResponse, + InitializeResponse, + PreviewRequest, + PreviewResponse, + ReadyResponse, + RejectedResponse, +} from './types'; + +// Handle preflight OPTIONS request +export function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }); +} + +export async function POST( + req: NextRequest, +): Promise> { + const authError = await checkPreviewAuth(req); + + if (authError) { + return jsonResponse(authError.response, authError.status); + } + + const REJECTED_RESPONSE: RejectedResponse = { + status: 'rejected', + message: 'Invalid protocol', + }; + + try { + const body = (await req.json()) as PreviewRequest; + const { type } = body; + + switch (type) { + case 'initialize-preview': { + const { protocol: protocolJson, assetMeta, architectVersion } = body; + + // Check Architect version compatibility + const architectVer = semverSchema.parse(`v${architectVersion}`); + const minVer = semverSchema.parse( + `v${MIN_ARCHITECT_VERSION_FOR_PREVIEW}`, + ); + if (compareSemver(architectVer, minVer) < 0) { + const response: InitializeResponse = { + status: 'error', + message: `Architect versions below ${MIN_ARCHITECT_VERSION_FOR_PREVIEW} are not supported for preview mode`, + }; + return jsonResponse(response, 400); + } + + // Validate and migrate protocol + const validationResult = await validateAndMigrateProtocol(protocolJson); + + if (!validationResult.success) { + return jsonResponse(REJECTED_RESPONSE, 400); + } + + const protocolToValidate = validationResult.protocol; + + // Calculate protocol hash + const protocolHash = hash(protocolJson); + + // Prune existing preview protocols based on age limit + // - Pending protocols (abandoned uploads) are deleted after 15 minutes + // - Completed protocols are deleted after 24 hours + // Ensures that we dont accumulate old preview protocols + await prunePreviewProtocols(); + + // Check if this exact protocol already exists + const existingPreview = await prisma.protocol.findFirst({ + where: { + hash: protocolHash, + }, + }); + + // If protocol exists, return ready immediately + if (existingPreview) { + const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + url.pathname = `/preview/${existingPreview.id}`; + + const response: ReadyResponse = { + status: 'ready', + previewUrl: url.toString(), + }; + return jsonResponse(response); + } + + // Check which assets already exist in the database + const assetIds = assetMeta.map((a) => a.assetId); + const existingDbAssets = await getExistingAssets(assetIds); + + const existingAssetMap = new Map( + existingDbAssets.map((a) => [ + a.assetId, + { key: a.key, url: a.url, type: a.type }, + ]), + ); + + // Get asset manifest from protocol to look up asset types + const assetManifest = protocolToValidate.assetManifest ?? {}; + + const existingAssetIds = assetMeta + .filter((a) => existingAssetMap.has(a.assetId)) + .map((a) => a.assetId); + + const newAssets = assetMeta.filter( + (a) => !existingAssetMap.has(a.assetId), + ); + + const tokenData = await parseUploadThingToken(); + + if (newAssets.length > 0 && !tokenData) { + const response: InitializeResponse = { + status: 'error', + message: 'UploadThing not configured', + }; + return jsonResponse(response, 500); + } + + const presignedData = newAssets.map((asset) => { + const manifestEntry = assetManifest[asset.assetId]; + const assetType = manifestEntry?.type ?? 'file'; + + const presigned = generatePresignedUploadUrl({ + fileName: asset.name, + fileSize: asset.size, + tokenData: tokenData!, + }); + + if (!presigned) { + throw new Error('Failed to generate presigned URL'); + } + + return { + uploadUrl: presigned.uploadUrl, + assetRecord: { + assetId: asset.assetId, + key: presigned.fileKey, + name: asset.name, + type: assetType, + url: presigned.fileUrl, + size: asset.size, + }, + }; + }); + + const presignedUrls = presignedData.map((d) => d.uploadUrl); + const assetsToCreate = presignedData.map((d) => d.assetRecord); + + // Create the protocol with assets immediately + // Mark as pending if there are assets to upload + const protocol = await prisma.protocol.create({ + data: { + hash: protocolHash, + name: `preview-${Date.now()}`, + schemaVersion: protocolJson.schemaVersion, + description: protocolJson.description, + lastModified: protocolJson.lastModified + ? new Date(protocolJson.lastModified) + : new Date(), + stages: protocolJson.stages as never, + codebook: protocolJson.codebook as never, + isPreview: true, + isPending: presignedUrls.length > 0, + assets: { + create: assetsToCreate, + connect: existingAssetIds.map((assetId) => ({ assetId })), + }, + }, + }); + + void addEvent('Preview Mode', `Preview protocol upload initiated`); + + // If no new assets to upload, return ready immediately + if (presignedUrls.length === 0) { + const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + url.pathname = `/preview/${protocol.id}`; + + const response: InitializeResponse = { + status: 'ready', + previewUrl: url.toString(), + }; + return jsonResponse(response); + } + + const response: InitializeResponse = { + status: 'job-created', + protocolId: protocol.id, + presignedUrls, + }; + return jsonResponse(response); + } + + case 'complete-preview': { + const { protocolId } = body; + + // Find the protocol + const protocol = await prisma.protocol.findUnique({ + where: { id: protocolId }, + }); + + if (!protocol) { + const response: CompleteResponse = { + status: 'error', + message: 'Preview job not found', + }; + return jsonResponse(response, 404); + } + + // Update timestamp and clear pending flag to mark completion + await prisma.protocol.update({ + where: { id: protocol.id }, + data: { importedAt: new Date(), isPending: false }, + }); + + void addEvent('Preview Mode', `Preview protocol upload completed`); + + const url = new URL(env.PUBLIC_URL ?? req.nextUrl.clone()); + url.pathname = `/preview/${protocol.id}`; + + const response: CompleteResponse = { + status: 'ready', + previewUrl: url.toString(), + }; + return jsonResponse(response); + } + + case 'abort-preview': { + const { protocolId } = body; + + // Find and delete the protocol + const protocol = await prisma.protocol.findUnique({ + where: { id: protocolId }, + }); + + if (!protocol) { + const response: AbortResponse = { + status: 'error', + message: 'Preview job not found', + }; + return jsonResponse(response, 404); + } + + // Delete the protocol (cascades to related entities) + await prisma.protocol.delete({ + where: { id: protocolId }, + }); + + void addEvent( + 'Protocol Uninstalled', + `Preview protocol "${protocol.name}" was aborted and removed`, + ); + + const response: AbortResponse = { + status: 'removed', + protocolId: protocolId, + }; + return jsonResponse(response); + } + } + } catch (e) { + const error = ensureError(e); + void trackEvent({ + type: 'Error', + message: error.message, + name: 'Preview API Error', + }); + + return jsonResponse( + { + status: 'error', + message: 'Failed to process preview request', + }, + 500, + ); + } +} diff --git a/app/api/preview/types.ts b/app/api/preview/types.ts new file mode 100644 index 000000000..f39094af6 --- /dev/null +++ b/app/api/preview/types.ts @@ -0,0 +1,84 @@ +/* + Types that cover the preview message exchange + Lives here and in Architect (/architect-vite/src/utils/preview/types.ts). + This must be kept in sync and updated in both places. + TODO: Move to shared pacakge when in the monorepo +*/ + +import { type VersionedProtocol } from '@codaco/protocol-validation'; + +// REQUEST TYPES +type AssetMetadata = { + assetId: string; + name: string; + size: number; +}; + +type InitializePreviewRequest = { + type: 'initialize-preview'; + protocol: VersionedProtocol; + assetMeta: AssetMetadata[]; + architectVersion: string; +}; + +type CompletePreviewRequest = { + type: 'complete-preview'; + protocolId: string; +}; + +type AbortPreviewRequest = { + type: 'abort-preview'; + protocolId: string; +}; + +export type PreviewRequest = + | InitializePreviewRequest + | CompletePreviewRequest + | AbortPreviewRequest; + +// RESPONSE TYPES + +type JobCreatedResponse = { + status: 'job-created'; + protocolId: string; + presignedUrls: string[]; +}; + +// No assets to upload +export type ReadyResponse = { + status: 'ready'; + previewUrl: string; +}; + +export type RejectedResponse = { + status: 'rejected'; + message: 'Invalid protocol'; +}; + +export type ErrorResponse = { + status: 'error'; + message: string; +}; + +type RemovedResponse = { + status: 'removed'; + protocolId: string; +}; + +export type InitializeResponse = + | JobCreatedResponse + | RejectedResponse + | ErrorResponse + | ReadyResponse; +export type CompleteResponse = ReadyResponse | ErrorResponse; +export type AbortResponse = RemovedResponse | ErrorResponse; + +export type PreviewResponse = + | InitializeResponse + | CompleteResponse + | AbortResponse; + +export type AuthError = { + response: ErrorResponse; + status: number; +}; diff --git a/app/api/test/clear-cache/route.ts b/app/api/test/clear-cache/route.ts new file mode 100644 index 000000000..3cf7f7c8c --- /dev/null +++ b/app/api/test/clear-cache/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server'; +import { CacheTags, safeRevalidateTag } from '~/lib/cache'; + +/** + * Test-only API endpoint to clear Next.js data cache. + * This is used by e2e tests to ensure fresh data after database mutations. + * Only available when SKIP_ENV_VALIDATION is set (test environment). + * + * Uses revalidateTag() to properly invalidate the in-memory cache, + * not just the filesystem cache. + */ +export function POST(): NextResponse { + // Only allow when running in test mode (SKIP_ENV_VALIDATION is set by test environment) + // eslint-disable-next-line no-process-env + if (process.env.SKIP_ENV_VALIDATION !== 'true') { + return NextResponse.json( + { error: 'Not available outside test environment' }, + { status: 403 }, + ); + } + + try { + // Revalidate all cache tags to clear the in-memory cache + const revalidatedTags: string[] = []; + + for (const tag of CacheTags) { + safeRevalidateTag(tag); + revalidatedTags.push(tag); + } + + return NextResponse.json({ + success: true, + message: 'Cache invalidated successfully', + revalidatedTags, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear cache', + }, + { status: 500 }, + ); + } +} diff --git a/app/api/uploadthing/route.ts b/app/api/uploadthing/route.ts index d0f78da24..cad055ab4 100644 --- a/app/api/uploadthing/route.ts +++ b/app/api/uploadthing/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from 'next/server'; import { createRouteHandler } from 'uploadthing/next'; -import { env } from '~/env'; import { getAppSetting } from '~/queries/appSettings'; +import { getBaseUrl } from '~/utils/getBaseUrl'; import { ourFileRouter } from './core'; /** @@ -23,8 +23,8 @@ const routeHandler = async () => { // UploadThing attempts to automatically detect this value based on the request URL and headers // However, the automatic detection fails in docker deployments // docs: https://docs.uploadthing.com/api-reference/server#config - callbackUrl: env.PUBLIC_URL && `${env.PUBLIC_URL}/api/uploadthing`, - token: uploadThingToken, + callbackUrl: `${getBaseUrl()}/api/uploadthing`, + token: uploadThingToken ?? undefined, }, }); diff --git a/app/dashboard/_components/ActivityFeed/ColumnDefinition.tsx b/app/dashboard/_components/ActivityFeed/ColumnDefinition.tsx index 1a4350beb..76105526f 100644 --- a/app/dashboard/_components/ActivityFeed/ColumnDefinition.tsx +++ b/app/dashboard/_components/ActivityFeed/ColumnDefinition.tsx @@ -1,17 +1,17 @@ 'use client'; +import type { Events } from '~/lib/db/generated/client'; import { type ColumnDef } from '@tanstack/react-table'; -import { Badge } from '~/components/ui/badge'; +import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; import { + type Activity, type ActivityType, + activityTypes, type DataTableFilterableColumn, type DataTableSearchableColumn, - type Activity, - activityTypes, -} from '~/lib/data-table/types'; -import type { Events } from '~/lib/db/generated/client'; +} from '~/components/DataTable/types'; +import { Badge } from '~/components/ui/badge'; import TimeAgo from '~/components/ui/TimeAgo'; -import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; import { getBadgeColorsForActivityType } from './utils'; export function fetchActivityFeedTableColumnDefs(): ColumnDef< @@ -27,9 +27,10 @@ export function fetchActivityFeedTableColumnDefs(): ColumnDef< cell: ({ row }) => { const timestamp: string = row.getValue('timestamp'); return ( -
- -
+ ); }, }, @@ -41,11 +42,7 @@ export function fetchActivityFeedTableColumnDefs(): ColumnDef< cell: ({ row }) => { const activityType: ActivityType = row.getValue('type'); const color = getBadgeColorsForActivityType(activityType); - return ( -
- {activityType} -
- ); + return {activityType}; }, enableSorting: false, enableHiding: false, diff --git a/app/dashboard/_components/ActivityFeed/SearchParams.ts b/app/dashboard/_components/ActivityFeed/SearchParams.ts index 924413770..ecf14b58e 100644 --- a/app/dashboard/_components/ActivityFeed/SearchParams.ts +++ b/app/dashboard/_components/ActivityFeed/SearchParams.ts @@ -1,11 +1,15 @@ import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsJson, - parseAsStringLiteral, + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsJson, + parseAsStringLiteral, } from 'nuqs/server'; -import { FilterParam, sortOrder, sortableFields } from '~/lib/data-table/types'; +import { + FilterParam, + sortOrder, + sortableFields, +} from '~/components/DataTable/types'; export const searchParamsParsers = { page: parseAsInteger.withDefault(1), @@ -17,4 +21,4 @@ export const searchParamsParsers = { ), }; -export const searchParamsCache = createSearchParamsCache(searchParamsParsers); \ No newline at end of file +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/app/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts b/app/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts index 30a29a400..ee4ff1d26 100644 --- a/app/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts +++ b/app/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts @@ -28,4 +28,4 @@ export const useTableStateFromSearchParams = () => { }, setSearchParams, }; -}; \ No newline at end of file +}; diff --git a/app/dashboard/_components/ActivityFeed/utils.ts b/app/dashboard/_components/ActivityFeed/utils.ts index a8213d0ca..9e0f87349 100644 --- a/app/dashboard/_components/ActivityFeed/utils.ts +++ b/app/dashboard/_components/ActivityFeed/utils.ts @@ -1,4 +1,4 @@ -import { type ActivityType } from '~/lib/data-table/types'; +import { type ActivityType } from '~/components/DataTable/types'; export const getBadgeColorsForActivityType = (type: ActivityType) => { switch (type.toLowerCase()) { diff --git a/app/dashboard/_components/InterviewsTable/Columns.tsx b/app/dashboard/_components/InterviewsTable/Columns.tsx index 84f71cbc6..d7044bf1b 100644 --- a/app/dashboard/_components/InterviewsTable/Columns.tsx +++ b/app/dashboard/_components/InterviewsTable/Columns.tsx @@ -1,6 +1,5 @@ 'use client'; -import type { Codebook, NcNetwork, Stage } from '@codaco/shared-consts'; import { type ColumnDef } from '@tanstack/react-table'; import Image from 'next/image'; import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; @@ -8,14 +7,17 @@ import { Badge } from '~/components/ui/badge'; import { Checkbox } from '~/components/ui/checkbox'; import { Progress } from '~/components/ui/progress'; import TimeAgo from '~/components/ui/TimeAgo'; -import type { GetInterviewsReturnType } from '~/queries/interviews'; +import type { GetInterviewsQuery } from '~/queries/interviews'; import NetworkSummary from './NetworkSummary'; export const InterviewColumns = (): ColumnDef< - Awaited[0] + Awaited[0] >[] => [ { id: 'select', + meta: { + className: 'sticky left-0', + }, header: ({ table }) => ( ; }, cell: ({ row }) => { - const date = new Date(row.original.startTime); - return ; + return ; }, }, { @@ -116,8 +117,7 @@ export const InterviewColumns = (): ColumnDef< return ; }, cell: ({ row }) => { - const date = new Date(row.original.lastUpdated); - return ; + return ; }, }, { @@ -132,20 +132,21 @@ export const InterviewColumns = (): ColumnDef< return ; }, cell: ({ row }) => { - const stages = row.original.protocol.stages! as unknown as Stage[]; + const stages = row.original.protocol.stages; const progress = (row.original.currentStep / stages.length) * 100; return (
-
{progress.toFixed(0)}%
+
{progress.toFixed(0)}%
); }, }, { id: 'network', + enableSorting: false, accessorFn: (row) => { - const network = row.network as NcNetwork; + const network = row.network; const nodeCount = network?.nodes?.length ?? 0; const edgeCount = network?.edges?.length ?? 0; return nodeCount + edgeCount; @@ -154,8 +155,8 @@ export const InterviewColumns = (): ColumnDef< return ; }, cell: ({ row }) => { - const network = row.original.network as NcNetwork; - const codebook = row.original.protocol.codebook as Codebook; + const network = row.original.network; + const codebook = row.original.protocol.codebook; return ; }, diff --git a/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx b/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx index 7e6ce7ad8..d6c1bef8a 100644 --- a/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx +++ b/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx @@ -3,6 +3,7 @@ import { HardDriveUpload } from 'lucide-react'; import { hash as objectHash } from 'ohash'; import { use, useMemo, useState } from 'react'; +import superjson from 'superjson'; import { ActionsDropdown } from '~/app/dashboard/_components/InterviewsTable/ActionsDropdown'; import { InterviewColumns } from '~/app/dashboard/_components/InterviewsTable/Columns'; import { DeleteInterviewsDialog } from '~/app/dashboard/interviews/_components/DeleteInterviewsDialog'; @@ -16,7 +17,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '~/components/ui/dropdown-menu'; -import type { GetInterviewsReturnType } from '~/queries/interviews'; +import type { + GetInterviewsQuery, + GetInterviewsReturnType, +} from '~/queries/interviews'; import type { GetProtocolsReturnType } from '~/queries/protocols'; export const InterviewsTable = ({ @@ -26,7 +30,8 @@ export const InterviewsTable = ({ interviewsPromise: GetInterviewsReturnType; protocolsPromise: GetProtocolsReturnType; }) => { - const interviews = use(interviewsPromise); + const serializedInterviews = use(interviewsPromise); + const interviews = superjson.parse(serializedInterviews); const [selectedInterviews, setSelectedInterviews] = useState(); diff --git a/app/dashboard/_components/InterviewsTable/NetworkSummary.tsx b/app/dashboard/_components/InterviewsTable/NetworkSummary.tsx index faa2e2cab..5520f5b62 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 { type Codebook } from '@codaco/protocol-validation'; +import type { NcNetwork } from '@codaco/shared-consts'; +import { type NodeColorSequence } from '~/lib/ui/components/Node'; 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'; - type EdgeColorSequence = | 'edge-color-seq-1' | 'edge-color-seq-2' @@ -33,7 +25,11 @@ type EdgeSummaryProps = { count: number; typeName: string; }; -function NodeSummary({ color, count, typeName }: NodeSummaryProps) { +function NodeSummary({ + color = 'node-color-seq-1', + count, + typeName, +}: NodeSummaryProps) { const classes = cn( 'flex items-center h-8 w-8 justify-center rounded-full', 'bg-linear-145 from-50% to-50%', @@ -93,13 +89,12 @@ function EdgeSummary({ color, count, typeName }: EdgeSummaryProps) { ); return ( -
-
+
+
@@ -136,9 +131,9 @@ function EdgeSummary({ color, count, typeName }: EdgeSummaryProps) {
- +
{typeName} ({count}) - +
); } @@ -159,12 +154,18 @@ const NetworkSummary = ({ return acc; }, {}) ?? {}, ).map(([nodeType, count]) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const nodeInfo = codebook.node?.[nodeType]!; + const nodeInfo = codebook.node?.[nodeType]; + + if (!nodeInfo) { + // eslint-disable-next-line no-console + console.warn(`Node type ${nodeType} not found in codebook`); + return null; + } + return ( @@ -177,8 +178,14 @@ const NetworkSummary = ({ return acc; }, {}) ?? {}, ).map(([edgeType, count]) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const edgeInfo = codebook.edge?.[edgeType]!; + const edgeInfo = codebook.edge?.[edgeType]; + + if (!edgeInfo) { + // eslint-disable-next-line no-console + console.warn(`Edge type ${edgeType} not found in codebook`); + return null; + } + return ( -
{nodeSummaries}
-
{edgeSummaries}
+
+ {nodeSummaries} + {edgeSummaries}
); }; diff --git a/app/dashboard/_components/NavigationBar.tsx b/app/dashboard/_components/NavigationBar.tsx index 7783aa907..3b93558f9 100644 --- a/app/dashboard/_components/NavigationBar.tsx +++ b/app/dashboard/_components/NavigationBar.tsx @@ -6,7 +6,7 @@ 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 Heading from '~/components/typography/Heading'; import { env } from '~/env'; import { cn } from '~/utils/shadcn'; import UserMenu from './UserMenu'; @@ -25,7 +25,7 @@ const NavButton = ({ @@ -34,7 +34,7 @@ const NavButton = ({ {isActive && ( )} @@ -45,9 +45,15 @@ export function NavigationBar() { const pathname = usePathname(); return ( - + - Fresco + Fresco Fresco {env.APP_VERSION} diff --git a/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx b/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx index bcf099ee2..0e28e5466 100644 --- a/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx +++ b/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx @@ -1,4 +1,8 @@ +import type { Participant } from '~/lib/db/generated/client'; +import type { Row } from '@tanstack/react-table'; import { MoreHorizontal } from 'lucide-react'; +import { useState } from 'react'; +import ParticipantModal from '~/app/dashboard/participants/_components/ParticipantModal'; import { Button } from '~/components/ui/Button'; import { DropdownMenu, @@ -7,11 +11,7 @@ import { 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'; +import type { ParticipantWithInterviews } from './ParticipantsTableClient'; export const ActionsDropdown = ({ row, diff --git a/app/dashboard/_components/ParticipantsTable/Columns.tsx b/app/dashboard/_components/ParticipantsTable/Columns.tsx index bd96e707a..b85d9ab04 100644 --- a/app/dashboard/_components/ParticipantsTable/Columns.tsx +++ b/app/dashboard/_components/ParticipantsTable/Columns.tsx @@ -1,19 +1,19 @@ 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 { InfoIcon } from 'lucide-react'; import Image from 'next/image'; +import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; 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 Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; import { buttonVariants } from '~/components/ui/Button'; import { Badge } from '~/components/ui/badge'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; +import { Checkbox } from '~/components/ui/checkbox'; +import type { ProtocolWithInterviews } from '../ProtocolsTable/ProtocolsTableClient'; +import { GenerateParticipationURLButton } from './GenerateParticipantURLButton'; +import type { ParticipantWithInterviews } from './ParticipantsTableClient'; export function getParticipantColumns( - protocols: Awaited, + protocols: ProtocolWithInterviews[], ): ColumnDef[] { return [ { diff --git a/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx b/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx index 62f7b2646..0dfca0b74 100644 --- a/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx +++ b/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx @@ -12,20 +12,21 @@ import { import { PopoverTrigger } from '@radix-ui/react-popover'; import { Check, Copy } from 'lucide-react'; +import Paragraph from '~/components/typography/Paragraph'; 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'; +import type { ProtocolWithInterviews } from '../ProtocolsTable/ProtocolsTableClient'; export const GenerateParticipationURLButton = ({ participant, protocols, }: { participant: Participant; - protocols: Awaited; + protocols: ProtocolWithInterviews[]; }) => { - const [selectedProtocol, setSelectedProtocol] = useState(); + const [selectedProtocol, setSelectedProtocol] = + useState | null>(); const { toast } = useToast(); @@ -69,7 +70,7 @@ export const GenerateParticipationURLButton = ({ onValueChange={(value) => { const protocol = protocols.find( (protocol) => protocol.id === value, - ); + ) as Protocol; setSelectedProtocol(protocol); handleCopy( diff --git a/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx b/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx index 18463d060..1635a417f 100644 --- a/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx +++ b/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx @@ -3,6 +3,7 @@ import { type ColumnDef } from '@tanstack/react-table'; import { Trash } from 'lucide-react'; import { use, useCallback, useMemo, useState } from 'react'; +import SuperJSON from 'superjson'; import { deleteAllParticipants, deleteParticipants, @@ -12,12 +13,19 @@ import { getParticipantColumns } from '~/app/dashboard/_components/ParticipantsT 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 type { + GetParticipantsQuery, + GetParticipantsReturnType, +} from '~/queries/participants'; +import type { + GetProtocolsQuery, + GetProtocolsReturnType, +} from '~/queries/protocols'; import AddParticipantButton from '../../participants/_components/AddParticipantButton'; import { GenerateParticipantURLs } from '../../participants/_components/ExportParticipants/GenerateParticipantURLsButton'; +export type ParticipantWithInterviews = GetParticipantsQuery[number]; + export const ParticipantsTableClient = ({ participantsPromise, protocolsPromise, @@ -25,8 +33,10 @@ export const ParticipantsTableClient = ({ participantsPromise: GetParticipantsReturnType; protocolsPromise: GetProtocolsReturnType; }) => { - const participants = use(participantsPromise); - const protocols = use(protocolsPromise); + const rawParticiapnts = use(participantsPromise); + const rawProtocols = use(protocolsPromise); + const participants = SuperJSON.parse(rawParticiapnts); + const protocols = SuperJSON.parse(rawProtocols); // Memoize the columns so they don't re-render on every render const columns = useMemo[]>( diff --git a/app/dashboard/_components/ProtocolUploader.tsx b/app/dashboard/_components/ProtocolUploader.tsx index c7feb1ad8..795edc0c5 100644 --- a/app/dashboard/_components/ProtocolUploader.tsx +++ b/app/dashboard/_components/ProtocolUploader.tsx @@ -58,8 +58,8 @@ function ProtocolUploader({ className={cn( isActive && cn( - 'bg-linear-to-r from-cyber-grape via-neon-coral to-cyber-grape text-white', - 'pointer-events-none animate-background-gradient cursor-wait bg-[length:400%]', + 'from-cyber-grape via-neon-coral to-cyber-grape bg-linear-to-r text-white', + 'animate-background-gradient pointer-events-none cursor-wait bg-[length:400%]', ), className, )} @@ -80,7 +80,7 @@ function ProtocolUploader({ diff --git a/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx b/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx index c2dabc44d..64c3cc840 100644 --- a/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx +++ b/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx @@ -1,6 +1,9 @@ 'use client'; +import type { Row } from '@tanstack/react-table'; import { MoreHorizontal } from 'lucide-react'; +import { useState } from 'react'; +import { DeleteProtocolsDialog } from '~/app/dashboard/protocols/_components/DeleteProtocolsDialog'; import { Button } from '~/components/ui/Button'; import { DropdownMenu, @@ -9,10 +12,7 @@ import { 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'; +import type { ProtocolWithInterviews } from './ProtocolsTableClient'; export const ActionsDropdown = ({ row, diff --git a/app/dashboard/_components/ProtocolsTable/Columns.tsx b/app/dashboard/_components/ProtocolsTable/Columns.tsx index 2074deb1f..aa716f748 100644 --- a/app/dashboard/_components/ProtocolsTable/Columns.tsx +++ b/app/dashboard/_components/ProtocolsTable/Columns.tsx @@ -1,18 +1,18 @@ '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 { InfoIcon } from 'lucide-react'; import Image from 'next/image'; -import { buttonVariants } from '~/components/ui/Button'; +import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; 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 Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; +import { buttonVariants } from '~/components/ui/Button'; +import { Checkbox } from '~/components/ui/checkbox'; +import TimeAgo from '~/components/ui/TimeAgo'; +import { AnonymousRecruitmentURLButton } from './AnonymousRecruitmentURLButton'; +import type { ProtocolWithInterviews } from './ProtocolsTableClient'; export const getProtocolColumns = ( allowAnonRecruitment = false, diff --git a/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx index b49876875..8b41eba56 100644 --- a/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -1,4 +1,3 @@ -import { unstable_noStore } from 'next/cache'; import { Suspense } from 'react'; import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; import { getAppSetting } from '~/queries/appSettings'; @@ -6,8 +5,6 @@ import { getProtocols } from '~/queries/protocols'; import ProtocolsTableClient from './ProtocolsTableClient'; async function getData() { - unstable_noStore(); - return Promise.all([ getProtocols(), getAppSetting('allowAnonymousRecruitment'), diff --git a/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx b/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx index 4159b33c3..b180897d6 100644 --- a/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx +++ b/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx @@ -1,17 +1,21 @@ 'use client'; import { use, useState } from 'react'; +import { SuperJSON } from 'superjson'; import { DeleteProtocolsDialog } from '~/app/dashboard/protocols/_components/DeleteProtocolsDialog'; import { DataTable } from '~/components/DataTable/DataTable'; -import type { ProtocolWithInterviews } from '~/types/types'; +import type { GetProtocolsQuery } from '~/queries/protocols'; 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] = + const [rawProtocols, allowAnonymousRecruitment, hasUploadThingToken] = use(dataPromise); + const protocols = SuperJSON.parse(rawProtocols); const [showAlertDialog, setShowAlertDialog] = useState(false); const [protocolsToDelete, setProtocolsToDelete] = diff --git a/app/dashboard/_components/RecruitmentTestSection.tsx b/app/dashboard/_components/RecruitmentTestSection.tsx index 3d5b31394..0646cc70e 100644 --- a/app/dashboard/_components/RecruitmentTestSection.tsx +++ b/app/dashboard/_components/RecruitmentTestSection.tsx @@ -3,6 +3,7 @@ import type { Participant, Protocol } from '~/lib/db/generated/client'; import { type Route } from 'next'; import { useRouter } from 'next/navigation'; import { use, useEffect, useState } from 'react'; +import { SuperJSON } from 'superjson'; import { Button } from '~/components/ui/Button'; import { Select, @@ -11,8 +12,14 @@ import { SelectTrigger, SelectValue, } from '~/components/ui/select'; -import { type GetParticipantsReturnType } from '~/queries/participants'; -import { type GetProtocolsReturnType } from '~/queries/protocols'; +import { + type GetParticipantsQuery, + type GetParticipantsReturnType, +} from '~/queries/participants'; +import { + type GetProtocolsQuery, + type GetProtocolsReturnType, +} from '~/queries/protocols'; export default function RecruitmentTestSection({ protocolsPromise, @@ -23,11 +30,13 @@ export default function RecruitmentTestSection({ participantsPromise: GetParticipantsReturnType; allowAnonymousRecruitmentPromise: Promise; }) { - 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(); @@ -56,7 +65,7 @@ export default function RecruitmentTestSection({ onValueChange={(value) => { const protocol = protocols.find( (protocol) => protocol.id === value, - ); + ) as Protocol; setSelectedProtocol(protocol); }} diff --git a/app/dashboard/_components/RecruitmentTestSectionServer.tsx b/app/dashboard/_components/RecruitmentTestSectionServer.tsx index 0a3fc5ec7..8a36f468f 100644 --- a/app/dashboard/_components/RecruitmentTestSectionServer.tsx +++ b/app/dashboard/_components/RecruitmentTestSectionServer.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react'; import SettingsSection from '~/components/layout/SettingsSection'; -import Paragraph from '~/components/ui/typography/Paragraph'; +import Paragraph from '~/components/typography/Paragraph'; import { getAppSetting } from '~/queries/appSettings'; import { getParticipants } from '~/queries/participants'; import { getProtocols } from '~/queries/protocols'; diff --git a/app/dashboard/_components/SummaryStatistics/Icons.tsx b/app/dashboard/_components/SummaryStatistics/Icons.tsx index 5a92bfb0b..abe0ce19d 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..10e453e9f 100644 --- a/app/dashboard/_components/SummaryStatistics/StatCard.tsx +++ b/app/dashboard/_components/SummaryStatistics/StatCard.tsx @@ -1,6 +1,6 @@ import { use } from 'react'; +import Heading from '~/components/typography/Heading'; import { Skeleton } from '~/components/ui/skeleton'; -import Heading from '~/components/ui/typography/Heading'; import { cn } from '~/utils/shadcn'; const statCardClasses = cn( diff --git a/app/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx b/app/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx index e2cf2012a..c0b11195f 100644 --- a/app/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx +++ b/app/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx @@ -1,7 +1,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { Suspense } from 'react'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; +import ResponsiveContainer from '~/components/layout/ResponsiveContainer'; import { getSummaryStatistics } from '~/queries/summaryStatistics'; import { InterviewIcon, ProtocolIcon } from './Icons'; import StatCard, { StatCardSkeleton } from './StatCard'; diff --git a/app/dashboard/_components/UpdateSettingsValue.tsx b/app/dashboard/_components/UpdateSettingsValue.tsx index df50e22bd..0680302c1 100644 --- a/app/dashboard/_components/UpdateSettingsValue.tsx +++ b/app/dashboard/_components/UpdateSettingsValue.tsx @@ -1,20 +1,22 @@ import { Loader2 } from 'lucide-react'; import { useState } from 'react'; -import { type z } from 'zod'; +import type z from 'zod'; +import { setAppSetting } from '~/actions/appSettings'; import { Button } from '~/components/ui/Button'; import { Input } from '~/components/ui/Input'; +import { type AppSetting } from '~/schemas/appSettings'; import ReadOnlyEnvAlert from '../settings/ReadOnlyEnvAlert'; -export default function UpdateSettingsValue({ +export default function UpdateSettingsValue({ + key, initialValue, - updateValue, - schema, readOnly, + schema, }: { - initialValue?: T; - updateValue: (value: T) => Promise; - schema: z.ZodSchema; + key: AppSetting; + initialValue?: string; readOnly?: boolean; + schema: z.ZodType; }) { const [newValue, setNewValue] = useState(initialValue); const [error, setError] = useState(null); @@ -28,7 +30,7 @@ export default function UpdateSettingsValue({ 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,7 +49,7 @@ export default function UpdateSettingsValue({ if (!newValue) return; setSaving(true); - await updateValue(newValue); + await setAppSetting(key, newValue); setSaving(false); }; diff --git a/app/dashboard/_components/UploadThingModal.tsx b/app/dashboard/_components/UploadThingModal.tsx index 3c47e4ffa..888e70e2e 100644 --- a/app/dashboard/_components/UploadThingModal.tsx +++ b/app/dashboard/_components/UploadThingModal.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import { setAppSetting } from '~/actions/appSettings'; import { UploadThingTokenForm } from '~/app/(blobs)/(setup)/_components/UploadThingTokenForm'; import Link from '~/components/Link'; +import Paragraph from '~/components/typography/Paragraph'; import { Dialog, DialogContent, @@ -13,7 +14,6 @@ import { DialogTitle, } from '~/components/ui/dialog'; import { Divider } from '~/components/ui/Divider'; -import Paragraph from '~/components/ui/typography/Paragraph'; function UploadThingModal() { const [open, setOpen] = useState(true); @@ -36,7 +36,7 @@ function UploadThingModal() { Updating the key should take a matter of minutes, and can be completed using the following steps: -
    +
    1. Visit the{' '} diff --git a/app/dashboard/interviews/_components/ExportCSVInterviewURLs.tsx b/app/dashboard/interviews/_components/ExportCSVInterviewURLs.tsx index bd3631a60..45017949b 100644 --- a/app/dashboard/interviews/_components/ExportCSVInterviewURLs.tsx +++ b/app/dashboard/interviews/_components/ExportCSVInterviewURLs.tsx @@ -6,15 +6,15 @@ import { useState } from 'react'; import { Button } from '~/components/ui/Button'; import { useToast } from '~/components/ui/use-toast'; import { useDownload } from '~/hooks/useDownload'; -import type { GetInterviewsReturnType } from '~/queries/interviews'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; +import type { GetInterviewsQuery } from '~/queries/interviews'; +import type { ProtocolWithInterviews } from '../../_components/ProtocolsTable/ProtocolsTableClient'; function ExportCSVInterviewURLs({ protocol, interviews, }: { - protocol?: Awaited[number]; - interviews: Awaited; + protocol?: ProtocolWithInterviews; + interviews: Awaited; }) { const download = useDownload(); const [isExporting, setIsExporting] = useState(false); diff --git a/app/dashboard/interviews/_components/ExportInterviewsDialog.tsx b/app/dashboard/interviews/_components/ExportInterviewsDialog.tsx index 2a70f53af..3c628e60b 100644 --- a/app/dashboard/interviews/_components/ExportInterviewsDialog.tsx +++ b/app/dashboard/interviews/_components/ExportInterviewsDialog.tsx @@ -2,12 +2,15 @@ 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 superjson from 'superjson'; import { exportSessions, + type FormattedProtocols, prepareExportData, updateExportTime, } from '~/actions/interviews'; import { deleteZipFromUploadThing } from '~/actions/uploadThing'; +import Heading from '~/components/typography/Heading'; import { Button } from '~/components/ui/Button'; import { cardClasses } from '~/components/ui/card'; import { @@ -17,19 +20,21 @@ import { 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 { + ExportOptionsSchema, + type FormattedSession, +} from '~/lib/network-exporters/utils/types'; import { ensureError } from '~/utils/ensureError'; import { cn } from '~/utils/shadcn'; import ExportOptionsView from './ExportOptionsView'; const ExportingStateAnimation = () => { return ( -
      +
      (formattedSessions); + const parsedFormattedProtocols = + superjson.parse(formattedProtocols); + // export the data const { zipUrl, zipKey, status, error } = await exportSessions( - formattedSessions, - formattedProtocols, + parsedFormattedSessions, + parsedFormattedProtocols, interviewIds, exportOptions, ); diff --git a/app/dashboard/interviews/_components/ExportOptionsView.tsx b/app/dashboard/interviews/_components/ExportOptionsView.tsx index 23f5fb163..634fcca6a 100644 --- a/app/dashboard/interviews/_components/ExportOptionsView.tsx +++ b/app/dashboard/interviews/_components/ExportOptionsView.tsx @@ -1,8 +1,8 @@ import { type Dispatch, type SetStateAction } from 'react'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; 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'; diff --git a/app/dashboard/interviews/_components/GenerateInterviewURLs.tsx b/app/dashboard/interviews/_components/GenerateInterviewURLs.tsx index 5b774954c..479be4e8a 100644 --- a/app/dashboard/interviews/_components/GenerateInterviewURLs.tsx +++ b/app/dashboard/interviews/_components/GenerateInterviewURLs.tsx @@ -2,6 +2,7 @@ import { FileUp } from 'lucide-react'; import { use, useEffect, useState } from 'react'; +import superjson from 'superjson'; import { Button } from '~/components/ui/Button'; import { Dialog, @@ -19,18 +20,22 @@ import { SelectValue, } from '~/components/ui/select'; import { Skeleton } from '~/components/ui/skeleton'; -import type { GetInterviewsReturnType } from '~/queries/interviews'; -import type { GetProtocolsReturnType } from '~/queries/protocols'; +import type { GetInterviewsQuery } from '~/queries/interviews'; +import type { + GetProtocolsQuery, + GetProtocolsReturnType, +} from '~/queries/protocols'; import ExportCSVInterviewURLs from './ExportCSVInterviewURLs'; export const GenerateInterviewURLs = ({ interviews, protocolsPromise, }: { - interviews: Awaited; + interviews: Awaited; protocolsPromise: GetProtocolsReturnType; }) => { - const protocols = use(protocolsPromise); + const rawProtocols = use(protocolsPromise); + const protocols = superjson.parse(rawProtocols); const [interviewsToExport, setInterviewsToExport] = useState< typeof interviews @@ -81,7 +86,7 @@ export const GenerateInterviewURLs = ({
      {!protocols ? ( - + ) : ( setNewTokenDescription(e.target.value)} + /> +
      +
      + + + + + + + + {/* Show Created Token Dialog */} + setCreatedToken(null)}> + + + API Token Created + + Save this token securely. You won't be able to see it again. + + + + Your API Token + + + {createdToken} + + + + + + + + + +
      + ); +} 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..43edf6a9f 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/CloseButton.tsx b/components/CloseButton.tsx new file mode 100644 index 000000000..a15d18e93 --- /dev/null +++ b/components/CloseButton.tsx @@ -0,0 +1,23 @@ +import { X } from 'lucide-react'; +import { type ComponentProps } from 'react'; +import { cn } from '~/utils/shadcn'; +import { Button } from './ui/Button'; + +type CloseButtonProps = { + className?: string; +} & ComponentProps; + +export default function CloseButton(props: CloseButtonProps) { + const { className, ...rest } = props; + return ( + + ); +} diff --git a/components/DataTable/ColumnHeader.tsx b/components/DataTable/ColumnHeader.tsx index b81749d0f..ebf0de4a8 100644 --- a/components/DataTable/ColumnHeader.tsx +++ b/components/DataTable/ColumnHeader.tsx @@ -1,5 +1,5 @@ -import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; import { type Column } from '@tanstack/react-table'; +import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; import { Button, buttonVariants } from '~/components/ui/Button'; import { @@ -25,7 +25,7 @@ export function DataTableColumnHeader({
      @@ -35,15 +35,15 @@ export function DataTableColumnHeader({ } return ( -
      +
      -
      diff --git a/components/DataTable/DataTable.tsx b/components/DataTable/DataTable.tsx index 41b0367c6..663572849 100644 --- a/components/DataTable/DataTable.tsx +++ b/components/DataTable/DataTable.tsx @@ -169,57 +169,50 @@ export function DataTable({ {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.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. + + + )} + +
      diff --git a/lib/data-table/types.ts b/components/DataTable/types.ts similarity index 95% rename from lib/data-table/types.ts rename to components/DataTable/types.ts index c2c657c30..1cca3ef57 100644 --- a/lib/data-table/types.ts +++ b/components/DataTable/types.ts @@ -35,6 +35,10 @@ export const activityTypes = [ 'Interview Completed', 'Interview(s) Deleted', 'Data Exported', + 'API Token Created', + 'API Token Updated', + 'API Token Deleted', + 'Preview Mode', ] as const; export type ActivityType = (typeof activityTypes)[number]; diff --git a/components/DynamicLucideIcon.tsx b/components/DynamicLucideIcon.tsx new file mode 100644 index 000000000..542380f31 --- /dev/null +++ b/components/DynamicLucideIcon.tsx @@ -0,0 +1,38 @@ +import { type LucideProps, icons } from 'lucide-react'; + +type IconComponentName = keyof typeof icons; + +type IconProps = { + name: string; // because this is coming from the CMS +} & LucideProps; + +// 👮‍♀️ guard +function isValidIconComponent( + componentName: string, +): componentName is IconComponentName { + return componentName in icons; +} + +// This is a workaround to issues with lucide-react/dynamicIconImports found at https://github.com/lucide-icons/lucide/issues/1576#issuecomment-2335019821 +export default function DynamicLucideIcon({ name, ...props }: IconProps) { + // we need to convert kebab-case to PascalCase because we formerly relied on + // lucide-react/dynamicIconImports and the icon names are what are stored in the CMS. + const kebabToPascal = (str: string) => + str + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + + const componentName = kebabToPascal(name); + + // ensure what is in the CMS is a valid icon component + if (!isValidIconComponent(componentName)) { + return null; + } + + // lucide-react/dynamicIconImports makes makes NextJS development server very slow + // https://github.com/lucide-icons/lucide/issues/1576 + const Icon = icons[componentName]; + + return ; +} diff --git a/components/ErrorDetails.tsx b/components/ErrorDetails.tsx index bcbae1e9a..c365b1f36 100644 --- a/components/ErrorDetails.tsx +++ b/components/ErrorDetails.tsx @@ -1,12 +1,12 @@ +import { ChevronDown, ChevronUp } from 'lucide-react'; import { type ReactNode, useState } from 'react'; +import CopyDebugInfoButton from './CopyDebugInfoButton'; +import Heading from './typography/Heading'; 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, @@ -33,7 +33,7 @@ export const ErrorDetails = ({ )} - + {children} diff --git a/components/ErrorReportNotifier.tsx b/components/ErrorReportNotifier.tsx index 7b352618d..17abd5a01 100644 --- a/components/ErrorReportNotifier.tsx +++ b/components/ErrorReportNotifier.tsx @@ -13,12 +13,12 @@ type ReportStates = 'idle' | 'loading' | 'success' | 'error'; function ReportNotifier({ state = 'idle' }: { state?: ReportStates }) { return ( -
      +
      {state === 'loading' && ( - + Sent analytics data! )} @@ -50,7 +50,7 @@ function ReportNotifier({ state = 'idle' }: { state?: ReportStates }) { animate="visible" exit="exit" > - + Error sending analytics data. )} diff --git a/components/PreviewModeAuthSwitch.tsx b/components/PreviewModeAuthSwitch.tsx new file mode 100644 index 000000000..94727a311 --- /dev/null +++ b/components/PreviewModeAuthSwitch.tsx @@ -0,0 +1,24 @@ +import { setAppSetting } from '~/actions/appSettings'; +import { getAppSetting } from '~/queries/appSettings'; +import SwitchWithOptimisticUpdate from './SwitchWithOptimisticUpdate'; + +const PreviewModeAuthSwitch = async () => { + const previewModeRequireAuth = await getAppSetting('previewModeRequireAuth'); + + if (previewModeRequireAuth === null) { + return null; + } + + return ( + { + 'use server'; + await setAppSetting('previewModeRequireAuth', value); + return value; + }} + /> + ); +}; + +export default PreviewModeAuthSwitch; diff --git a/components/ProtocolImport/JobCard.tsx b/components/ProtocolImport/JobCard.tsx index e2b306a6b..cbab66941 100644 --- a/components/ProtocolImport/JobCard.tsx +++ b/components/ProtocolImport/JobCard.tsx @@ -2,11 +2,11 @@ import { CheckCircle, Loader2, XCircle } from 'lucide-react'; import { motion } from 'motion/react'; import { forwardRef, useEffect, useState } from 'react'; import { cn } from '~/utils/shadcn'; +import Heading from '../typography/Heading'; +import Paragraph from '../typography/Paragraph'; import { Button } from '../ui/Button'; import { CloseButton } from '../ui/CloseButton'; import ErrorDialog from '../ui/ErrorDialog'; -import Heading from '../ui/typography/Heading'; -import Paragraph from '../ui/typography/Paragraph'; import { type ImportJob } from './JobReducer'; type JobCardProps = { @@ -38,7 +38,7 @@ const JobCard = forwardRef(
    2. ( {!(isComplete || error) && ( )} - {isComplete && } - {error && } + {isComplete && } + {error && } {children}; +} diff --git a/components/Providers/index.tsx b/components/Providers/index.tsx new file mode 100644 index 000000000..35daee5a2 --- /dev/null +++ b/components/Providers/index.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { MotionConfig } from 'motion/react'; +import { type ReactNode } from 'react'; +import DialogProvider from '~/lib/dialogs/DialogProvider'; +import { Toaster } from '../ui/toaster'; +import RadixDirectionProvider from './RadixDirectionProvider'; + +export default function Providers({ children }: { children: ReactNode }) { + return ( + + + {children} + + + + ); +} diff --git a/components/VersionSection.tsx b/components/VersionSection.tsx index 866cd17d9..a24c53c18 100644 --- a/components/VersionSection.tsx +++ b/components/VersionSection.tsx @@ -9,9 +9,9 @@ import trackEvent from '~/lib/analytics'; import { ensureError } from '~/utils/ensureError'; import { getSemverUpdateType, semverSchema } from '~/utils/semVer'; import SettingsSection from './layout/SettingsSection'; +import Heading from './typography/Heading'; +import Paragraph from './typography/Paragraph'; import { Button } from './ui/Button'; -import Heading from './ui/typography/Heading'; -import Paragraph from './ui/typography/Paragraph'; const GithubApiResponseSchema = z .object({ @@ -130,7 +130,7 @@ export default async function VersionSection() { upgrade documentation. -
      +
      {releaseNotes}
      diff --git a/components/data-table/advanced/data-table-advanced-filter.tsx b/components/data-table/advanced/data-table-advanced-filter.tsx index cfba5d1b6..74b8cdd83 100644 --- a/components/data-table/advanced/data-table-advanced-filter.tsx +++ b/components/data-table/advanced/data-table-advanced-filter.tsx @@ -1,6 +1,7 @@ import { ChevronDown, ChevronsUpDown, Plus, TextIcon } from 'lucide-react'; import * as React from 'react'; +import { type DataTableFilterOption } from '~/components/DataTable/types'; import { Button } from '~/components/ui/Button'; import { Command, @@ -16,7 +17,6 @@ import { PopoverContent, PopoverTrigger, } from '~/components/ui/popover'; -import { type DataTableFilterOption } from '~/lib/data-table/types'; type DataTableAdvancedFilterProps = { options: DataTableFilterOption[]; diff --git a/components/data-table/advanced/data-table-advanced-toolbar.tsx b/components/data-table/advanced/data-table-advanced-toolbar.tsx index 31f29516c..d6102b6d9 100644 --- a/components/data-table/advanced/data-table-advanced-toolbar.tsx +++ b/components/data-table/advanced/data-table-advanced-toolbar.tsx @@ -1,17 +1,17 @@ 'use client'; -import * as React from 'react'; import type { Table } from '@tanstack/react-table'; +import * as React from 'react'; -import { Button } from '~/components/ui/Button'; -import { Input } from '~/components/ui/Input'; +import { ChevronsUpDown, Plus } from 'lucide-react'; import { DataTableAdvancedFilter } from '~/components/data-table/advanced/data-table-advanced-filter'; import type { DataTableFilterOption, DataTableFilterableColumn, DataTableSearchableColumn, -} from '~/lib/data-table/types'; -import { ChevronsUpDown, Plus } from 'lucide-react'; +} from '~/components/DataTable/types'; +import { Button } from '~/components/ui/Button'; +import { Input } from '~/components/ui/Input'; type DataTableAdvancedToolbarProps = { dataTable: Table; @@ -53,7 +53,7 @@ export function DataTableAdvancedToolbar({ }, [filterableColumns, searchableColumns]); return ( -
      + <>
      {searchableColumns.length > 0 && @@ -126,6 +126,6 @@ export function DataTableAdvancedToolbar({
      ) : null} -
      + ); } diff --git a/components/data-table/data-table-faceted-filter.tsx b/components/data-table/data-table-faceted-filter.tsx index cdff64fa1..d01e3e57b 100644 --- a/components/data-table/data-table-faceted-filter.tsx +++ b/components/data-table/data-table-faceted-filter.tsx @@ -1,6 +1,7 @@ import { type Column } from '@tanstack/react-table'; import { Check, PlusCircle } from 'lucide-react'; import { getBadgeColorsForActivityType } from '~/app/dashboard/_components/ActivityFeed/utils'; +import { type Option } from '~/components/DataTable/types'; import { Badge } from '~/components/ui/badge'; import { Button } from '~/components/ui/Button'; import { @@ -18,7 +19,6 @@ import { PopoverTrigger, } from '~/components/ui/popover'; import { Separator } from '~/components/ui/separator'; -import { type Option } from '~/lib/data-table/types'; import { cn } from '~/utils/shadcn'; type DataTableFacetedFilter = { @@ -105,7 +105,7 @@ export function DataTableFacetedFilter({ >
      ({
      {option.icon && (
      + + ); + }, +}; diff --git a/lib/dnd/stories/DragAndDrop.stories.tsx b/lib/dnd/stories/DragAndDrop.stories.tsx new file mode 100644 index 000000000..fee8e70cb --- /dev/null +++ b/lib/dnd/stories/DragAndDrop.stories.tsx @@ -0,0 +1,339 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { + DndStoreProvider, + useDragSource, + useDropTarget, + type DragMetadata, +} from '~/lib/dnd'; +import { cn } from '~/utils/shadcn'; + +type Item = { + id: string; + name: string; + type: 'fruit' | 'vegetable' | 'protein'; +}; + +const initialItems: Item[] = [ + { id: '1', name: 'Apple', type: 'fruit' }, + { id: '2', name: 'Banana', type: 'fruit' }, + { id: '3', name: 'Orange', type: 'fruit' }, + { id: '4', name: 'Carrot', type: 'vegetable' }, + { id: '5', name: 'Broccoli', type: 'vegetable' }, + { id: '6', name: 'Spinach', type: 'vegetable' }, + { id: '7', name: 'Chicken', type: 'protein' }, + { id: '8', name: 'Fish', type: 'protein' }, +]; + +type ItemStore = Record; + +function DraggableItem({ item }: { item: Item }) { + const { dragProps, isDragging } = useDragSource({ + type: item.type, + metadata: { + ...item, + }, + announcedName: item.name, // For screen reader announcements + // Custom preview for fruits + preview: + item.type === 'fruit' ? ( +
      + 🍎 {item.name} +
      + ) : undefined, // Use default (cloned element) for other types + }); + + return ( +
      + {item.name} +
      + ); +} + +function DropZone({ + title, + acceptTypes, + items, + onItemReceived, + children, +}: { + title: string; + acceptTypes: string[]; + items: Item[]; + onItemReceived: (metadata?: DragMetadata) => void; + children?: React.ReactNode; +}) { + const { dropProps, willAccept, isOver, isDragging } = useDropTarget({ + id: `dropzone-${title.toLowerCase().replace(/\s+/g, '-')}`, + accepts: acceptTypes, + announcedName: title, // For screen reader announcements + onDrop: onItemReceived, + onDragEnter: () => { + // Drag entered + }, + onDragLeave: () => { + // Drag left + }, + }); + + return ( +
      +

      {title}

      + {items.length === 0 && !children ? ( +

      + Drop {acceptTypes.join(' or ')} items here +

      + ) : ( + <> + {children} +
      + {items.map((item) => ( + + ))} +
      + + )} +
      + ); +} + +function ScrollableContainer({ children }: { children: React.ReactNode }) { + return ( +
      +

      Scrollable Container

      +

      + This demonstrates dragging from/to scrollable containers +

      + {children} +
      +

      + Scroll content to test auto-scroll during drag +

      +
      + ); +} + +function DragDropExample() { + // State to track items in different zones + const [itemStore, setItemStore] = useState({ + source: initialItems, + fruits: [], + vegetables: [], + proteins: [], + mixed: [], + scrollable: [ + { id: 's1', name: 'Scrolled Apple', type: 'fruit' }, + { + id: 's2', + name: 'Scrolled Tomato', + type: 'vegetable', + }, + ], + }); + + const moveItem = (item: Item, fromZone: string, toZone: string) => { + setItemStore((prev) => { + const newStore = { ...prev }; + + // Remove from source zone + const sourceItems = newStore[fromZone] ?? []; + newStore[fromZone] = sourceItems.filter((i) => i.id !== item.id); + + // Add to target zone + newStore[toZone] ??= []; + const targetItems = newStore[toZone]; + newStore[toZone] = [...targetItems, item]; + + return newStore; + }); + }; + + const handleItemReceived = + (targetZone: string) => (metadata?: DragMetadata) => { + if (!metadata) return; + const item = findItemById(metadata.id as string); + + // Find source zone by id + const sourceZone = Object.keys(itemStore).find((zone) => + itemStore[zone]?.some((i) => i.id === metadata.id), + ); + + if (item && sourceZone && sourceZone !== targetZone) { + moveItem(item, sourceZone, targetZone); + } + }; + + const findItemById = (id: string): Item | null => { + for (const items of Object.values(itemStore)) { + const found = items.find((item) => item.id === id); + if (found) return found; + } + return null; + }; + + return ( + +
      +
      +

      Instructions

      +
        +
      • + Mouse/Touch: Drag items between zones +
      • +
      • + Keyboard: Tab to focus items, Space/Enter to + start drag, Arrow keys to navigate drop zones, Space/Enter to + drop, Escape to cancel +
      • +
      • + Visual Feedback: Success borders = valid drop + zones, Destructive borders = invalid zones +
      • +
      • + Type Restrictions: Each zone accepts specific + item types +
      • +
      +
      + +
      +
      + + + + +
      +
      +
      + + + + +
      +
      +
      +
      +
      + ); +} + +const meta: Meta = { + title: 'Systems/DragAndDrop', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +# Drag and Drop System + +A comprehensive drag and drop system with full accessibility support, type safety, and visual feedback. + +## Features + +- 🎯 **Type-safe drag operations** - Restrict which items can be dropped where +- ♿ **Full accessibility** - Keyboard navigation and screen reader support +- 🎨 **Custom drag previews** - Show custom UI while dragging +- 📱 **Touch support** - Works on mobile and desktop +- 🔄 **Auto-scroll** - Scroll containers automatically during drag +- 🎭 **Visual feedback** - Clear indication of valid/invalid drop zones + +## Architecture + +The system uses React Context (DndStoreProvider) to manage drag state globally, with two main hooks: + +- \`useDragSource\` - Makes elements draggable +- \`useDropTarget\` - Creates drop zones + +## Usage + +\`\`\`tsx +// Wrap your app with the provider + + + + +// Make an element draggable +const { dragProps, isDragging } = useDragSource({ + type: 'item', + metadata: { id: '1', name: 'Item 1' }, + announcedName: 'Item 1', +}); + +// Create a drop zone +const { dropProps, isOver, willAccept } = useDropTarget({ + id: 'drop-zone-1', + accepts: ['item'], + onDrop: (metadata) => console.log('Dropped:', metadata), +}); +\`\`\` + `, + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const MainExample: Story = { + name: 'Complete Example', + render: () => , +}; diff --git a/lib/dnd/stories/DragSource.stories.tsx b/lib/dnd/stories/DragSource.stories.tsx new file mode 100644 index 000000000..e13bd06db --- /dev/null +++ b/lib/dnd/stories/DragSource.stories.tsx @@ -0,0 +1,263 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { + DndStoreProvider, + useDragSource, + useDropTarget, + type DragMetadata, +} from '..'; + +// Simple draggable item component +function DraggableItem({ + id, + type, + children, + preview, + style = {}, +}: { + id: string; + type: string; + children: React.ReactNode; + preview?: React.ReactNode; + style?: React.CSSProperties; +}) { + const { dragProps, isDragging } = useDragSource({ + type, + metadata: { type, id }, + preview, + announcedName: `${type} item ${id}`, + }); + + return ( +
      + {children} +
      + ); +} + +// Simple drop zone +function DropZone({ + accepts, + children, + onDrop, +}: { + accepts: string[]; + children: React.ReactNode; + onDrop?: (metadata?: DragMetadata) => void; +}) { + const { dropProps, isOver, willAccept } = useDropTarget({ + id: `drop-zone-${Math.random().toString(36).substr(2, 9)}`, + accepts, + onDrop, + }); + + return ( +
      + {children} +
      + ); +} + +const meta: Meta = { + title: 'Systems/DragAndDrop/DragSource', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +The \`useDragSource\` hook makes elements draggable. It handles mouse, touch, and keyboard interactions. + +## Basic Usage +\`\`\`tsx +const { dragProps, isDragging } = useDragSource({ + type: 'item', + metadata: { id: '1', type: 'item' }, + announcedName: 'Item 1', +}); + +return
      Draggable Item
      ; +\`\`\` + `, + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: () => ( + +
      +

      Basic Draggable Items

      +
      +
      + + Card Item + + + Another Card + +
      + Drop cards here +
      +
      +
      + ), +}; + +export const WithPreview: Story = { + render: () => ( + +
      +

      Custom Preview

      +
      +
      + + 🎯 Custom Preview +
      + } + > + Item with Custom Preview + + + Default Preview + +
      + Drop items here +
      +
      + + ), +}; + +export const TypeRestrictions: Story = { + render: () => { + const [lastDrop, setLastDrop] = useState(''); + + return ( + +
      +

      Type Restrictions

      + {lastDrop && ( +
      + Last dropped: {lastDrop} +
      + )} +
      +
      +

      Items

      + + 🍎 Apple + + + 🥕 Carrot + + + 🍖 Meat + +
      +
      +

      Drop Zones

      + { + const id = + typeof metadata?.id === 'string' ? metadata.id : 'unknown'; + setLastDrop(`Fruit: ${id}`); + }} + > + Fruits Only + + { + const id = + typeof metadata?.id === 'string' ? metadata.id : 'unknown'; + setLastDrop(`Vegetable: ${id}`); + }} + > + Vegetables Only + + { + const id = + typeof metadata?.id === 'string' ? metadata.id : 'unknown'; + setLastDrop(`Any: ${id}`); + }} + > + All Types + +
      +
      +
      +
      + ); + }, +}; diff --git a/lib/dnd/stories/DropTarget.stories.tsx b/lib/dnd/stories/DropTarget.stories.tsx new file mode 100644 index 000000000..682885b93 --- /dev/null +++ b/lib/dnd/stories/DropTarget.stories.tsx @@ -0,0 +1,435 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { + DndStoreProvider, + useDragSource, + useDropTarget, + type DragMetadata, +} from '..'; + +// Simple drag source for testing +function DraggableItem({ + id, + type, + children, + style = {}, +}: { + id: string; + type: string; + children: React.ReactNode; + style?: React.CSSProperties; +}) { + const { dragProps, isDragging } = useDragSource({ + type, + metadata: { type, id }, + announcedName: `${type} ${id}`, + }); + + return ( +
      + {children} +
      + ); +} + +// Configurable drop target component +function DropTargetExample({ + accepts, + name, + onDrop, + onDragEnter, + onDragLeave, + children, + style = {}, + minHeight = 100, +}: { + accepts: string[]; + name?: string; + onDrop?: (metadata?: DragMetadata) => void; + onDragEnter?: () => void; + onDragLeave?: () => void; + children?: React.ReactNode; + style?: React.CSSProperties; + minHeight?: number; +}) { + const { dropProps, isOver, willAccept, isDragging } = useDropTarget({ + id: `drop-${Math.random().toString(36).substr(2, 9)}`, + accepts, + announcedName: name ?? 'Drop Target', + onDrop, + onDragEnter, + onDragLeave, + }); + + return ( +
      + {children} +
      + ); +} + +const meta: Meta = { + title: 'Systems/DragAndDrop/DropTarget', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +The \`useDropTarget\` hook creates drop zones that can receive draggable items. It provides visual feedback during drag operations. + +## Basic Usage +\`\`\`tsx +const { dropProps, isOver, willAccept } = useDropTarget({ + id: 'my-drop-zone', + accepts: ['item'], + onDrop: (metadata) => console.log('Dropped:', metadata), +}); + +return
      Drop Zone
      ; +\`\`\` + `, + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: () => ( + +
      +

      Basic Drop Target

      +
      +
      +

      Draggable Items

      + + 📄 Document + + + 📋 Report + +
      +
      +

      Drop Zone

      + + Drop documents here + +
      +
      +
      +
      + ), +}; + +export const MultipleTypes: Story = { + render: () => { + const [dropLog, setDropLog] = useState([]); + + const handleDrop = (zone: string) => (metadata?: DragMetadata) => { + const type = + typeof metadata?.type === 'string' ? metadata.type : 'unknown'; + const message = `${type} dropped in ${zone}`; + setDropLog((prev) => [...prev.slice(-4), message]); + }; + + return ( + +
      +

      Multiple Drop Zones

      + + {dropLog.length > 0 && ( +
      +

      Drop Log:

      +
        + {dropLog.map((log, i) => ( +
      • {log}
      • + ))} +
      +
      + )} + +
      +
      +

      Items

      + + 🖼️ Image + + + 🎥 Video + + + 📄 Document + +
      + +
      +
      +
      +

      Images Only

      + + Images Only + +
      +
      +

      Videos Only

      + + Videos Only + +
      +
      +

      All Files

      + + Any File Type + +
      +
      +
      +
      +
      +
      + ); + }, +}; + +export const VisualFeedback: Story = { + render: () => { + const [eventLog, setEventLog] = useState([]); + + const logEvent = (event: string, zone: string) => { + const timestamp = new Date().toLocaleTimeString(); + setEventLog((prev) => [ + ...prev.slice(-5), + `${timestamp}: ${event} (${zone})`, + ]); + }; + + return ( + +
      +

      Visual Feedback States

      + +
      +

      Event Log:

      +
      + {eventLog.length === 0 ? ( +
      + Start dragging to see events... +
      + ) : ( + eventLog.map((log, i) =>
      {log}
      ) + )} +
      + +
      + +
      +
      + + Test Item + +
      + +
      + logEvent('DROP', 'Zone A')} + onDragEnter={() => logEvent('ENTER', 'Zone A')} + onDragLeave={() => logEvent('LEAVE', 'Zone A')} + > + Zone A (Accepts test) + + + logEvent('ENTER', 'Zone B')} + onDragLeave={() => logEvent('LEAVE', 'Zone B')} + > + Zone B (Rejects test) + +
      +
      + +
      + Visual States: +
        +
      • + Blue border: Will accept the dragged item +
      • +
      • + Green border: Item is over and will accept +
      • +
      • + Red border: Will not accept the dragged item +
      • +
      +
      +
      +
      + ); + }, +}; + +export const NestedDropTargets: Story = { + render: () => { + const [drops, setDrops] = useState<{ zone: string; item: string }[]>([]); + + const handleDrop = (zoneName: string) => (metadata?: DragMetadata) => { + const id = typeof metadata?.id === 'string' ? metadata.id : 'unknown'; + setDrops((prev) => [...prev, { zone: zoneName, item: id }]); + }; + + return ( + +
      +

      Nested Drop Targets

      + + {drops.length > 0 && ( +
      + Drops:{' '} + {drops.map((d) => `${d.item} → ${d.zone}`).join(', ')} +
      + )} + +
      +
      + + Draggable Item + +
      + +
      + +
      Outer Drop Zone
      + + Inner Drop Zone + +
      +
      +
      +
      +
      + ); + }, +}; diff --git a/lib/dnd/types.ts b/lib/dnd/types.ts new file mode 100644 index 000000000..d83598110 --- /dev/null +++ b/lib/dnd/types.ts @@ -0,0 +1,35 @@ +// Core types for drag and drop +export type DragMetadata = Record; + +export type DragItem = { + id: string; + type: string; + metadata?: DragMetadata; + _sourceZone: string | null; +}; + +export type DropTarget = { + id: string; + x: number; + y: number; + width: number; + height: number; + accepts: string[]; + announcedName?: string; +}; + +export type UseDropTargetReturn = { + dropProps: { + 'ref': (element: HTMLElement | null) => void; + 'aria-dropeffect'?: 'none' | 'copy' | 'execute' | 'link' | 'move' | 'popup'; + 'aria-label'?: string; + 'data-zone-id'?: string; + 'style'?: React.CSSProperties; + 'tabIndex'?: number; + }; + isOver: boolean; + willAccept: boolean; + isDragging: boolean; +}; + +export type DropCallback = (metadata?: DragMetadata) => void; \ No newline at end of file diff --git a/lib/dnd/useAccessibilityAnnouncements.ts b/lib/dnd/useAccessibilityAnnouncements.ts new file mode 100644 index 000000000..337cb077f --- /dev/null +++ b/lib/dnd/useAccessibilityAnnouncements.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Custom hook for managing accessibility announcements in drag and drop operations. + * Creates and manages an ARIA live region that is properly cleaned up with React's lifecycle. + */ +export function useAccessibilityAnnouncements() { + const liveRegionRef = useRef(null); + const timeoutRef = useRef(null); + + // Create the live region on mount + useEffect(() => { + if (!liveRegionRef.current) { + const liveRegion = document.createElement('div'); + liveRegion.setAttribute('role', 'status'); + liveRegion.setAttribute('aria-live', 'polite'); + liveRegion.setAttribute('aria-atomic', 'true'); + + // Visually hidden but accessible to screen readers + liveRegion.style.position = 'absolute'; + liveRegion.style.width = '1px'; + liveRegion.style.height = '1px'; + liveRegion.style.padding = '0'; + liveRegion.style.margin = '-1px'; + liveRegion.style.overflow = 'hidden'; + liveRegion.style.clipPath = 'inset(0)'; + liveRegion.style.whiteSpace = 'nowrap'; + liveRegion.style.border = '0'; + + document.body.appendChild(liveRegion); + liveRegionRef.current = liveRegion; + } + + // Cleanup on unmount + return () => { + if (liveRegionRef.current?.parentNode) { + liveRegionRef.current.parentNode.removeChild(liveRegionRef.current); + liveRegionRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + + const announce = useCallback((message: string): void => { + const region = liveRegionRef.current; + if (!region) return; + + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + region.textContent = message; + + // Clear after announcement to allow repeated announcements of the same message + timeoutRef.current = window.setTimeout(() => { + if (region.textContent === message) { + region.textContent = ''; + } + timeoutRef.current = null; + }, 1000); + }, []); + + return { announce }; +} + +// Keyboard navigation helpers (kept as pure functions) +export function getDropTargetDescription( + index: number, + total: number, + targetName?: string, +): string { + if (targetName) { + return `Drop target ${index + 1} of ${total}: ${targetName}`; + } + return `Drop target ${index + 1} of ${total}`; +} + +function getDragInstructions(): string { + return 'Press Space or Enter to start dragging. Use arrow keys to navigate between drop targets. Press Space or Enter to drop. Press Escape to cancel.'; +} + +export function getKeyboardDragAnnouncement( + action: 'start' | 'navigate' | 'drop' | 'cancel', + details?: string, +): string { + switch (action) { + case 'start': + return `Started dragging. ${details ?? ''} ${getDragInstructions()}`; + case 'navigate': + return details ?? 'Navigated to drop target'; + case 'drop': + return `Dropped item. ${details ?? ''}`; + case 'cancel': + return 'Drag cancelled'; + default: + return ''; + } +} diff --git a/lib/dnd/useDragSource.tsx b/lib/dnd/useDragSource.tsx new file mode 100644 index 000000000..14db84a8b --- /dev/null +++ b/lib/dnd/useDragSource.tsx @@ -0,0 +1,305 @@ +import type React from 'react'; +import { + createElement, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; + +import { useDndStore, useDndStoreApi } from './DndStoreProvider'; +import { type DragMetadata } from './types'; +import { + getDropTargetDescription, + getKeyboardDragAnnouncement, + useAccessibilityAnnouncements, +} from './useAccessibilityAnnouncements'; +import { findSourceZone, rafThrottle } from './utils'; + +// Hook-specific types +type DragSourceOptions = { + type: string; + metadata?: DragMetadata; + announcedName?: string; + preview?: ReactNode; + disabled?: boolean; +}; + +type UseDragSourceReturn = { + dragProps: { + 'ref': (element: HTMLElement | null) => void; + 'onPointerDown': (e: React.PointerEvent) => void; + 'onKeyDown': (e: React.KeyboardEvent) => void; + 'style'?: React.CSSProperties; + 'aria-grabbed'?: boolean; + 'aria-dropeffect'?: 'none' | 'copy' | 'execute' | 'link' | 'move' | 'popup'; + 'aria-label'?: string; + 'role'?: string; + 'tabIndex'?: number; + }; + isDragging: boolean; +}; + +export function useDragSource(options: DragSourceOptions): UseDragSourceReturn { + const { type, metadata, announcedName, preview, disabled = false } = options; + const previewComponent = preview; + + const { announce } = useAccessibilityAnnouncements(); + + const [dragMode, setDragMode] = useState<'none' | 'pointer' | 'keyboard'>( + 'none', + ); + const [currentDropTargetIndex, setCurrentDropTargetIndex] = useState(-1); + const dragId = useId(); + const elementRef = useRef(null); + + const startDrag = useDndStore((state) => state.startDrag); + const updateDragPosition = useDndStore((state) => state.updateDragPosition); + const endDrag = useDndStore((state) => state.endDrag); + + const updatePosition = useRef(rafThrottle(updateDragPosition)).current; + + const createPreview = useCallback( + (element: HTMLElement | null): ReactNode => { + if (previewComponent !== undefined) return previewComponent; + if (!element) return null; + + const clonedElement = element.cloneNode(true) as HTMLElement; + clonedElement.style.pointerEvents = 'none'; + clonedElement.removeAttribute('id'); + clonedElement + .querySelectorAll('[id]') + .forEach((el) => el.removeAttribute('id')); + return createElement('div', { + dangerouslySetInnerHTML: { __html: clonedElement.outerHTML }, + }); + }, + [previewComponent], + ); + + // Unified drag initialization logic + const initializeDrag = useCallback( + ( + element: HTMLElement, + position: { x: number; y: number }, + mode: 'pointer' | 'keyboard', + ) => { + elementRef.current = element; + setDragMode(mode); + + const rect = element.getBoundingClientRect(); + const sourceZone = findSourceZone(element); + const dragItem = { + id: dragId, + type, + metadata, + _sourceZone: sourceZone, + }; + const dragPosition = { + ...position, + width: rect.width, + height: rect.height, + }; + const dragPreview = createPreview(element); + + startDrag(dragItem, dragPosition, dragPreview); + + // Only hide the element during pointer drag + if (mode === 'pointer') { + element.style.visibility = 'hidden'; + } + }, + [dragId, type, metadata, createPreview, startDrag], + ); + + // Unified drag end logic + const storeApi = useDndStoreApi(); + const finishDrag = useCallback( + (shouldDrop = true) => { + const activeDropTargetId = storeApi.getState().activeDropTargetId; + + if (!shouldDrop) { + storeApi.getState().setActiveDropTarget(null); + } + + endDrag(); + setDragMode('none'); + setCurrentDropTargetIndex(-1); + + const element = elementRef.current; + if (element) { + element.style.visibility = 'visible'; // Restore visibility + } + + // Announce keyboard drag result for failed drops only + // Successful drops are announced by the drop target + if (dragMode === 'keyboard' && (!shouldDrop || !activeDropTargetId)) { + const itemName = announcedName ?? 'Item'; + announce(`Drop cancelled, ${itemName} returned to original position`); + } + }, + [endDrag, dragMode, announce, announcedName, storeApi], + ); + + const handlePointerMove = useCallback( + (e: PointerEvent) => { + e.preventDefault(); + updatePosition(e.clientX, e.clientY); + }, + [updatePosition], + ); + + const handlePointerUp = useCallback( + (e: PointerEvent) => { + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + document.removeEventListener('pointercancel', handlePointerUp); + + const element = elementRef.current; + if (element?.hasPointerCapture(e.pointerId)) { + element.releasePointerCapture(e.pointerId); + } + finishDrag(true); + }, + [handlePointerMove, finishDrag], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (disabled || e.button !== 0) return; + const element = e.currentTarget as HTMLElement; + + initializeDrag(element, { x: e.clientX, y: e.clientY }, 'pointer'); + + element.setPointerCapture(e.pointerId); + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + document.addEventListener('pointercancel', handlePointerUp); + + // Prevent default to avoid text selection + e.preventDefault(); + }, + [disabled, initializeDrag, handlePointerMove, handlePointerUp], + ); + + const startKeyboardDrag = useCallback( + (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + initializeDrag( + element, + { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }, + 'keyboard', + ); + + // Announce the enhanced grab message + const itemName = announcedName ?? 'Item'; + announce( + `${itemName} grabbed, use arrow keys to navigate to drop targets, press Escape to cancel`, + ); + }, + [initializeDrag, announcedName, announce], + ); + + useEffect(() => () => updatePosition.cancel(), [updatePosition]); + + const setDragRef = useCallback((element: HTMLElement | null) => { + elementRef.current = element; + }, []); + + const isDragging = dragMode !== 'none'; + + const compatibleTargets = useMemo(() => { + if (!isDragging) return []; + return storeApi.getState().getCompatibleTargets(); + }, [isDragging, storeApi]); + + const navigateDropTargets = useCallback( + (direction: 'next' | 'prev') => { + if (compatibleTargets.length === 0) return; + + const nextIndex = + direction === 'next' + ? (currentDropTargetIndex + 1) % compatibleTargets.length + : (currentDropTargetIndex - 1 + compatibleTargets.length) % + compatibleTargets.length; + + setCurrentDropTargetIndex(nextIndex); + const target = compatibleTargets[nextIndex]; + if (target) { + updateDragPosition( + target.x + target.width / 2, + target.y + target.height / 2, + ); + const description = getDropTargetDescription( + nextIndex, + compatibleTargets.length, + target.announcedName, + ); + announce(getKeyboardDragAnnouncement('navigate', description)); + } + }, + [currentDropTargetIndex, updateDragPosition, announce, compatibleTargets], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (disabled) return; + + if (dragMode === 'keyboard') { + e.preventDefault(); + switch (e.key) { + case 'ArrowDown': + case 'ArrowRight': + return navigateDropTargets('next'); + case 'ArrowUp': + case 'ArrowLeft': + return navigateDropTargets('prev'); + case 'Enter': + case ' ': + return finishDrag(true); + case 'Escape': + return finishDrag(false); + } + } else if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + startKeyboardDrag(e.currentTarget as HTMLElement); + } + }, + [disabled, dragMode, navigateDropTargets, finishDrag, startKeyboardDrag], + ); + + return useMemo( + () => ({ + dragProps: { + 'ref': setDragRef, + 'onPointerDown': handlePointerDown, + 'onKeyDown': handleKeyDown, + 'role': 'button', + 'tabIndex': disabled ? -1 : 0, + 'aria-grabbed': isDragging, + 'aria-dropeffect': 'move', + 'aria-label': announcedName, + 'style': { + cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'grab', + touchAction: isDragging ? 'none' : 'pan-y', + userSelect: 'none', + }, + }, + isDragging, + }), + [ + setDragRef, + handlePointerDown, + handleKeyDown, + isDragging, + disabled, + announcedName, + ], + ); +} diff --git a/lib/dnd/useDropTarget.ts b/lib/dnd/useDropTarget.ts new file mode 100644 index 000000000..28427b5c1 --- /dev/null +++ b/lib/dnd/useDropTarget.ts @@ -0,0 +1,337 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useDndStore, useDndStoreApi } from './DndStoreProvider'; +import { + type DragItem, + type DragMetadata, + type DropCallback, + type UseDropTargetReturn, +} from './types'; +import { getElementBounds, rafThrottle } from './utils'; + +type DropTargetOptions = { + id: string; // Required stable ID for the drop target + accepts: string[]; + announcedName?: string; // Human-readable name for screen reader announcements + onDrop?: DropCallback; + onDragEnter?: (metadata?: DragMetadata) => void; + onDragLeave?: (metadata?: DragMetadata) => void; + disabled?: boolean; +}; + +export function useDropTarget(options: DropTargetOptions): UseDropTargetReturn { + const { + id, + accepts, + announcedName, + onDrop, + onDragEnter, + onDragLeave, + disabled = false, + } = options; + + const dropIdRef = useRef(id); + const elementRef = useRef(null); + const resizeObserverRef = useRef(null); + const intersectionObserverRef = useRef(null); + const lastDragItemRef = useRef(null); + const dropOccurredRef = useRef(false); + + // Use selective subscriptions for better performance + const dragItem = useDndStore((state) => state.dragItem); + const isDragging = useDndStore((state) => state.isDragging); + const registerDropTarget = useDndStore((state) => state.registerDropTarget); + const unregisterDropTarget = useDndStore( + (state) => state.unregisterDropTarget, + ); + const updateDropTarget = useDndStore((state) => state.updateDropTarget); + + // Use targeted selectors to prevent unnecessary re-renders + const isOver = useDndStore((state) => { + const target = state.dropTargets.get(dropIdRef.current); + return target?.isOver ?? false; + }); + + const canDrop = useDndStore((state) => { + const target = state.dropTargets.get(dropIdRef.current); + return target?.canDrop ?? false; + }); + + // Memoize the accepts array to ensure stable reference + const acceptsRef = useRef(accepts); + acceptsRef.current = accepts; + + // Immediate bounds update (no throttling) for drag operations + const updateBoundsImmediate = useCallback(() => { + if (elementRef.current && !disabled) { + const bounds = getElementBounds(elementRef.current); + updateDropTarget(dropIdRef.current, bounds); + } + }, [disabled, updateDropTarget]); + + // Throttled bounds update for non-drag operations + const updateBoundsThrottled = useRef( + rafThrottle(() => { + updateBoundsImmediate(); + }), + ).current; + + // Smart bounds update that chooses throttled or immediate based on drag state + const updateBounds = useCallback(() => { + if (isDragging) { + // During drag operations, update immediately to ensure accurate hit detection + updateBoundsImmediate(); + } else { + // Outside of drag operations, use throttled updates for performance + updateBoundsThrottled(); + } + }, [isDragging, updateBoundsImmediate, updateBoundsThrottled]); + + // Handle element ref + const setRef = useCallback( + (element: HTMLElement | null) => { + // Clean up previous element + if (elementRef.current && elementRef.current !== element) { + resizeObserverRef.current?.disconnect(); + intersectionObserverRef.current?.disconnect(); + + // Clean up previous scroll listeners + const previousElement = elementRef.current as HTMLElement & { + __dndCleanup?: () => void; + }; + if (previousElement.__dndCleanup) { + previousElement.__dndCleanup(); + delete previousElement.__dndCleanup; + } + } + + elementRef.current = element; + + if (element && !disabled) { + // Initial registration + const bounds = getElementBounds(element); + registerDropTarget({ + id: dropIdRef.current, + ...bounds, + accepts: acceptsRef.current, + announcedName, + }); + + // Set up ResizeObserver for size changes + resizeObserverRef.current = new ResizeObserver(() => { + updateBounds(); + }); + resizeObserverRef.current.observe(element); + + // Set up IntersectionObserver for visibility changes + intersectionObserverRef.current = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry?.isIntersecting) { + updateBounds(); + } + }, + { threshold: 0.1 }, + ); + intersectionObserverRef.current.observe(element); + + // Listen for scroll events on the document and scrollable parents + const scrollListeners: { + element: Element | Document; + handler: () => void; + options: AddEventListenerOptions; + }[] = []; + const handleScroll = () => updateBounds(); + const handleResize = () => updateBounds(); + + // Add document scroll listener + const documentScrollOptions: AddEventListenerOptions = { + passive: true, + capture: true, + }; + document.addEventListener( + 'scroll', + handleScroll, + documentScrollOptions, + ); + scrollListeners.push({ + element: document, + handler: handleScroll, + options: documentScrollOptions, + }); + + // Add window resize listener + const windowResizeOptions: AddEventListenerOptions = { + passive: true, + }; + window.addEventListener('resize', handleResize, windowResizeOptions); + + // Find and listen to all scrollable parents + let parent = element.parentElement; + while (parent) { + const style = getComputedStyle(parent); + const hasScrollableContent = + style.overflowY === 'auto' || + style.overflowY === 'scroll' || + style.overflowX === 'auto' || + style.overflowX === 'scroll'; + + if (hasScrollableContent) { + const parentScrollOptions: AddEventListenerOptions = { + passive: true, + capture: false, + }; + parent.addEventListener( + 'scroll', + handleScroll, + parentScrollOptions, + ); + scrollListeners.push({ + element: parent, + handler: handleScroll, + options: parentScrollOptions, + }); + } + parent = parent.parentElement; + } + + // Store cleanup function with proper event listener removal + (element as HTMLElement & { __dndCleanup?: () => void }).__dndCleanup = + () => { + scrollListeners.forEach(({ element: el, handler, options }) => { + el.removeEventListener('scroll', handler, options); + }); + window.removeEventListener( + 'resize', + handleResize, + windowResizeOptions, + ); + }; + } + }, + [disabled, registerDropTarget, updateBounds, dropIdRef, announcedName], + ); + + // Handle drag enter/leave callbacks + const prevIsOverRef = useRef(isOver); + useEffect(() => { + const prevIsOver = prevIsOverRef.current; + prevIsOverRef.current = isOver; + + if (isOver && !prevIsOver && onDragEnter && dragItem) { + onDragEnter(dragItem.metadata); + } else if ( + !isOver && + prevIsOver && + onDragLeave && + lastDragItemRef.current && + !dropOccurredRef.current // Don't announce leave if a drop just occurred + ) { + onDragLeave(lastDragItemRef.current.metadata); + } + }, [isOver, onDragEnter, onDragLeave, dragItem]); + + // Track drag item for callbacks + useEffect(() => { + if (dragItem) { + lastDragItemRef.current = dragItem; + } else { + lastDragItemRef.current = null; + } + }, [dragItem]); + + // Handle drop and position updates during drag - optimized subscription + const storeApi = useDndStoreApi(); + useEffect(() => { + const unsubscribe = storeApi.subscribe( + (state) => state.isDragging, + (isDragging, wasDragging) => { + // Drag just started - update bounds immediately + if (isDragging && !wasDragging) { + updateBoundsImmediate(); + dropOccurredRef.current = false; // Reset drop flag for new drag + } + + // Drag just ended + if (!isDragging && wasDragging) { + const state = storeApi.getState(); + const wasOver = state.activeDropTargetId === dropIdRef.current; + const draggedItem = lastDragItemRef.current; + + if (wasOver && draggedItem && canDrop && onDrop) { + dropOccurredRef.current = true; + onDrop(draggedItem.metadata); + } + } + }, + ); + + return unsubscribe; + }, [canDrop, onDrop, updateBoundsImmediate, storeApi]); + + // Clean up on unmount or when disabled + useEffect(() => { + const id = dropIdRef.current; + + return () => { + unregisterDropTarget(id); + resizeObserverRef.current?.disconnect(); + intersectionObserverRef.current?.disconnect(); + updateBoundsThrottled.cancel(); + + // Clean up scroll listeners + const element = elementRef.current; + const elementWithCleanup = element as HTMLElement & { + __dndCleanup?: () => void; + }; + if (element && elementWithCleanup.__dndCleanup) { + elementWithCleanup.__dndCleanup(); + delete elementWithCleanup.__dndCleanup; + } + }; + }, [unregisterDropTarget, updateBoundsThrottled]); + + // Update registration when disabled state changes + useEffect(() => { + if (disabled) { + unregisterDropTarget(dropIdRef.current); + } else if (elementRef.current) { + const bounds = getElementBounds(elementRef.current); + registerDropTarget({ + id: dropIdRef.current, + ...bounds, + accepts: acceptsRef.current, + announcedName, + }); + } + }, [ + disabled, + registerDropTarget, + unregisterDropTarget, + dropIdRef, + announcedName, + ]); + + // Memoize the return object to prevent unnecessary re-renders + return useMemo(() => { + // Check if this is the source zone for the current drag item + const isSourceZone = + dragItem && + dropIdRef.current && + dragItem._sourceZone === dropIdRef.current; + + return { + dropProps: { + 'ref': setRef, + 'aria-dropeffect': canDrop ? 'move' : 'none', + 'aria-label': announcedName, + 'data-zone-id': dropIdRef.current, + // Only make drop zones focusable when keyboard dragging is active + 'tabIndex': isDragging ? 0 : -1, + } as const, + // Source zone should not show any drag styling + isOver: isSourceZone ? false : isOver, + willAccept: isSourceZone ? false : canDrop, + isDragging: isSourceZone ? false : isDragging, + }; + }, [setRef, canDrop, isDragging, isOver, dragItem, announcedName]); +} diff --git a/lib/dnd/utils.ts b/lib/dnd/utils.ts new file mode 100644 index 000000000..182267f97 --- /dev/null +++ b/lib/dnd/utils.ts @@ -0,0 +1,60 @@ +// Performance utilities for drag and drop + +// Throttle function using requestAnimationFrame +export function rafThrottle( + fn: (...args: TArgs) => TReturn, +): ((...args: TArgs) => void) & { cancel: () => void } { + let rafId: number | null = null; + let lastArgs: TArgs | null = null; + + const throttled = (...args: TArgs) => { + lastArgs = args; + + rafId ??= requestAnimationFrame(() => { + if (lastArgs !== null) { + fn(...lastArgs); + } + rafId = null; + }); + }; + + // Add cancel method + const throttledWithCancel = throttled as typeof throttled & { + cancel: () => void; + }; + throttledWithCancel.cancel = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + return throttledWithCancel; +} + + + +// Get element bounds with transform support (viewport coordinates) +export function getElementBounds(element: HTMLElement): { + x: number; + y: number; + width: number; + height: number; +} { + const rect = element.getBoundingClientRect(); + + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; +} + +export const findSourceZone = (element: HTMLElement | null): string | null => { + // Find the closest parent with the data-zone-id attribute (used by drop targets) + const sourceZoneElement = element?.closest('[data-zone-id]'); + + // Return the attribute's value, or null if not found + return sourceZoneElement?.getAttribute('data-zone-id') ?? null; +}; diff --git a/lib/form/components/Form.tsx b/lib/form/components/Form.tsx new file mode 100644 index 000000000..cae53c10e --- /dev/null +++ b/lib/form/components/Form.tsx @@ -0,0 +1,116 @@ +import type { VariableValue } from '@codaco/shared-consts'; +import { forwardRef, useEffect, useMemo, useState } from 'react'; +import { useTanStackForm } from '~/lib/form/hooks/useTanStackForm'; +import type { FormErrors, ProcessedFormField } from '~/lib/form/types'; +import { scrollToFirstError } from '~/lib/form/utils/scrollToFirstError'; + +type FormProps = { + fields: ProcessedFormField[]; + handleSubmit: (data: { value: Record }) => void; + getInitialValues?: () => + | Record + | Promise>; + submitButton?: React.ReactNode; + disabled?: boolean; + focusFirstInput?: boolean; +} & React.FormHTMLAttributes; + +const Form = forwardRef( + ( + { + fields, + id, + handleSubmit, + getInitialValues, + submitButton =