Compare commits
No commits in common. "acadenice/main" and "main" have entirely different histories.
acadenice/
...
main
374 changed files with 151 additions and 45157 deletions
41
.env.example
41
.env.example
|
|
@ -56,44 +56,3 @@ DEBUG_DB=false
|
|||
|
||||
# Log http requests
|
||||
LOG_HTTP=false
|
||||
|
||||
# ─── Branding ──────────────────────────────────────────────────────────
|
||||
# Le branding (nom, couleurs, logo) se gere desormais UNIQUEMENT via l'UI
|
||||
# admin: /settings/branding (couleurs + nom workspace) et /settings/general
|
||||
# (logo). Pas de variable d'env a definir ici. Le defaut hardcode est
|
||||
# "AcadeDoc" + bleu #2563eb / violet #7c3aed.
|
||||
|
||||
# ─── SMTP Brevo (recommande pour AcadeDoc) ─────────────────────────────
|
||||
# Compte Brevo : https://app.brevo.com/settings/keys/smtp
|
||||
# Pas le mot de passe du compte — generer une "SMTP key" dans le dashboard.
|
||||
# Plan free Brevo : 300 emails/jour (suffisant pour usage interne).
|
||||
#
|
||||
# MAIL_DRIVER=smtp
|
||||
# SMTP_HOST=smtp-relay.brevo.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USERNAME=<login-email-brevo>
|
||||
# SMTP_PASSWORD=<smtp-master-key-brevo>
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_IGNORETLS=false
|
||||
#
|
||||
# MAIL_FROM_ADDRESS=noreply@acadenice.fr
|
||||
# MAIL_FROM_NAME=AcadeDoc
|
||||
|
||||
# ─── OIDC (Authentik) — Bloc 4b ──────────────────────────────────────
|
||||
# Disabled by default. Set OIDC_ENABLED=true and fill the block below
|
||||
# to expose /api/auth/oidc/login and the SSO button on the login page.
|
||||
#
|
||||
# OIDC_ENABLED=true
|
||||
# OIDC_ISSUER=https://auth.example.com/application/o/docadenice/
|
||||
# OIDC_CLIENT_ID=
|
||||
# OIDC_CLIENT_SECRET=
|
||||
# OIDC_REDIRECT_URI=http://localhost:3000/api/auth/oidc/callback
|
||||
# Authentik : 'groups' n'est pas un scope standard — les groups arrivent
|
||||
# dans le claim 'groups' du scope 'profile' par defaut.
|
||||
# OIDC_SCOPES=openid email profile
|
||||
# OIDC_PROVIDER_NAME=Authentik
|
||||
#
|
||||
# Just-in-time provisioning for unknown emails. Strict by default — set
|
||||
# to true to auto-create a user in the default workspace on first login.
|
||||
# OIDC_AUTO_PROVISION=false
|
||||
# OIDC_DEFAULT_WORKSPACE_ID=
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,7 +5,6 @@ data
|
|||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
|
|
|||
1452
ACADENICE_PATCHES.md
1452
ACADENICE_PATCHES.md
File diff suppressed because it is too large
Load diff
|
|
@ -10,9 +10,6 @@ WORKDIR /app
|
|||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
RUN node -e "const f='apps/extension-clipper/package.json';const p=require('./'+f);p.scripts.build='echo skip-clipper';require('fs').writeFileSync(f,JSON.stringify(p,null,2))"
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
FROM base AS installer
|
||||
|
|
|
|||
|
|
@ -1,11 +1,3 @@
|
|||
# DocAdenice
|
||||
|
||||
> Fork Acadenice de Docmost, customise pour formation-hub.
|
||||
> Nom de marque temporaire en attendant le rebranding complet (logo, design system, manifest PWA).
|
||||
> Voir `ACADENICE_PATCHES.md` pour la liste des patches custom appliques sur l'upstream.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<h1><b>Docmost</b></h1>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
# R5.2 — REST Conventions Audit
|
||||
|
||||
Generated: 2026-05-08
|
||||
Branch: acadenice/main
|
||||
|
||||
## A. Endpoints audited
|
||||
|
||||
| Endpoint | Method | Status Code | Method OK | Code OK | Naming OK | Notes | Action |
|
||||
|----------|--------|-------------|-----------|---------|-----------|-------|--------|
|
||||
| GET /v1/api-keys | GET | 200 | OK | OK | OK | - | None |
|
||||
| POST /v1/api-keys | POST | 201 | OK | OK | OK | explicit HttpCode(201) present | None |
|
||||
| DELETE /v1/api-keys/:id | DELETE | 204 | OK | OK | OK | - | None |
|
||||
| GET /v1/audit-log | GET | 200 | OK | OK | KO | singular noun — route predates R5.2 scope | Deferred (no rename) |
|
||||
| GET /v1/pages/:pageId/backlinks | GET | 200 | OK | OK | OK | sub-resource path correct | None |
|
||||
| POST /v1/clipper/import | POST | 201 | OK | OK | OK | creates a page resource | None |
|
||||
| POST /v1/clipper/tokens | POST | 201 | OK | OK | OK | - | None |
|
||||
| GET /v1/clipper/tokens | GET | 200 | OK | OK | OK | - | None |
|
||||
| DELETE /v1/clipper/tokens/:id | DELETE | 204 | OK | OK | OK | - | None |
|
||||
| POST /v1/page-comments/resolve | POST | 200 | OK | OK | OK | action verb, no resource created | None |
|
||||
| GET /v1/graph | GET | 200 | OK | OK | OK | - | None |
|
||||
| GET /v1/notifications | GET | 200 | OK | OK | OK | - | None |
|
||||
| GET /v1/notifications/unread-count | GET | 200 | OK | OK | OK | - | None |
|
||||
| POST /v1/notifications/read-all | POST | 204 | OK | OK | OK | action, no resource | None |
|
||||
| POST /v1/notifications/mark-read | POST | 204 | OK | OK | OK | action, no resource | None |
|
||||
| POST /v1/notifications/:id/read | POST | 204 | OK | OK | OK | action, no resource | None |
|
||||
| GET /v1/notification-preferences | GET | 200 | OK | OK | OK | - | None |
|
||||
| PUT /v1/notification-preferences | PUT | 200 | OK | OK | OK | full replace semantics correct | None |
|
||||
| GET /v1/roles | GET | 200 | OK | OK | OK | - | None |
|
||||
| POST /v1/roles | POST | 201 | OK | OK | OK | explicit HttpCode(201) | None |
|
||||
| GET /v1/roles/:id | GET | 200 | OK | OK | OK | - | None |
|
||||
| PATCH /v1/roles/:id | PATCH | 200 | OK | OK | OK | - | None |
|
||||
| DELETE /v1/roles/:id | DELETE | 204 | OK | OK | OK | - | None |
|
||||
| GET /v1/roles/:id/permissions | GET | 200 | OK | OK | OK | sub-resource | None |
|
||||
| PUT /v1/roles/:id/permissions | PUT | 200 | OK | OK | OK | full replace semantics | None |
|
||||
| GET /v1/users/:userId/roles | GET | 200 | OK | OK | OK | sub-resource | None |
|
||||
| POST /v1/users/:userId/roles | POST | 200 | KO | KO | OK | assigns roles (action), 201 would also be valid | Deferred |
|
||||
| DELETE /v1/users/:userId/roles/:roleId | DELETE | 204 | OK | OK | OK | - | None |
|
||||
| GET /v1/permissions | GET | 200 | OK | OK | OK | - | None |
|
||||
| GET /v1/permissions/me | GET | 200 | OK | OK | OK | sub-resource | None |
|
||||
| GET /v1/security/oidc-status | GET | 200 | OK | OK | OK | - | None |
|
||||
| GET /v1/slash-commands | GET | 200 | OK | OK | OK | - | None |
|
||||
| GET /v1/slash-commands/:id | GET | 200 | OK | OK | OK | - | None |
|
||||
| POST /v1/slash-commands | POST | 201 | OK | OK | OK | added explicit HttpCode(201) — R5.2 | PATCHED |
|
||||
| PATCH /v1/slash-commands/:id | PATCH | 200 | OK | OK | OK | - | None |
|
||||
| DELETE /v1/slash-commands/:id | DELETE | 204 | OK | OK | OK | - | None |
|
||||
| POST /v1/sync-blocks | POST | 201 | OK | OK | OK | added explicit HttpCode(201) — R5.2 | PATCHED |
|
||||
| GET /v1/sync-blocks/:id | GET | 200 | OK | OK | OK | - | None |
|
||||
| PATCH /v1/sync-blocks/:id | PATCH | 200 | OK | OK | OK | - | None |
|
||||
| DELETE /v1/sync-blocks/:id | DELETE | 204 | OK | OK | OK | - | None |
|
||||
| GET /v1/sync-blocks/:id/usages | GET | 200 | OK | OK | OK | sub-resource | None |
|
||||
| GET /v1/templates | GET | 200 | OK | OK | OK | - | None |
|
||||
| GET /v1/templates/:id | GET | 200 | OK | OK | OK | - | None |
|
||||
| POST /v1/templates | POST | 201 | OK | OK | OK | added explicit HttpCode(201) — R5.2 | PATCHED |
|
||||
| PATCH /v1/templates/:id | PATCH | 200 | OK | OK | OK | - | None |
|
||||
| DELETE /v1/templates/:id | DELETE | 204 | OK | OK | OK | - | None |
|
||||
| POST /v1/templates/:id/instantiate | POST | 201 | OK | OK | OK | creates a page — 201 correct, added explicit | PATCHED |
|
||||
| PATCH /v1/templates/:id/default | PATCH | 200 | OK | OK | OK | - | None |
|
||||
| POST /v1/row-comments/list | POST | 200 | KO | OK | KO | RPC verb in path | PATCHED → GET /v1/row-comments |
|
||||
| POST /v1/row-comments/create | POST | 200 | KO | KO | KO | RPC verb, wrong code | PATCHED → POST /v1/row-comments 201 |
|
||||
| POST /v1/row-comments/update | POST | 200 | KO | OK | KO | RPC verb in path | PATCHED → PATCH /v1/row-comments/:id |
|
||||
| POST /v1/row-comments/resolve | POST | 200 | KO | OK | KO | RPC verb in path | PATCHED → PATCH /v1/row-comments/:id/resolve |
|
||||
| POST /v1/row-comments/delete | POST | 200 | KO | KO | KO | RPC verb, wrong method | PATCHED → DELETE /v1/row-comments/:id 204 |
|
||||
| POST /v1/row-comments/count | POST | 200 | KO | OK | KO | RPC verb in path | PATCHED → GET /v1/row-comments/count |
|
||||
|
||||
**Total audited: 54 endpoints**
|
||||
**Violations found: 9**
|
||||
**Patches applied: 9 (6 row-comments routes + 3 missing 201 codes)**
|
||||
|
||||
## B. Breaking changes
|
||||
|
||||
| Change | Before | After | Impact |
|
||||
|--------|--------|-------|--------|
|
||||
| row-comments list | POST /v1/row-comments/list | GET /v1/row-comments?tableId=&rowId= | Breaking — client updated |
|
||||
| row-comments create | POST /v1/row-comments/create | POST /v1/row-comments (201) | Breaking — client + status updated |
|
||||
| row-comments update | POST /v1/row-comments/update (body commentId) | PATCH /v1/row-comments/:id | Breaking — client + server updated |
|
||||
| row-comments resolve | POST /v1/row-comments/resolve (body commentId) | PATCH /v1/row-comments/:id/resolve | Breaking — client + server updated |
|
||||
| row-comments delete | POST /v1/row-comments/delete (body commentId) | DELETE /v1/row-comments/:id (204) | Breaking — client + server updated |
|
||||
| row-comments count | POST /v1/row-comments/count | GET /v1/row-comments/count?tableId=&rowId= | Breaking — client updated |
|
||||
|
||||
All breaking changes updated consistently across: server controller, server spec, client service, client test.
|
||||
|
||||
## C. Patches applied
|
||||
|
||||
### P1 — row-comments: Full REST refactor (breaking)
|
||||
- `row-comments.controller.ts`: 6 routes converted from RPC POST to proper HTTP methods
|
||||
- `comment.dto.ts`: Removed `commentId` from `UpdateRowCommentDto` and `ResolveRowCommentDto` (now path param)
|
||||
- `row-comments.controller.spec.ts`: Rewrote to match new REST interface
|
||||
- `row-comment.service.spec.ts`: Fixed 3 DTO literals (removed `commentId`)
|
||||
- `row-comments-client.ts` (client): Converted from POST-only to GET/POST/PATCH/DELETE
|
||||
- `row-comments-client.test.ts` (client): Rewrote to match new REST methods
|
||||
|
||||
### P2 — sync-blocks: explicit 201 on POST create
|
||||
- `sync-blocks.controller.ts`: Added `@HttpCode(HttpStatus.CREATED)`
|
||||
|
||||
### P3 — slash-commands: explicit 201 on POST create
|
||||
- `slash-commands.controller.ts`: Added `@HttpCode(HttpStatus.CREATED)`
|
||||
|
||||
### P4 — templates: explicit 201 on POST create + instantiate
|
||||
- `templates.controller.ts`: Added `@HttpCode(HttpStatus.CREATED)` to `create` and `instantiate`
|
||||
|
||||
### P5 — Fix pre-existing test failures (D section)
|
||||
|
||||
#### D1 — clipper-client.test.ts: jest → vitest
|
||||
- Converted `jest.mock`, `jest.fn()`, `jest.Mocked`, `afterEach(jest.resetAllMocks)` to vitest equivalents
|
||||
|
||||
#### D2 — templates-client.test.ts: `.data.data` → `.data`
|
||||
- `templates-client.ts`: Removed double-unwrap `.data.data` → `.data` (x6 methods)
|
||||
- `clipper-client.ts`: Same fix (x2 methods)
|
||||
- `slash-commands-client.ts`: Same fix (x4 methods)
|
||||
- `sync-blocks-client.ts`: Same fix (x4 methods)
|
||||
|
||||
## D. Test counts
|
||||
|
||||
| Suite | Before | After |
|
||||
|-------|--------|-------|
|
||||
| Server Jest (acadenice) | 321 pass | 322 pass |
|
||||
| Server Jest (total) | 440 pass / 5 fail | 448 pass / 5 fail (pre-existing) |
|
||||
| Client Vitest | 356 pass / 7 fail | 366 pass / 0 fail |
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# Dockerfile.e2e — DocAdenice client for e2e stack.
|
||||
#
|
||||
# Builds the Vite app and serves it via a lightweight static server.
|
||||
# Used only in docker-compose.e2e.yml — not for production.
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace root (monorepo — client may depend on shared packages).
|
||||
COPY package*.json ./
|
||||
COPY apps/client/package*.json ./apps/client/
|
||||
|
||||
# Install deps.
|
||||
RUN npm ci --workspace=apps/client 2>/dev/null || \
|
||||
(cd apps/client && npm ci)
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build with e2e environment placeholders.
|
||||
# VITE_ vars must be set at build time (Vite inlines them).
|
||||
# In CI they are overridden via docker-compose env: section.
|
||||
ARG VITE_APP_URL=http://localhost:3001
|
||||
ARG VITE_BRIDGE_URL=http://localhost:4001
|
||||
|
||||
ENV VITE_APP_URL=${VITE_APP_URL}
|
||||
ENV VITE_BRIDGE_URL=${VITE_BRIDGE_URL}
|
||||
|
||||
RUN cd apps/client && npm run build
|
||||
|
||||
# --- Serve stage ---
|
||||
FROM node:22-alpine AS serve
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Use serve package to host the built assets.
|
||||
RUN npm install -g serve@14
|
||||
|
||||
COPY --from=build /app/apps/client/dist ./dist
|
||||
|
||||
# serve on port 5173 to match Vite dev server default.
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["serve", "-s", "dist", "-l", "5173"]
|
||||
|
|
@ -5,14 +5,13 @@
|
|||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
|
||||
<title>AcadeDoc</title>
|
||||
<meta name="description" content="AcadeDoc — collaborative wiki for Acadenice" />
|
||||
<title>Docmost</title>
|
||||
<meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-touch-fullscreen" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="AcadeDoc" />
|
||||
<meta name="apple-mobile-web-app-title" content="Docmost" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<!--meta-tags-->
|
||||
|
|
|
|||
|
|
@ -7,25 +7,14 @@
|
|||
"build": "tsc && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/react": "^5.0.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@docmost/editor-ext": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/resource-timeline": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@fullcalendar/timeline": "^6.1.20",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^8.3.18",
|
||||
"@mantine/form": "^8.3.18",
|
||||
|
|
@ -35,12 +24,10 @@
|
|||
"@mantine/spotlight": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"alfaaz": "^1.1.0",
|
||||
"axios": "1.15.0",
|
||||
"blueimp-load-image": "^5.16.0",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
|
|
@ -62,7 +49,6 @@
|
|||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^1.0.7",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-helmet-async": "^3.0.0",
|
||||
"react-i18next": "16.5.8",
|
||||
"react-router-dom": "^7.13.1",
|
||||
|
|
@ -74,9 +60,6 @@
|
|||
"devDependencies": {
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.94.4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/blueimp-load-image": "^5.16.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
|
|
@ -90,7 +73,6 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^15.13.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
|
|
@ -98,7 +80,6 @@
|
|||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "8.0.5",
|
||||
"vitest": "^2.1.8"
|
||||
"vite": "8.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
{
|
||||
"Account": "Account",
|
||||
"Accent color": "Accent color",
|
||||
"Branding": "Branding",
|
||||
"Branding settings": "Branding settings",
|
||||
"Primary color": "Primary color",
|
||||
"Save branding": "Save branding",
|
||||
"Branding saved": "Branding saved",
|
||||
"Branding update failed": "Branding update failed",
|
||||
"Enter a hex color (e.g. #2563eb)": "Enter a hex color (e.g. #2563eb)",
|
||||
"Workspace logo": "Workspace logo",
|
||||
"Logo upload and name-based branding override is managed from the General settings tab.": "Logo upload and name-based branding override is managed from the General settings tab.",
|
||||
"Active": "Active",
|
||||
"Add": "Add",
|
||||
"Add group members": "Add group members",
|
||||
|
|
@ -938,285 +928,5 @@
|
|||
"Settings navigation": "Settings navigation",
|
||||
"AI navigation": "AI navigation",
|
||||
"Breadcrumb": "Breadcrumb",
|
||||
"Skip to main content": "Skip to main content",
|
||||
"Roles": "Roles",
|
||||
"Role detail": "Role detail",
|
||||
"Role name": "Role name",
|
||||
"Role created successfully": "Role created successfully",
|
||||
"Role updated successfully": "Role updated successfully",
|
||||
"Role deleted successfully": "Role deleted successfully",
|
||||
"Role removed from user": "Role removed from user",
|
||||
"Role assignments updated": "Role assignments updated",
|
||||
"Failed to create role": "Failed to create role",
|
||||
"Failed to update role": "Failed to update role",
|
||||
"Failed to delete role": "Failed to delete role",
|
||||
"Failed to assign roles": "Failed to assign roles",
|
||||
"Failed to remove role": "Failed to remove role",
|
||||
"Failed to save permissions": "Failed to save permissions",
|
||||
"Failed to load roles": "Failed to load roles",
|
||||
"Failed to load role": "Failed to load role",
|
||||
"Failed to load role assignments": "Failed to load role assignments",
|
||||
"Permissions saved": "Permissions saved",
|
||||
"Permissions": "Permissions",
|
||||
"Identity": "Identity",
|
||||
"Save changes": "Save changes",
|
||||
"Save permissions": "Save permissions",
|
||||
"Save assignments": "Save assignments",
|
||||
"Discard": "Discard",
|
||||
"Retry": "Retry",
|
||||
"An unknown error occurred": "An unknown error occurred",
|
||||
"Search roles by name": "Search roles by name",
|
||||
"Search roles": "Search roles",
|
||||
"system": "system",
|
||||
"custom": "custom",
|
||||
"Create role": "Create role",
|
||||
"Create a new role": "Create a new role",
|
||||
"Open role {{name}}": "Open role {{name}}",
|
||||
"System role — name and existence are protected": "System role — name and existence are protected",
|
||||
"System roles cannot be renamed": "System roles cannot be renamed",
|
||||
"System roles cannot be deleted": "System roles cannot be deleted",
|
||||
"Define what members can do across this workspace. Custom roles override defaults; system roles cannot be removed.": "Define what members can do across this workspace. Custom roles override defaults; system roles cannot be removed.",
|
||||
"No roles match your filters": "No roles match your filters",
|
||||
"Seed roles will appear once the workspace is initialised.": "Seed roles will appear once the workspace is initialised.",
|
||||
"Try clearing the search or switching the filter.": "Try clearing the search or switching the filter.",
|
||||
"Back to roles": "Back to roles",
|
||||
"Back to members": "Back to members",
|
||||
"Missing role id in URL": "Missing role id in URL",
|
||||
"Missing user id in URL": "Missing user id in URL",
|
||||
"Selected actions are granted to every member who holds this role.": "Selected actions are granted to every member who holds this role.",
|
||||
"Workspace owner": "Workspace owner",
|
||||
"Selecting this overrides every other permission. Use sparingly.": "Selecting this overrides every other permission. Use sparingly.",
|
||||
"Toggle admin wildcard": "Toggle admin wildcard",
|
||||
"Toggle wildcard for {{group}}": "Toggle wildcard for {{group}}",
|
||||
"Grant every permission in this group, including future ones.": "Grant every permission in this group, including future ones.",
|
||||
"All {{group}}": "All {{group}}",
|
||||
"all granted": "all granted",
|
||||
"inherited via admin": "inherited via admin",
|
||||
"You do not have the roles:manage permission. Permissions are read-only.": "You do not have the roles:manage permission. Permissions are read-only.",
|
||||
"You do not have the roles:manage permission. Assignments are read-only.": "You do not have the roles:manage permission. Assignments are read-only.",
|
||||
"Requires the roles:manage permission": "Requires the roles:manage permission",
|
||||
"Danger zone": "Danger zone",
|
||||
"System roles are protected and cannot be deleted. You can edit their permissions but their existence is guaranteed.": "System roles are protected and cannot be deleted. You can edit their permissions but their existence is guaranteed.",
|
||||
"Deleting this role removes it from every user. This action cannot be undone.": "Deleting this role removes it from every user. This action cannot be undone.",
|
||||
"Delete role": "Delete role",
|
||||
"This will remove the role and unassign it from every member. This action cannot be undone.": "This will remove the role and unassign it from every member. This action cannot be undone.",
|
||||
"to confirm:": "to confirm:",
|
||||
"Confirm role name": "Confirm role name",
|
||||
"Name is required": "Name is required",
|
||||
"Name is too long (max 120)": "Name is too long (max 120)",
|
||||
"Description is too long (max 2000)": "Description is too long (max 2000)",
|
||||
"e.g. Formateur": "e.g. Formateur",
|
||||
"What this role can do, in plain words": "What this role can do, in plain words",
|
||||
"User roles": "User roles",
|
||||
"User role assignments": "User role assignments",
|
||||
"Assigned roles": "Assigned roles",
|
||||
"A user inherits the union of every role's permissions. The owner shortcut admin:* overrides everything else.": "A user inherits the union of every role's permissions. The owner shortcut admin:* overrides everything else.",
|
||||
"Pick one or more roles": "Pick one or more roles",
|
||||
"Roles assigned to user": "Roles assigned to user",
|
||||
"Effective permissions preview": "Effective permissions preview",
|
||||
"Permission preview requires the roles:manage permission to read role definitions.": "Permission preview requires the roles:manage permission to read role definitions.",
|
||||
"No roles selected.": "No roles selected.",
|
||||
"No permissions are granted by the selected roles yet.": "No permissions are granted by the selected roles yet.",
|
||||
"database_view.node.header_label": "Database",
|
||||
"database_view.placeholder.not_supported": "View type \"{{viewType}}\" is not yet supported. It will be available in a future update.",
|
||||
"database_view.table.empty_state": "No rows found in this view.",
|
||||
"database_view.table.page_info": "Page {{page}} — {{total}} total rows",
|
||||
"database_view.table.prev": "Previous",
|
||||
"database_view.table.next": "Next",
|
||||
"database_view.error.title": "Could not load view",
|
||||
"database_view.error.generic": "An unexpected error occurred while loading the view.",
|
||||
"database_view.error.view_not_found": "This view no longer exists or has been deleted.",
|
||||
"database_view.error.permission_denied": "You do not have permission to read this view.",
|
||||
"database_view.error.retry": "Retry",
|
||||
"database_view.error.tables_load": "Failed to load tables from the bridge.",
|
||||
"database_view.error.views_load": "Failed to load views for this table.",
|
||||
"database_view.modal.title": "Insert database view",
|
||||
"database_view.modal.step1": "Pick table",
|
||||
"database_view.modal.step2": "Pick view",
|
||||
"database_view.modal.search_tables": "Search tables...",
|
||||
"database_view.modal.no_tables": "No tables found. Check that the bridge is running.",
|
||||
"database_view.modal.no_views": "No views found for this table.",
|
||||
"database_view.modal.select_view": "Select a view to embed:",
|
||||
"database_view.modal.back": "Back",
|
||||
"database_view.modal.insert": "Insert",
|
||||
"database_view.kanban.empty_column": "No cards",
|
||||
"database_view.kanban.no_groupby_field": "No single-select field found. Kanban requires a single-select field to group cards by.",
|
||||
"database_view.calendar.no_date_field": "No date field found. Calendar requires a date field to position events.",
|
||||
"database_view.calendar.view_month": "Month",
|
||||
"database_view.calendar.view_week": "Week",
|
||||
"database_view.calendar.view_day": "Day",
|
||||
"database_view.edit.permission_denied": "You do not have permission to edit this field.",
|
||||
"database_view.edit.read_only_mode": "Read-only — you do not have write access to this database.",
|
||||
"database_view.row_detail.title": "Row details",
|
||||
"database_view.row_detail.primary_badge": "primary",
|
||||
"database_view.row_detail.no_fields": "No fields to display.",
|
||||
"backlinks.panel.title": "Linked references",
|
||||
"backlinks.group.wikilinks": "Wikilinks",
|
||||
"backlinks.group.mentions": "Mentions",
|
||||
"backlinks.group.embeds": "Database embeds",
|
||||
"backlinks.empty": "No pages link here yet.",
|
||||
"backlinks.error": "Could not load linked references.",
|
||||
"backlinks.retry": "Retry",
|
||||
"backlinks.untitled": "Untitled",
|
||||
"wikilink.suggestion.no_results": "No matching pages",
|
||||
"wikilink.suggestion.type_to_search": "Type to search pages...",
|
||||
"wikilink.broken": "Page not found or deleted",
|
||||
"slash_commands.page_title": "Slash commands",
|
||||
"slash_commands.create_button": "New command",
|
||||
"slash_commands.create_title": "Create slash command",
|
||||
"slash_commands.edit_title": "Edit slash command",
|
||||
"slash_commands.col_keyword": "Keyword",
|
||||
"slash_commands.col_label": "Label",
|
||||
"slash_commands.col_action_type": "Action type",
|
||||
"slash_commands.col_enabled": "Enabled",
|
||||
"slash_commands.keyword_label": "Keyword",
|
||||
"slash_commands.keyword_description": "Lowercase letters, numbers and hyphens only. Used as /keyword in the editor.",
|
||||
"slash_commands.keyword_format_error": "Only lowercase letters, numbers and hyphens are allowed",
|
||||
"slash_commands.label_label": "Label",
|
||||
"slash_commands.label_required": "Label is required",
|
||||
"slash_commands.description_label": "Description",
|
||||
"slash_commands.description_placeholder": "Short description shown in the slash menu",
|
||||
"slash_commands.icon_label": "Icon",
|
||||
"slash_commands.icon_description": "Tabler icon name (e.g. IconNotes) or leave blank",
|
||||
"slash_commands.action_type_label": "Action type",
|
||||
"slash_commands.action_config_section": "Action configuration",
|
||||
"slash_commands.enabled_label": "Enabled",
|
||||
"slash_commands.template_label": "Template content",
|
||||
"slash_commands.template_description": "Markdown text or Tiptap JSON to insert at cursor",
|
||||
"slash_commands.rows_label": "Rows",
|
||||
"slash_commands.cols_label": "Columns",
|
||||
"slash_commands.header_row_label": "Include header row",
|
||||
"slash_commands.url_label": "URL to embed",
|
||||
"slash_commands.url_required": "A valid URL starting with http is required",
|
||||
"slash_commands.webhook_url_label": "Webhook URL",
|
||||
"slash_commands.webhook_https_required": "Webhook URL must start with https://",
|
||||
"slash_commands.webhook_headers_label": "Additional headers (JSON)",
|
||||
"slash_commands.webhook_headers_description": "Optional JSON object of extra HTTP headers to send",
|
||||
"slash_commands.webhook_security_title": "Security note",
|
||||
"slash_commands.webhook_security_note": "Never include secrets in stored headers. Use a secret-manager proxy in front of your webhook endpoint.",
|
||||
"slash_commands.language_label": "Code language",
|
||||
"slash_commands.language_required": "Language is required",
|
||||
"slash_commands.snippet_code_label": "Starter code",
|
||||
"slash_commands.snippet_code_description": "Optional starter code inserted with the snippet",
|
||||
"slash_commands.enable_tooltip": "Enable this command",
|
||||
"slash_commands.disable_tooltip": "Disable this command",
|
||||
"slash_commands.delete_confirm": "Delete slash command \"{{label}}\"? This cannot be undone.",
|
||||
"slash_commands.create_success": "Slash command created",
|
||||
"slash_commands.update_success": "Slash command updated",
|
||||
"slash_commands.delete_success": "Slash command deleted",
|
||||
"slash_commands.load_error": "Could not load slash commands",
|
||||
"slash_commands.empty_state": "No custom slash commands yet. Create one to get started.",
|
||||
"slash_commands.access_denied_title": "Access denied",
|
||||
"slash_commands.access_denied_description": "You need the slash_commands:manage permission to access this page.",
|
||||
"dual_editor.switch_to_markdown": "Switch to markdown source",
|
||||
"dual_editor.switch_to_wysiwyg": "Switch to visual editor",
|
||||
"dual_editor.switch_warning_title": "Potential data loss on switch",
|
||||
"dual_editor.switch_warning_to_md": "Some block types cannot be fully represented in markdown. The following elements may be altered:",
|
||||
"dual_editor.switch_warning_to_wysiwyg": "Some markdown tokens could not be parsed back to rich content. The following elements may be lost:",
|
||||
"dual_editor.switch_anyway": "Switch anyway",
|
||||
"dual_editor.markdown_editor_label": "Markdown source editor",
|
||||
"graph.page_title": "Knowledge Graph",
|
||||
"graph.search_placeholder": "Search nodes...",
|
||||
"graph.filters_label": "Filters",
|
||||
"graph.space_filter_label": "Space",
|
||||
"graph.all_spaces": "All spaces",
|
||||
"graph.edge_types_label": "Link types",
|
||||
"graph.edge_type_wikilink": "Wikilink",
|
||||
"graph.edge_type_mention": "Mention",
|
||||
"graph.edge_type_database_embed": "Database embed",
|
||||
"graph.depth_label": "Depth: {{depth}}",
|
||||
"graph.include_orphans_label": "Include orphans",
|
||||
"graph.stats_label": "Stats",
|
||||
"graph.nodes_unit": "nodes",
|
||||
"graph.edges_unit": "edges",
|
||||
"graph.truncated_warning": "Graph truncated to 1000 nodes — apply filters to reduce scope",
|
||||
"graph.reset_filters": "Reset filters",
|
||||
"graph.open_page": "Open page",
|
||||
"graph.close_panel": "Close panel",
|
||||
"graph.in_links": "in",
|
||||
"graph.out_links": "out",
|
||||
"graph.orphan_label": "orphan",
|
||||
"graph.untitled_page": "(untitled)",
|
||||
"graph.error_title": "Graph load error",
|
||||
"graph.error_generic": "Could not load the knowledge graph",
|
||||
"graph.legend_title": "Legend",
|
||||
"graph.legend_wikilink": "Wikilink",
|
||||
"graph.legend_parent_child": "Parent-child",
|
||||
"graph.legend_aria_label": "Edge type legend: solid line = wikilink, dashed line = parent-child hierarchy",
|
||||
"graph.space_graph_title": "Graph — {{spaceName}}",
|
||||
"graph.space_graph_menu_label": "Graph",
|
||||
"templates.page_title": "Templates",
|
||||
"templates.create_button": "New template",
|
||||
"templates.create_title": "Create template",
|
||||
"templates.edit_title": "Edit template",
|
||||
"templates.search_placeholder": "Search templates...",
|
||||
"templates.name_label": "Name",
|
||||
"templates.name_placeholder": "Template name",
|
||||
"templates.name_required": "Name is required",
|
||||
"templates.icon_label": "Icon",
|
||||
"templates.icon_placeholder": "e.g. calendar",
|
||||
"templates.icon_description": "Short text or emoji for the template icon",
|
||||
"templates.category_label": "Category",
|
||||
"templates.description_label": "Description",
|
||||
"templates.description_placeholder": "Describe what this template is for",
|
||||
"templates.category_meeting": "Meeting",
|
||||
"templates.category_project": "Project",
|
||||
"templates.category_wiki": "Wiki",
|
||||
"templates.category_todo": "Todo",
|
||||
"templates.category_custom": "Custom",
|
||||
"templates.create_success": "Template created",
|
||||
"templates.update_success": "Template updated",
|
||||
"templates.delete_success": "Template deleted",
|
||||
"templates.set_default_success": "Workspace default template updated",
|
||||
"templates.instantiate_error": "Failed to create page from template",
|
||||
"templates.empty_state": "No templates found",
|
||||
"templates.built_in_badge": "Built-in",
|
||||
"templates.default_badge": "Workspace default",
|
||||
"templates.usage_count": "Used {{count}}x",
|
||||
"templates.actions_menu": "Template actions",
|
||||
"templates.use_action": "Use template",
|
||||
"templates.edit_action": "Edit",
|
||||
"templates.delete_action": "Delete",
|
||||
"templates.set_default_action": "Set as default",
|
||||
"templates.delete_confirm_title": "Delete template",
|
||||
"templates.delete_confirm_body": "Delete template \"{{name}}\"? This cannot be undone.",
|
||||
"templates.use_modal_title": "Use template",
|
||||
"templates.use_modal_description": "Open the editor and use the \"New page from template\" button in the sidebar to create a page from \"{{name}}\".",
|
||||
"templates.new_from_template": "From template",
|
||||
"templates.picker_title": "Choose a template",
|
||||
"acadenice.notifications.title": "Notifications",
|
||||
"acadenice.notifications.empty": "No notifications",
|
||||
"acadenice.notifications.mark_all_read": "Mark all as read",
|
||||
"acadenice.notifications.all_read": "All notifications marked as read",
|
||||
"acadenice.notifications.load_more": "Load more",
|
||||
"acadenice.notifications.prefs_saved": "Notification preferences saved",
|
||||
"acadenice.notifications.prefs_error": "Failed to save notification preferences",
|
||||
"acadenice.notifications.prefs.title": "Notification settings",
|
||||
"acadenice.notifications.prefs.subtitle": "Choose how and when you get notified.",
|
||||
"acadenice.notifications.prefs.email_mentions": "Email — page mentions",
|
||||
"acadenice.notifications.prefs.email_mentions_desc": "Receive an email when someone mentions you on a page.",
|
||||
"acadenice.notifications.prefs.email_replies": "Email — comment replies",
|
||||
"acadenice.notifications.prefs.email_replies_desc": "Receive an email when someone replies to your comment.",
|
||||
"acadenice.notifications.prefs.email_shares": "Email — page updates",
|
||||
"acadenice.notifications.prefs.email_shares_desc": "Receive an email when a watched page is updated.",
|
||||
"acadenice.notifications.prefs.in_app_mentions": "In-app — page mentions",
|
||||
"acadenice.notifications.prefs.in_app_mentions_desc": "Receive an in-app notification when someone mentions you on a page.",
|
||||
"acadenice.notifications.prefs.in_app_replies": "In-app — comment replies",
|
||||
"acadenice.notifications.prefs.in_app_replies_desc": "Receive an in-app notification when someone replies to your comment.",
|
||||
"database_view.row_detail.tab_fields": "Fields",
|
||||
"database_view.row_detail.tab_comments": "Comments",
|
||||
"acadenice.comments.open": "Open",
|
||||
"acadenice.comments.resolved": "Resolved",
|
||||
"acadenice.comments.empty": "No comments yet.",
|
||||
"acadenice.comments.new_placeholder": "Write a comment...",
|
||||
"acadenice.comments.reply_placeholder": "Write a reply...",
|
||||
"acadenice.comments.send": "Comment",
|
||||
"acadenice.comments.send_reply": "Reply",
|
||||
"acadenice.comments.reply": "Reply",
|
||||
"acadenice.comments.cancel": "Cancel",
|
||||
"acadenice.comments.resolve_action": "Resolve thread",
|
||||
"acadenice.comments.reopen_action": "Re-open thread",
|
||||
"acadenice.comments.delete_action": "Delete comment",
|
||||
"acadenice.comments.resolved_badge": "Resolved",
|
||||
"acadenice.comments.unknown_user": "Unknown user"
|
||||
"Skip to main content": "Skip to main content"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
{
|
||||
"Account": "Compte",
|
||||
"Accent color": "Couleur d'accentuation",
|
||||
"Branding": "Personnalisation",
|
||||
"Branding settings": "Paramètres de personnalisation",
|
||||
"Primary color": "Couleur primaire",
|
||||
"Save branding": "Enregistrer",
|
||||
"Branding saved": "Personnalisation enregistrée",
|
||||
"Branding update failed": "Echec de la mise à jour",
|
||||
"Enter a hex color (e.g. #2563eb)": "Entrez une couleur hex (ex : #2563eb)",
|
||||
"Workspace logo": "Logo de l'espace de travail",
|
||||
"Logo upload and name-based branding override is managed from the General settings tab.": "Le logo et le nom se configurent depuis l'onglet Général.",
|
||||
"Active": "Actif",
|
||||
"Add": "Ajouter",
|
||||
"Add group members": "Ajouter des membres au groupe",
|
||||
|
|
@ -725,7 +715,7 @@
|
|||
"Restricted pages cannot be shared publicly.": "Les pages restreintes ne peuvent pas être partagées publiquement.",
|
||||
"Restricted by parent": "Restreint par la page parente",
|
||||
"Restricted": "Restreint",
|
||||
"Open": "Ouvrir",
|
||||
"Open": "Ouvert",
|
||||
"Inherits restrictions from ancestor page": "Hérite des restrictions d'une page ancêtre",
|
||||
"Only people listed below can access this page": "Seules les personnes listées ci-dessous peuvent accéder à cette page",
|
||||
"Everyone in this space can access": "Tout le monde dans cet espace y a accès",
|
||||
|
|
@ -890,288 +880,5 @@
|
|||
"Try a different search term.": "Essayez un autre terme de recherche.",
|
||||
"Try again": "Réessayer",
|
||||
"Untitled chat": "Discussion sans titre",
|
||||
"What can I help you with?": "Que puis-je faire pour vous aider ?",
|
||||
"Roles": "Rôles",
|
||||
"Role detail": "Détail du rôle",
|
||||
"Role name": "Nom du rôle",
|
||||
"Role created successfully": "Rôle créé avec succès",
|
||||
"Role updated successfully": "Rôle mis à jour avec succès",
|
||||
"Role deleted successfully": "Rôle supprimé avec succès",
|
||||
"Role removed from user": "Rôle retiré de l'utilisateur",
|
||||
"Role assignments updated": "Attributions de rôle mises à jour",
|
||||
"Failed to create role": "Échec de la création du rôle",
|
||||
"Failed to update role": "Échec de la mise à jour du rôle",
|
||||
"Failed to delete role": "Échec de la suppression du rôle",
|
||||
"Failed to assign roles": "Échec de l'attribution des rôles",
|
||||
"Failed to remove role": "Échec du retrait du rôle",
|
||||
"Failed to save permissions": "Échec de l'enregistrement des permissions",
|
||||
"Failed to load roles": "Échec du chargement des rôles",
|
||||
"Failed to load role": "Échec du chargement du rôle",
|
||||
"Failed to load role assignments": "Échec du chargement des attributions",
|
||||
"Permissions saved": "Permissions enregistrées",
|
||||
"Permissions": "Permissions",
|
||||
"Identity": "Identité",
|
||||
"Save changes": "Enregistrer les modifications",
|
||||
"Save permissions": "Enregistrer les permissions",
|
||||
"Save assignments": "Enregistrer les attributions",
|
||||
"Discard": "Annuler les modifications",
|
||||
"Retry": "Réessayer",
|
||||
"An unknown error occurred": "Une erreur inconnue est survenue",
|
||||
"Search roles by name": "Rechercher un rôle par nom",
|
||||
"Search roles": "Rechercher des rôles",
|
||||
"All": "Tous",
|
||||
"System": "Système",
|
||||
"Custom": "Personnalisés",
|
||||
"system": "système",
|
||||
"custom": "personnalisé",
|
||||
"Create role": "Créer un rôle",
|
||||
"Create a new role": "Créer un nouveau rôle",
|
||||
"Open role {{name}}": "Ouvrir le rôle {{name}}",
|
||||
"System role — name and existence are protected": "Rôle système — le nom et l'existence sont protégés",
|
||||
"System roles cannot be renamed": "Les rôles système ne peuvent pas être renommés",
|
||||
"System roles cannot be deleted": "Les rôles système ne peuvent pas être supprimés",
|
||||
"Define what members can do across this workspace. Custom roles override defaults; system roles cannot be removed.": "Définissez ce que les membres peuvent faire dans cet espace de travail. Les rôles personnalisés complètent les rôles par défaut ; les rôles système ne peuvent pas être supprimés.",
|
||||
"No roles match your filters": "Aucun rôle ne correspond à vos filtres",
|
||||
"Seed roles will appear once the workspace is initialised.": "Les rôles initiaux apparaîtront une fois l'espace de travail initialisé.",
|
||||
"Try clearing the search or switching the filter.": "Essayez d'effacer la recherche ou de changer de filtre.",
|
||||
"Back to roles": "Retour aux rôles",
|
||||
"Back to members": "Retour aux membres",
|
||||
"Missing role id in URL": "Identifiant de rôle manquant dans l'URL",
|
||||
"Missing user id in URL": "Identifiant utilisateur manquant dans l'URL",
|
||||
"Selected actions are granted to every member who holds this role.": "Les actions sélectionnées sont accordées à tous les membres qui portent ce rôle.",
|
||||
"Workspace owner": "Propriétaire de l'espace",
|
||||
"Selecting this overrides every other permission. Use sparingly.": "Cocher cette case écrase toutes les autres permissions. À utiliser avec parcimonie.",
|
||||
"Toggle admin wildcard": "Activer le joker admin",
|
||||
"Toggle wildcard for {{group}}": "Activer le joker pour {{group}}",
|
||||
"Grant every permission in this group, including future ones.": "Accorde toutes les permissions de ce groupe, y compris les futures.",
|
||||
"All {{group}}": "Tout {{group}}",
|
||||
"all granted": "tout accordé",
|
||||
"inherited via admin": "hérité via admin",
|
||||
"You do not have the roles:manage permission. Permissions are read-only.": "Vous n'avez pas la permission roles:manage. Les permissions sont en lecture seule.",
|
||||
"You do not have the roles:manage permission. Assignments are read-only.": "Vous n'avez pas la permission roles:manage. Les attributions sont en lecture seule.",
|
||||
"Requires the roles:manage permission": "Nécessite la permission roles:manage",
|
||||
"Danger zone": "Zone sensible",
|
||||
"System roles are protected and cannot be deleted. You can edit their permissions but their existence is guaranteed.": "Les rôles système sont protégés et ne peuvent pas être supprimés. Leurs permissions restent éditables mais leur existence est garantie.",
|
||||
"Deleting this role removes it from every user. This action cannot be undone.": "Supprimer ce rôle le retire de tous les utilisateurs. Cette action est irréversible.",
|
||||
"Delete role": "Supprimer le rôle",
|
||||
"This will remove the role and unassign it from every member. This action cannot be undone.": "Le rôle sera supprimé et retiré de tous les membres. Cette action est irréversible.",
|
||||
"to confirm:": "pour confirmer :",
|
||||
"Confirm role name": "Confirmer le nom du rôle",
|
||||
"Name is required": "Le nom est requis",
|
||||
"Name is too long (max 120)": "Le nom est trop long (max 120)",
|
||||
"Description is too long (max 2000)": "La description est trop longue (max 2000)",
|
||||
"e.g. Formateur": "ex. Formateur",
|
||||
"What this role can do, in plain words": "Ce que ce rôle permet de faire, en quelques mots",
|
||||
"User roles": "Rôles utilisateur",
|
||||
"User role assignments": "Attributions de rôle utilisateur",
|
||||
"Assigned roles": "Rôles attribués",
|
||||
"A user inherits the union of every role's permissions. The owner shortcut admin:* overrides everything else.": "Un utilisateur hérite de l'union des permissions de tous ses rôles. Le raccourci propriétaire admin:* écrase tout le reste.",
|
||||
"Pick one or more roles": "Choisissez un ou plusieurs rôles",
|
||||
"Roles assigned to user": "Rôles attribués à l'utilisateur",
|
||||
"Effective permissions preview": "Aperçu des permissions effectives",
|
||||
"Permission preview requires the roles:manage permission to read role definitions.": "L'aperçu des permissions nécessite la permission roles:manage pour lire les définitions de rôle.",
|
||||
"No roles selected.": "Aucun rôle sélectionné.",
|
||||
"No permissions are granted by the selected roles yet.": "Les rôles sélectionnés n'accordent aucune permission pour l'instant.",
|
||||
"database_view.node.header_label": "Base de données",
|
||||
"database_view.placeholder.not_supported": "Le type de vue \"{{viewType}}\" n'est pas encore pris en charge. Il sera disponible dans une prochaine mise à jour.",
|
||||
"database_view.table.empty_state": "Aucune ligne trouvée dans cette vue.",
|
||||
"database_view.table.page_info": "Page {{page}} — {{total}} lignes au total",
|
||||
"database_view.table.prev": "Précédent",
|
||||
"database_view.table.next": "Suivant",
|
||||
"database_view.error.title": "Impossible de charger la vue",
|
||||
"database_view.error.generic": "Une erreur inattendue s'est produite lors du chargement de la vue.",
|
||||
"database_view.error.view_not_found": "Cette vue n'existe plus ou a été supprimée.",
|
||||
"database_view.error.permission_denied": "Vous n'avez pas la permission de lire cette vue.",
|
||||
"database_view.error.retry": "Réessayer",
|
||||
"database_view.error.tables_load": "Échec du chargement des tables depuis le bridge.",
|
||||
"database_view.error.views_load": "Échec du chargement des vues pour cette table.",
|
||||
"database_view.modal.title": "Insérer une vue de base de données",
|
||||
"database_view.modal.step1": "Choisir une table",
|
||||
"database_view.modal.step2": "Choisir une vue",
|
||||
"database_view.modal.search_tables": "Rechercher des tables...",
|
||||
"database_view.modal.no_tables": "Aucune table trouvée. Vérifiez que le bridge est en cours d'exécution.",
|
||||
"database_view.modal.no_views": "Aucune vue trouvée pour cette table.",
|
||||
"database_view.modal.select_view": "Sélectionnez une vue à intégrer :",
|
||||
"database_view.modal.back": "Retour",
|
||||
"database_view.modal.insert": "Insérer",
|
||||
"database_view.kanban.empty_column": "Aucune carte",
|
||||
"database_view.kanban.no_groupby_field": "Aucun champ à sélection unique trouvé. Le kanban nécessite un champ à sélection unique pour regrouper les cartes.",
|
||||
"database_view.calendar.no_date_field": "Aucun champ de date trouvé. Le calendrier nécessite un champ de date pour positionner les événements.",
|
||||
"database_view.calendar.view_month": "Mois",
|
||||
"database_view.calendar.view_week": "Semaine",
|
||||
"database_view.calendar.view_day": "Jour",
|
||||
"database_view.edit.permission_denied": "Vous n'avez pas la permission de modifier ce champ.",
|
||||
"database_view.edit.read_only_mode": "Lecture seule — vous n'avez pas accès en écriture à cette base de données.",
|
||||
"database_view.row_detail.title": "Détails de la ligne",
|
||||
"database_view.row_detail.primary_badge": "primaire",
|
||||
"database_view.row_detail.no_fields": "Aucun champ à afficher.",
|
||||
"backlinks.panel.title": "Références liées",
|
||||
"backlinks.group.wikilinks": "Wikilinks",
|
||||
"backlinks.group.mentions": "Mentions",
|
||||
"backlinks.group.embeds": "Embeds de base de données",
|
||||
"backlinks.empty": "Aucune page ne pointe ici pour le moment.",
|
||||
"backlinks.error": "Impossible de charger les références liées.",
|
||||
"backlinks.retry": "Réessayer",
|
||||
"backlinks.untitled": "Sans titre",
|
||||
"wikilink.suggestion.no_results": "Aucune page correspondante",
|
||||
"wikilink.suggestion.type_to_search": "Tapez pour rechercher des pages...",
|
||||
"wikilink.broken": "Page introuvable ou supprimée",
|
||||
"slash_commands.page_title": "Commandes slash",
|
||||
"slash_commands.create_button": "Nouvelle commande",
|
||||
"slash_commands.create_title": "Créer une commande slash",
|
||||
"slash_commands.edit_title": "Modifier la commande slash",
|
||||
"slash_commands.col_keyword": "Mot-clé",
|
||||
"slash_commands.col_label": "Libellé",
|
||||
"slash_commands.col_action_type": "Type d'action",
|
||||
"slash_commands.col_enabled": "Activée",
|
||||
"slash_commands.keyword_label": "Mot-clé",
|
||||
"slash_commands.keyword_description": "Lettres minuscules, chiffres et tirets uniquement. Utilisé comme /mot-clé dans l'éditeur.",
|
||||
"slash_commands.keyword_format_error": "Seuls les lettres minuscules, chiffres et tirets sont autorisés",
|
||||
"slash_commands.label_label": "Libellé",
|
||||
"slash_commands.label_required": "Le libellé est requis",
|
||||
"slash_commands.description_label": "Description",
|
||||
"slash_commands.description_placeholder": "Description courte affichée dans le menu slash",
|
||||
"slash_commands.icon_label": "Icône",
|
||||
"slash_commands.icon_description": "Nom d'icône Tabler (ex: IconNotes) ou laisser vide",
|
||||
"slash_commands.action_type_label": "Type d'action",
|
||||
"slash_commands.action_config_section": "Configuration de l'action",
|
||||
"slash_commands.enabled_label": "Activée",
|
||||
"slash_commands.template_label": "Contenu du template",
|
||||
"slash_commands.template_description": "Texte Markdown ou JSON Tiptap à insérer au curseur",
|
||||
"slash_commands.rows_label": "Lignes",
|
||||
"slash_commands.cols_label": "Colonnes",
|
||||
"slash_commands.header_row_label": "Inclure une ligne d'en-tête",
|
||||
"slash_commands.url_label": "URL à intégrer",
|
||||
"slash_commands.url_required": "Une URL valide commençant par http est requise",
|
||||
"slash_commands.webhook_url_label": "URL du webhook",
|
||||
"slash_commands.webhook_https_required": "L'URL du webhook doit commencer par https://",
|
||||
"slash_commands.webhook_headers_label": "En-têtes supplémentaires (JSON)",
|
||||
"slash_commands.webhook_headers_description": "Objet JSON optionnel d'en-têtes HTTP supplémentaires",
|
||||
"slash_commands.webhook_security_title": "Note de sécurité",
|
||||
"slash_commands.webhook_security_note": "Ne jamais inclure de secrets dans les en-têtes stockés. Utilisez un proxy gestionnaire de secrets devant votre endpoint webhook.",
|
||||
"slash_commands.language_label": "Langage du code",
|
||||
"slash_commands.language_required": "Le langage est requis",
|
||||
"slash_commands.snippet_code_label": "Code de départ",
|
||||
"slash_commands.snippet_code_description": "Code optionnel inséré avec le snippet",
|
||||
"slash_commands.enable_tooltip": "Activer cette commande",
|
||||
"slash_commands.disable_tooltip": "Désactiver cette commande",
|
||||
"slash_commands.delete_confirm": "Supprimer la commande slash \"{{label}}\" ? Cette action est irréversible.",
|
||||
"slash_commands.create_success": "Commande slash créée",
|
||||
"slash_commands.update_success": "Commande slash mise à jour",
|
||||
"slash_commands.delete_success": "Commande slash supprimée",
|
||||
"slash_commands.load_error": "Impossible de charger les commandes slash",
|
||||
"slash_commands.empty_state": "Aucune commande slash personnalisée pour l'instant. Créez-en une pour commencer.",
|
||||
"slash_commands.access_denied_title": "Accès refusé",
|
||||
"slash_commands.access_denied_description": "Vous avez besoin de la permission slash_commands:manage pour accéder à cette page.",
|
||||
"dual_editor.switch_to_markdown": "Passer en source markdown",
|
||||
"dual_editor.switch_to_wysiwyg": "Passer en éditeur visuel",
|
||||
"dual_editor.switch_warning_title": "Perte de données potentielle lors du changement",
|
||||
"dual_editor.switch_warning_to_md": "Certains types de blocs ne peuvent pas être représentés en markdown. Les éléments suivants peuvent être altérés :",
|
||||
"dual_editor.switch_warning_to_wysiwyg": "Certains tokens markdown n'ont pas pu être reconvertis en contenu riche. Les éléments suivants peuvent être perdus :",
|
||||
"dual_editor.switch_anyway": "Changer quand même",
|
||||
"dual_editor.markdown_editor_label": "Éditeur de source markdown",
|
||||
"graph.page_title": "Graphe de connaissance",
|
||||
"graph.search_placeholder": "Rechercher des noeuds...",
|
||||
"graph.filters_label": "Filtres",
|
||||
"graph.space_filter_label": "Space",
|
||||
"graph.all_spaces": "Tous les spaces",
|
||||
"graph.edge_types_label": "Types de liens",
|
||||
"graph.edge_type_wikilink": "Wikilink",
|
||||
"graph.edge_type_mention": "Mention",
|
||||
"graph.edge_type_database_embed": "Embed base de donnees",
|
||||
"graph.depth_label": "Profondeur : {{depth}}",
|
||||
"graph.include_orphans_label": "Inclure les orphelins",
|
||||
"graph.stats_label": "Statistiques",
|
||||
"graph.nodes_unit": "noeuds",
|
||||
"graph.edges_unit": "liens",
|
||||
"graph.truncated_warning": "Graphe tronque a 1000 noeuds — appliquer des filtres pour reduire le scope",
|
||||
"graph.reset_filters": "Reinitialiser les filtres",
|
||||
"graph.open_page": "Ouvrir la page",
|
||||
"graph.close_panel": "Fermer le panneau",
|
||||
"graph.in_links": "entrant",
|
||||
"graph.out_links": "sortant",
|
||||
"graph.orphan_label": "orphelin",
|
||||
"graph.untitled_page": "(sans titre)",
|
||||
"graph.error_title": "Erreur de chargement du graphe",
|
||||
"graph.error_generic": "Impossible de charger le graphe de connaissance",
|
||||
"graph.legend_title": "Legende",
|
||||
"graph.legend_wikilink": "Wikilink",
|
||||
"graph.legend_parent_child": "Parent-enfant",
|
||||
"graph.legend_aria_label": "Legende des types de liens : ligne pleine = wikilink, ligne pointillee = hierarchie parent-enfant",
|
||||
"graph.space_graph_title": "Graphe — {{spaceName}}",
|
||||
"graph.space_graph_menu_label": "Graphe",
|
||||
"templates.page_title": "Modeles",
|
||||
"templates.create_button": "Nouveau modele",
|
||||
"templates.create_title": "Creer un modele",
|
||||
"templates.edit_title": "Modifier le modele",
|
||||
"templates.search_placeholder": "Rechercher des modeles...",
|
||||
"templates.name_label": "Nom",
|
||||
"templates.name_placeholder": "Nom du modele",
|
||||
"templates.name_required": "Le nom est obligatoire",
|
||||
"templates.icon_label": "Icone",
|
||||
"templates.icon_placeholder": "ex: calendrier",
|
||||
"templates.icon_description": "Texte court ou emoji pour l'icone du modele",
|
||||
"templates.category_label": "Categorie",
|
||||
"templates.description_label": "Description",
|
||||
"templates.description_placeholder": "Decrivez l'usage de ce modele",
|
||||
"templates.category_meeting": "Reunion",
|
||||
"templates.category_project": "Projet",
|
||||
"templates.category_wiki": "Wiki",
|
||||
"templates.category_todo": "Todo",
|
||||
"templates.category_custom": "Personnalise",
|
||||
"templates.create_success": "Modele cree",
|
||||
"templates.update_success": "Modele mis a jour",
|
||||
"templates.delete_success": "Modele supprime",
|
||||
"templates.set_default_success": "Modele par defaut mis a jour",
|
||||
"templates.instantiate_error": "Impossible de creer la page depuis le modele",
|
||||
"templates.empty_state": "Aucun modele trouve",
|
||||
"templates.built_in_badge": "Integre",
|
||||
"templates.default_badge": "Modele par defaut",
|
||||
"templates.usage_count": "Utilise {{count}}x",
|
||||
"templates.actions_menu": "Actions du modele",
|
||||
"templates.use_action": "Utiliser",
|
||||
"templates.edit_action": "Modifier",
|
||||
"templates.delete_action": "Supprimer",
|
||||
"templates.set_default_action": "Definir par defaut",
|
||||
"templates.delete_confirm_title": "Supprimer le modele",
|
||||
"templates.delete_confirm_body": "Supprimer le modele \"{{name}}\" ? Cette action est irreversible.",
|
||||
"templates.use_modal_title": "Utiliser le modele",
|
||||
"templates.use_modal_description": "Ouvrez l'editeur et cliquez sur le bouton \"Nouvelle page depuis un modele\" dans la barre laterale pour creer une page depuis \"{{name}}\".",
|
||||
"templates.new_from_template": "Depuis un modele",
|
||||
"templates.picker_title": "Choisir un modele",
|
||||
"acadenice.notifications.title": "Notifications",
|
||||
"acadenice.notifications.empty": "Aucune notification",
|
||||
"acadenice.notifications.mark_all_read": "Tout marquer comme lu",
|
||||
"acadenice.notifications.all_read": "Toutes les notifications ont ete marquees comme lues",
|
||||
"acadenice.notifications.load_more": "Charger plus",
|
||||
"acadenice.notifications.prefs_saved": "Preferences de notification enregistrees",
|
||||
"acadenice.notifications.prefs_error": "Echec de l'enregistrement des preferences",
|
||||
"acadenice.notifications.prefs.title": "Parametres de notification",
|
||||
"acadenice.notifications.prefs.subtitle": "Choisissez comment et quand vous etes notifie.",
|
||||
"acadenice.notifications.prefs.email_mentions": "Email — mentions sur une page",
|
||||
"acadenice.notifications.prefs.email_mentions_desc": "Recevoir un email quand quelqu'un vous mentionne sur une page.",
|
||||
"acadenice.notifications.prefs.email_replies": "Email — reponses a vos commentaires",
|
||||
"acadenice.notifications.prefs.email_replies_desc": "Recevoir un email quand quelqu'un repond a votre commentaire.",
|
||||
"acadenice.notifications.prefs.email_shares": "Email — mises a jour de pages",
|
||||
"acadenice.notifications.prefs.email_shares_desc": "Recevoir un email quand une page surveillee est mise a jour.",
|
||||
"acadenice.notifications.prefs.in_app_mentions": "In-app — mentions sur une page",
|
||||
"acadenice.notifications.prefs.in_app_mentions_desc": "Recevoir une notification quand quelqu'un vous mentionne sur une page.",
|
||||
"acadenice.notifications.prefs.in_app_replies": "In-app — reponses a vos commentaires",
|
||||
"acadenice.notifications.prefs.in_app_replies_desc": "Recevoir une notification quand quelqu'un repond a votre commentaire.",
|
||||
"database_view.row_detail.tab_fields": "Champs",
|
||||
"database_view.row_detail.tab_comments": "Commentaires",
|
||||
"acadenice.comments.open": "Ouverts",
|
||||
"acadenice.comments.resolved": "Resolus",
|
||||
"acadenice.comments.empty": "Aucun commentaire.",
|
||||
"acadenice.comments.new_placeholder": "Ecrire un commentaire...",
|
||||
"acadenice.comments.reply_placeholder": "Ecrire une reponse...",
|
||||
"acadenice.comments.send": "Commenter",
|
||||
"acadenice.comments.send_reply": "Repondre",
|
||||
"acadenice.comments.reply": "Repondre",
|
||||
"acadenice.comments.cancel": "Annuler",
|
||||
"acadenice.comments.resolve_action": "Resoudre le fil",
|
||||
"acadenice.comments.reopen_action": "Rouvrir le fil",
|
||||
"acadenice.comments.delete_action": "Supprimer",
|
||||
"acadenice.comments.resolved_badge": "Resolu",
|
||||
"acadenice.comments.unknown_user": "Utilisateur inconnu"
|
||||
"What can I help you with?": "Que puis-je faire pour vous aider ?"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "AcadeDoc",
|
||||
"short_name": "AcadeDoc",
|
||||
"name": "Docmost",
|
||||
"short_name": "Docmost",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#222",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import Page from "@/pages/page/page";
|
|||
import AccountSettings from "@/pages/settings/account/account-settings";
|
||||
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
|
||||
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
|
||||
import WorkspaceBranding from "@/pages/settings/workspace/workspace-branding";
|
||||
import Groups from "@/pages/settings/group/groups";
|
||||
import GroupInfo from "./pages/settings/group/group-info";
|
||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
||||
|
|
@ -36,28 +35,16 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
|
|||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
|
||||
import TemplateList from "@/ee/template/pages/template-list";
|
||||
import TemplateEditor from "@/ee/template/pages/template-editor";
|
||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
|
||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||
import RolesListPage from "@/features/acadenice/rbac/pages/roles-list.page";
|
||||
import RoleDetailPage from "@/features/acadenice/rbac/pages/role-detail.page";
|
||||
import UserRolesPanelPage from "@/features/acadenice/rbac/pages/user-roles-panel";
|
||||
// Acadenice R3.3 — custom slash commands admin page
|
||||
import SlashCommandsPage from "@/features/acadenice/slash-commands-admin/pages/slash-commands-page";
|
||||
// Acadenice R3.5.2 — knowledge graph view
|
||||
import GraphPage from "@/features/acadenice/graph/pages/graph-page";
|
||||
// Acadenice R3.6 — page templates
|
||||
import TemplatesAdminPage from "@/features/acadenice/templates-admin/pages/templates-page";
|
||||
// Acadenice R3.7 — mention notifications
|
||||
import AcadeniceNotificationsPage from "@/features/acadenice/notifications/pages/notifications-page";
|
||||
import NotificationPreferencesPage from "@/features/acadenice/notifications/pages/notification-preferences-page";
|
||||
// Acadenice R4.3 — Web Clipper token management
|
||||
import ClipperTokensPage from "@/features/acadenice/clipper/pages/clipper-tokens-page";
|
||||
// Acadenice R4.5 — EE replacement: audit log, API keys, OIDC security status
|
||||
import AcadeniceAuditLogPage from "@/features/acadenice/audit-log/pages/audit-log-page";
|
||||
import AcadeniceApiKeysPage from "@/features/acadenice/api-keys/pages/api-keys-page";
|
||||
import AcadeniceSecurityPage from "@/features/acadenice/oidc-status/pages/security-page";
|
||||
// Acadenice R4.6 — space-scoped graph view
|
||||
import SpaceGraph from "@/pages/space/space-graph";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -101,17 +88,17 @@ export default function App() {
|
|||
|
||||
<Route element={<Layout />}>
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
{/* Acadenice R3.5.2 — knowledge graph */}
|
||||
<Route path={"/graph"} element={<GraphPage />} />
|
||||
{/* Acadenice R3.7 — notifications full page */}
|
||||
<Route path={"/notifications"} element={<AcadeniceNotificationsPage />} />
|
||||
<Route path={"/ai"} element={<AiChat />} />
|
||||
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
|
||||
<Route path={"/spaces"} element={<SpacesPage />} />
|
||||
<Route path={"/favorites"} element={<FavoritesPage />} />
|
||||
<Route path={"/templates"} element={<TemplatesAdminPage />} />
|
||||
<Route path={"/templates"} element={<TemplateList />} />
|
||||
<Route
|
||||
path={"/templates/:templateId"}
|
||||
element={<TemplateEditor />}
|
||||
/>
|
||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
|
||||
{/* Acadenice R4.6 — space-scoped graph view */}
|
||||
<Route path={"/s/:spaceSlug/graph"} element={<SpaceGraph />} />
|
||||
<Route
|
||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||
element={<Page />}
|
||||
|
|
@ -123,36 +110,19 @@ export default function App() {
|
|||
path={"account/preferences"}
|
||||
element={<AccountPreferences />}
|
||||
/>
|
||||
<Route path={"account/api-keys"} element={<UserApiKeys />} />
|
||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||
<Route path={"api-keys"} element={<AcadeniceApiKeysPage />} />
|
||||
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
|
||||
<Route path={"groups"} element={<Groups />} />
|
||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||
<Route path={"spaces"} element={<Spaces />} />
|
||||
<Route path={"sharing"} element={<Shares />} />
|
||||
<Route path={"security"} element={<Security />} />
|
||||
<Route path={"audit"} element={<AcadeniceAuditLogPage />} />
|
||||
{/* Acadenice R2.2 — RBAC dynamique */}
|
||||
<Route path={"roles"} element={<RolesListPage />} />
|
||||
<Route path={"roles/:id"} element={<RoleDetailPage />} />
|
||||
<Route
|
||||
path={"users/:userId/roles"}
|
||||
element={<UserRolesPanelPage />}
|
||||
/>
|
||||
{/* Acadenice R3.3 — custom slash commands admin */}
|
||||
<Route path={"slash-commands"} element={<SlashCommandsPage />} />
|
||||
{/* Acadenice R3.6 — page templates */}
|
||||
<Route path={"templates"} element={<TemplatesAdminPage />} />
|
||||
{/* Acadenice R3.7 — notification preferences */}
|
||||
<Route path={"notifications"} element={<NotificationPreferencesPage />} />
|
||||
{/* Acadenice R4.3 — Web Clipper token management */}
|
||||
<Route path={"clipper-tokens"} element={<ClipperTokensPage />} />
|
||||
{/* Acadenice R4.4 — Workspace branding */}
|
||||
<Route path={"branding"} element={<WorkspaceBranding />} />
|
||||
{/* Acadenice R4.5 — open source EE replacements */}
|
||||
<Route path={"acadenice/audit-log"} element={<AcadeniceAuditLogPage />} />
|
||||
<Route path={"acadenice/api-keys"} element={<AcadeniceApiKeysPage />} />
|
||||
<Route path={"acadenice/security"} element={<AcadeniceSecurityPage />} />
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-to
|
|||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||
import { isCloud, getAppName } from "@/lib/config.ts";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import {
|
||||
SearchControl,
|
||||
SearchMobileControl,
|
||||
|
|
@ -84,11 +84,11 @@ export function AppHeader() {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Link to="/home" className={classes.brand} aria-label={getAppName()}>
|
||||
<Link to="/home" className={classes.brand} aria-label="Docmost">
|
||||
<Box hiddenFrom="sm" className={classes.brandIcon}>
|
||||
<img
|
||||
src="/icons/favicon-32x32.png"
|
||||
alt={getAppName()}
|
||||
alt="Docmost"
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
|
|
@ -99,7 +99,7 @@ export function AppHeader() {
|
|||
style={{ userSelect: "none" }}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
{getAppName()}
|
||||
Docmost
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
IconLayoutGrid,
|
||||
IconSettings,
|
||||
IconUserPlus,
|
||||
IconAffiliate,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./global-sidebar.module.css";
|
||||
|
|
@ -26,8 +25,6 @@ const mainNavItems = [
|
|||
{ label: "Home", icon: IconHome, path: "/home" },
|
||||
{ label: "Favorites", icon: IconStar, path: "/favorites" },
|
||||
{ label: "Spaces", icon: IconLayoutGrid, path: "/spaces" },
|
||||
// Acadenice R3.5.2 — knowledge graph view
|
||||
{ label: "graph.page_title", icon: IconAffiliate, path: "/graph" },
|
||||
];
|
||||
|
||||
export default function GlobalSidebar() {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import {
|
|||
getBilling,
|
||||
getBillingPlans,
|
||||
} from "@/ee/billing/services/billing-service.ts";
|
||||
import { getAcadeniceAuditLogs } from "@/features/acadenice/audit-log/services/audit-log.service";
|
||||
import { listAcadeniceApiKeys } from "@/features/acadenice/api-keys/services/api-key.service";
|
||||
import { getSpaces } from "@/features/space/services/space-service.ts";
|
||||
import { getGroups } from "@/features/group/services/group-service.ts";
|
||||
import { QueryParams } from "@/lib/types.ts";
|
||||
|
|
@ -108,18 +106,3 @@ export const prefetchScimTokens = () => {
|
|||
queryFn: () => getScimTokens({}),
|
||||
});
|
||||
};
|
||||
|
||||
// Acadenice R4.5 — open source prefetch functions
|
||||
export const prefetchAcadeniceAuditLogs = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["acadenice-audit-logs", { limit: 50, offset: 0 }],
|
||||
queryFn: () => getAcadeniceAuditLogs({ limit: 50, offset: 0 }),
|
||||
});
|
||||
};
|
||||
|
||||
export const prefetchAcadeniceApiKeys = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["acadenice-api-keys"],
|
||||
queryFn: () => listAcadeniceApiKeys(),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,13 +15,7 @@ import {
|
|||
IconSparkles,
|
||||
IconHistory,
|
||||
IconShieldCheck,
|
||||
IconShieldLock,
|
||||
IconSlash,
|
||||
IconTemplate,
|
||||
IconBell,
|
||||
IconScissors,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -44,8 +38,6 @@ import {
|
|||
prefetchWorkspaceMembers,
|
||||
prefetchAuditLogs,
|
||||
prefetchVerifiedPages,
|
||||
prefetchAcadeniceAuditLogs,
|
||||
prefetchAcadeniceApiKeys,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
|
|
@ -59,10 +51,6 @@ type DataItem = {
|
|||
feature?: string;
|
||||
role?: "admin" | "owner";
|
||||
env?: "cloud" | "selfhosted";
|
||||
// Acadenice R2.2 — visible only when the JWT-derived (or fallback admin)
|
||||
// permission set says the user can manage roles. Backend remains the
|
||||
// source of truth (returns 403 otherwise).
|
||||
acadeniceCanManageRoles?: boolean;
|
||||
};
|
||||
|
||||
type DataGroup = {
|
||||
|
|
@ -81,22 +69,10 @@ const groupedData: DataGroup[] = [
|
|||
path: "/settings/account/preferences",
|
||||
},
|
||||
{
|
||||
// Acadenice R3.7 — notification preferences dedicated page
|
||||
label: "Notifications",
|
||||
icon: IconBell,
|
||||
path: "/settings/notifications",
|
||||
},
|
||||
{
|
||||
// Acadenice R4.5 — open source API keys (replaces EE-gated page)
|
||||
label: "API keys",
|
||||
icon: IconKey,
|
||||
path: "/settings/acadenice/api-keys",
|
||||
},
|
||||
{
|
||||
// Acadenice R4.3 — Web Clipper token management
|
||||
label: "Clipper tokens",
|
||||
icon: IconScissors,
|
||||
path: "/settings/clipper-tokens",
|
||||
path: "/settings/account/api-keys",
|
||||
feature: Feature.API_KEYS,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -104,13 +80,6 @@ const groupedData: DataGroup[] = [
|
|||
heading: "Workspace",
|
||||
items: [
|
||||
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
|
||||
{
|
||||
// Acadenice R4.4 — workspace brand colors
|
||||
label: "Branding",
|
||||
icon: IconBrush,
|
||||
path: "/settings/branding",
|
||||
role: "admin" as const,
|
||||
},
|
||||
{ label: "Members", icon: IconUsers, path: "/settings/members" },
|
||||
{
|
||||
label: "Billing",
|
||||
|
|
@ -120,45 +89,40 @@ const groupedData: DataGroup[] = [
|
|||
env: "cloud",
|
||||
},
|
||||
{
|
||||
// Acadenice R4.5 — open source security/OIDC status (replaces EE-gated page)
|
||||
label: "Security & SSO",
|
||||
icon: IconLock,
|
||||
path: "/settings/acadenice/security",
|
||||
path: "/settings/security",
|
||||
feature: Feature.SECURITY_SETTINGS,
|
||||
role: "admin",
|
||||
},
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{
|
||||
label: "Roles",
|
||||
icon: IconShieldLock,
|
||||
path: "/settings/roles",
|
||||
acadeniceCanManageRoles: true,
|
||||
},
|
||||
{
|
||||
// Acadenice R3.3 — custom slash commands admin
|
||||
label: "Slash commands",
|
||||
icon: IconSlash,
|
||||
path: "/settings/slash-commands",
|
||||
acadeniceCanManageRoles: true,
|
||||
},
|
||||
{
|
||||
// Acadenice R3.6 — page templates
|
||||
label: "Templates",
|
||||
icon: IconTemplate,
|
||||
path: "/settings/templates",
|
||||
},
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||
{
|
||||
label: "Verified pages",
|
||||
icon: IconShieldCheck,
|
||||
path: "/settings/verifications",
|
||||
feature: Feature.PAGE_VERIFICATION,
|
||||
},
|
||||
{
|
||||
label: "API management",
|
||||
icon: IconKey,
|
||||
path: "/settings/api-keys",
|
||||
feature: Feature.API_KEYS,
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
label: "AI settings",
|
||||
icon: IconSparkles,
|
||||
path: "/settings/ai",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
label: "Audit log",
|
||||
icon: IconHistory,
|
||||
path: "/settings/audit",
|
||||
role: "admin",
|
||||
feature: Feature.AUDIT_LOGS,
|
||||
role: "owner",
|
||||
env: "selfhosted",
|
||||
},
|
||||
],
|
||||
|
|
@ -181,7 +145,6 @@ export default function SettingsSidebar() {
|
|||
const [active, setActive] = useState(location.pathname);
|
||||
const { goBack } = useSettingsNavigation();
|
||||
const { isAdmin, isOwner } = useUserRole();
|
||||
const { canManageRoles: acadeniceCanManageRoles } = useAcadenicePermissions();
|
||||
const [entitlements] = useAtom(entitlementAtom);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
|
|
@ -199,7 +162,6 @@ export default function SettingsSidebar() {
|
|||
if (item.env === "selfhosted" && isCloud()) return false;
|
||||
if (item.role === "admin" && !isAdmin) return false;
|
||||
if (item.role === "owner" && !isOwner) return false;
|
||||
if (item.acadeniceCanManageRoles && !acadeniceCanManageRoles) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -252,15 +214,13 @@ export default function SettingsSidebar() {
|
|||
prefetchHandler = prefetchShares;
|
||||
break;
|
||||
case "API keys":
|
||||
// Acadenice R4.5: points to open source endpoint
|
||||
prefetchHandler = prefetchAcadeniceApiKeys;
|
||||
prefetchHandler = prefetchApiKeys;
|
||||
break;
|
||||
case "API management":
|
||||
prefetchHandler = prefetchApiKeyManagement;
|
||||
break;
|
||||
case "Audit log":
|
||||
// Acadenice R4.5: points to open source endpoint
|
||||
prefetchHandler = prefetchAcadeniceAuditLogs;
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
break;
|
||||
case "Verified pages":
|
||||
prefetchHandler = prefetchVerifiedPages;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function Error404() {
|
|||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("404 page not found")} - DocAdenice</title>
|
||||
<title>{t("404 page not found")} - Docmost</title>
|
||||
</Helmet>
|
||||
<Container className={classes.root}>
|
||||
<Title className={classes.title}>{t("404 page not found")}</Title>
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateAcadeniceApiKeyMutation } from "../queries/api-key.queries";
|
||||
import { CreateAcadeniceApiKeyResponse } from "../types/api-key.types";
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: "30", label: "30 days" },
|
||||
{ value: "90", label: "90 days" },
|
||||
{ value: "365", label: "1 year" },
|
||||
{ value: "0", label: "No expiry" },
|
||||
];
|
||||
|
||||
interface CreateApiKeyModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (result: CreateAcadeniceApiKeyResponse) => void;
|
||||
}
|
||||
|
||||
export function AcadeniceCreateApiKeyModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateApiKeyModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [label, setLabel] = useState("");
|
||||
const [duration, setDuration] = useState<string | null>("30");
|
||||
const createMutation = useCreateAcadeniceApiKeyMutation();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!label.trim()) return;
|
||||
|
||||
const durationDays =
|
||||
duration === "0" || duration === null ? null : Number(duration);
|
||||
|
||||
createMutation.mutate(
|
||||
{ label: label.trim(), durationDays },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
onSuccess(result);
|
||||
setLabel("");
|
||||
setDuration("30");
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Create API key")}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
color="yellow"
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
"This token grants full access to your account. Store it securely and never share it.",
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<TextInput
|
||||
label={t("Label")}
|
||||
placeholder={t("e.g. CI pipeline, personal script")}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.currentTarget.value)}
|
||||
required
|
||||
aria-label={t("API key label")}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t("Expiry")}
|
||||
data={DURATION_OPTIONS}
|
||||
value={duration}
|
||||
onChange={setDuration}
|
||||
aria-label={t("API key expiry duration")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!label.trim()}
|
||||
aria-label={t("Generate new API key")}
|
||||
>
|
||||
{t("Generate")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { Button, Group, Modal, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AcadeniceApiKey } from "../types/api-key.types";
|
||||
import { useRevokeAcadeniceApiKeyMutation } from "../queries/api-key.queries";
|
||||
|
||||
interface RevokeApiKeyModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
apiKey: AcadeniceApiKey | null;
|
||||
}
|
||||
|
||||
export function AcadeniceRevokeApiKeyModal({
|
||||
opened,
|
||||
onClose,
|
||||
apiKey,
|
||||
}: RevokeApiKeyModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const revokeMutation = useRevokeAcadeniceApiKeyMutation();
|
||||
|
||||
if (!apiKey) return null;
|
||||
|
||||
const handleRevoke = () => {
|
||||
revokeMutation.mutate(apiKey.id, { onSuccess: onClose });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t("Revoke API key")} size="sm">
|
||||
<Text size="sm" mb="md">
|
||||
{t(
|
||||
'Are you sure you want to revoke "{{label}}"? This action cannot be undone.',
|
||||
{ label: apiKey.label },
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={revokeMutation.isPending}
|
||||
onClick={handleRevoke}
|
||||
aria-label={t("Confirm revoke API key")}
|
||||
>
|
||||
{t("Revoke")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreateAcadeniceApiKeyResponse } from "../types/api-key.types";
|
||||
import CopyTextButton from "@/components/common/copy";
|
||||
|
||||
interface TokenCreatedModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
result: CreateAcadeniceApiKeyResponse | null;
|
||||
}
|
||||
|
||||
export function AcadeniceTokenCreatedModal({
|
||||
opened,
|
||||
onClose,
|
||||
result,
|
||||
}: TokenCreatedModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("API key created")}
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title={t("Important")}
|
||||
color="red"
|
||||
>
|
||||
{t(
|
||||
"This is the only time you can see this token. Copy it now — it cannot be retrieved later.",
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("Your new API key")}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
style={{ flex: 1 }}
|
||||
value={result.token}
|
||||
readOnly
|
||||
ff="monospace"
|
||||
aria-label={t("Generated API key token")}
|
||||
/>
|
||||
<CopyTextButton text={result.token} />
|
||||
</Group>
|
||||
|
||||
<Text fz="xs" c="dimmed">
|
||||
{t(
|
||||
"This token grants full access to your account. Treat it like a password.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Button fullWidth onClick={onClose} mt="sm" aria-label={t("Confirm token saved")}>
|
||||
{t("I have saved my token")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Menu,
|
||||
Space,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconDots, IconTrash, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { format } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import { useAcadeniceApiKeysQuery } from "../queries/api-key.queries";
|
||||
import { AcadeniceCreateApiKeyModal } from "../components/create-api-key-modal";
|
||||
import { AcadeniceTokenCreatedModal } from "../components/token-created-modal";
|
||||
import { AcadeniceRevokeApiKeyModal } from "../components/revoke-api-key-modal";
|
||||
import {
|
||||
AcadeniceApiKey,
|
||||
CreateAcadeniceApiKeyResponse,
|
||||
} from "../types/api-key.types";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
|
||||
export default function AcadeniceApiKeysPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: keys, isLoading } = useAcadeniceApiKeysQuery();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createdResult, setCreatedResult] =
|
||||
useState<CreateAcadeniceApiKeyResponse | null>(null);
|
||||
const [revokeTarget, setRevokeTarget] = useState<AcadeniceApiKey | null>(null);
|
||||
|
||||
const formatDate = (d: string | null) =>
|
||||
d ? format(new Date(d), "MMM dd, yyyy") : t("Never");
|
||||
|
||||
const isExpired = (expiresAt: string | null) =>
|
||||
expiresAt ? new Date(expiresAt) < new Date() : false;
|
||||
|
||||
const handleCreateSuccess = (result: CreateAcadeniceApiKeyResponse) => {
|
||||
setCreatedResult(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("API keys")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<SettingsTitle title={t("API keys")} />
|
||||
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
mb="md"
|
||||
p="sm"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Personal API keys grant full access to your account. Rotate them regularly and never commit them to source control.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label={t("Create new API key")}
|
||||
>
|
||||
{t("New API key")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Label")}</Table.Th>
|
||||
<Table.Th>{t("Last used")}</Table.Th>
|
||||
<Table.Th>{t("Expires")}</Table.Th>
|
||||
<Table.Th>{t("Created")}</Table.Th>
|
||||
<Table.Th aria-label={t("Action")} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text c="dimmed">{t("Loading...")}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : keys && keys.length > 0 ? (
|
||||
keys.map((key) => (
|
||||
<Table.Tr key={key.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{key.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(key.lastUsedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
fz="sm"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
c={isExpired(key.expiresAt) ? "red" : undefined}
|
||||
>
|
||||
{isExpired(key.expiresAt)
|
||||
? t("Expired")
|
||||
: formatDate(key.expiresAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(key.createdAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("API key actions for {{label}}", {
|
||||
label: key.label,
|
||||
})}
|
||||
>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={16} />}
|
||||
color="red"
|
||||
onClick={() => setRevokeTarget(key)}
|
||||
>
|
||||
{t("Revoke")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={5} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<AcadeniceCreateApiKeyModal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
|
||||
<AcadeniceTokenCreatedModal
|
||||
opened={!!createdResult}
|
||||
onClose={() => setCreatedResult(null)}
|
||||
result={createdResult}
|
||||
/>
|
||||
|
||||
<AcadeniceRevokeApiKeyModal
|
||||
opened={!!revokeTarget}
|
||||
onClose={() => setRevokeTarget(null)}
|
||||
apiKey={revokeTarget}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createAcadeniceApiKey,
|
||||
listAcadeniceApiKeys,
|
||||
revokeAcadeniceApiKey,
|
||||
} from "../services/api-key.service";
|
||||
import { CreateAcadeniceApiKeyRequest } from "../types/api-key.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const QUERY_KEY = ["acadenice-api-keys"];
|
||||
|
||||
export function useAcadeniceApiKeysQuery() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: listAcadeniceApiKeys,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAcadeniceApiKeyMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateAcadeniceApiKeyRequest) =>
|
||||
createAcadeniceApiKey(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to create API key"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeAcadeniceApiKeyMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => revokeAcadeniceApiKey(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
notifications.show({ message: t("API key revoked") });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to revoke API key"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import api from "@/lib/api-client";
|
||||
import {
|
||||
AcadeniceApiKey,
|
||||
CreateAcadeniceApiKeyRequest,
|
||||
CreateAcadeniceApiKeyResponse,
|
||||
} from "../types/api-key.types";
|
||||
|
||||
export async function listAcadeniceApiKeys(): Promise<AcadeniceApiKey[]> {
|
||||
const resp = await api.get("/v1/api-keys");
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
export async function createAcadeniceApiKey(
|
||||
data: CreateAcadeniceApiKeyRequest,
|
||||
): Promise<CreateAcadeniceApiKeyResponse> {
|
||||
const resp = await api.post("/v1/api-keys", data);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
export async function revokeAcadeniceApiKey(id: string): Promise<void> {
|
||||
await api.delete(`/v1/api-keys/${id}`);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
export interface AcadeniceApiKey {
|
||||
id: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
label: string;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreateAcadeniceApiKeyRequest {
|
||||
label: string;
|
||||
durationDays?: number | null;
|
||||
}
|
||||
|
||||
export interface CreateAcadeniceApiKeyResponse {
|
||||
token: string;
|
||||
keyInfo: AcadeniceApiKey;
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { Badge, Code, Table, Text, Tooltip } from "@mantine/core";
|
||||
import { format } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AcadeniceAuditLogEntry } from "../types/audit-log.types";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
|
||||
interface AuditLogTableProps {
|
||||
items: AcadeniceAuditLogEntry[] | undefined;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function truncateJson(obj: Record<string, unknown> | null): string {
|
||||
if (!obj) return "";
|
||||
const raw = JSON.stringify(obj);
|
||||
return raw.length > 120 ? raw.slice(0, 117) + "..." : raw;
|
||||
}
|
||||
|
||||
export function AcadeniceAuditLogTable({ items, isLoading }: AuditLogTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading) {
|
||||
return <Text c="dimmed">{t("Loading...")}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={700}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Timestamp")}</Table.Th>
|
||||
<Table.Th>{t("Actor")}</Table.Th>
|
||||
<Table.Th>{t("Event")}</Table.Th>
|
||||
<Table.Th>{t("Resource type")}</Table.Th>
|
||||
<Table.Th>{t("Resource ID")}</Table.Th>
|
||||
<Table.Th>{t("Details")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items && items.length > 0 ? (
|
||||
items.map((entry) => (
|
||||
<Table.Tr key={entry.id}>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{format(new Date(entry.createdAt), "yyyy-MM-dd HH:mm:ss")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{entry.actorEmail ? (
|
||||
<Text fz="sm">{entry.actorEmail}</Text>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">
|
||||
{entry.actorType}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Badge variant="light" size="sm">
|
||||
{entry.event}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Text fz="sm">{entry.resourceType}</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{entry.resourceId ? (
|
||||
<Code fz="xs">{entry.resourceId.slice(0, 8)}...</Code>
|
||||
) : (
|
||||
<Text fz="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{(entry.changes || entry.metadata) && (
|
||||
<Tooltip
|
||||
label={
|
||||
<Code fz="xs">
|
||||
{truncateJson(entry.changes ?? entry.metadata)}
|
||||
</Code>
|
||||
}
|
||||
multiline
|
||||
w={300}
|
||||
>
|
||||
<Text fz="xs" c="dimmed" style={{ cursor: "help" }}>
|
||||
{t("View")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={6} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import type { DateValue } from "@mantine/dates";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import { useAcadeniceAuditLogsQuery } from "../queries/audit-log.queries";
|
||||
import { AcadeniceAuditLogTable } from "../components/audit-log-table";
|
||||
import { AcadeniceAuditLogQuery } from "../types/audit-log.types";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
|
||||
const LIMIT = 50;
|
||||
|
||||
const EVENT_OPTIONS = [
|
||||
"page.created",
|
||||
"page.updated",
|
||||
"page.deleted",
|
||||
"space.created",
|
||||
"space.deleted",
|
||||
"user.invited",
|
||||
"user.deleted",
|
||||
"workspace.updated",
|
||||
].map((v) => ({ value: v, label: v }));
|
||||
|
||||
export default function AcadeniceAuditLogPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin, isOwner } = useUserRole();
|
||||
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [userId, setUserId] = useState("");
|
||||
const [action, setAction] = useState<string | null>(null);
|
||||
const [since, setSince] = useState<DateValue>(null);
|
||||
const [until, setUntil] = useState<DateValue>(null);
|
||||
|
||||
const toIso = (d: DateValue): string | undefined =>
|
||||
d instanceof Date ? d.toISOString() : undefined;
|
||||
|
||||
const queryParams: AcadeniceAuditLogQuery = {
|
||||
limit: LIMIT,
|
||||
offset,
|
||||
...(userId.trim() ? { userId: userId.trim() } : {}),
|
||||
...(action ? { action } : {}),
|
||||
...(since ? { since: toIso(since) } : {}),
|
||||
...(until ? { until: toIso(until) } : {}),
|
||||
};
|
||||
|
||||
const { data, isLoading } = useAcadeniceAuditLogsQuery(queryParams);
|
||||
|
||||
if (!isAdmin && !isOwner) {
|
||||
return (
|
||||
<Text c="dimmed">{t("You do not have permission to view audit logs.")}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / LIMIT) : 0;
|
||||
const currentPage = Math.floor(offset / LIMIT) + 1;
|
||||
|
||||
const resetFilters = () => {
|
||||
setUserId("");
|
||||
setAction(null);
|
||||
setSince(null);
|
||||
setUntil(null);
|
||||
setOffset(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Audit log")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<SettingsTitle title={t("Audit log")} />
|
||||
|
||||
<Stack gap="sm" mb="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder={t("Filter by event")}
|
||||
data={EVENT_OPTIONS}
|
||||
value={action}
|
||||
onChange={(v) => {
|
||||
setAction(v);
|
||||
setOffset(0);
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
w={220}
|
||||
size="sm"
|
||||
aria-label={t("Filter by event type")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
placeholder={t("Filter by user ID (UUID)")}
|
||||
value={userId}
|
||||
onChange={(e) => {
|
||||
setUserId(e.currentTarget.value);
|
||||
setOffset(0);
|
||||
}}
|
||||
size="sm"
|
||||
w={260}
|
||||
aria-label={t("Filter by user ID")}
|
||||
/>
|
||||
|
||||
<DatePickerInput
|
||||
placeholder={t("Since")}
|
||||
value={since}
|
||||
onChange={(v) => {
|
||||
setSince(v);
|
||||
setOffset(0);
|
||||
}}
|
||||
clearable
|
||||
size="sm"
|
||||
w={160}
|
||||
aria-label={t("Filter since date")}
|
||||
/>
|
||||
|
||||
<DatePickerInput
|
||||
placeholder={t("Until")}
|
||||
value={until}
|
||||
onChange={(v) => {
|
||||
setUntil(v);
|
||||
setOffset(0);
|
||||
}}
|
||||
clearable
|
||||
size="sm"
|
||||
w={160}
|
||||
aria-label={t("Filter until date")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={resetFilters}
|
||||
aria-label={t("Reset filters")}
|
||||
>
|
||||
{t("Reset")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{data && (
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("{{total}} entries", { total: data.total })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<AcadeniceAuditLogTable items={data?.items} isLoading={isLoading} />
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - LIMIT))}
|
||||
aria-label={t("Previous page")}
|
||||
>
|
||||
{t("Previous")}
|
||||
</Button>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{currentPage} / {totalPages}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={offset + LIMIT >= (data?.total ?? 0)}
|
||||
onClick={() => setOffset(offset + LIMIT)}
|
||||
aria-label={t("Next page")}
|
||||
>
|
||||
{t("Next")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { getAcadeniceAuditLogs } from "../services/audit-log.service";
|
||||
import { AcadeniceAuditLogQuery } from "../types/audit-log.types";
|
||||
|
||||
export function useAcadeniceAuditLogsQuery(params: AcadeniceAuditLogQuery = {}) {
|
||||
return useQuery({
|
||||
queryKey: ["acadenice-audit-logs", params],
|
||||
queryFn: () => getAcadeniceAuditLogs(params),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import api from "@/lib/api-client";
|
||||
import {
|
||||
AcadeniceAuditLogPage,
|
||||
AcadeniceAuditLogQuery,
|
||||
} from "../types/audit-log.types";
|
||||
|
||||
export async function getAcadeniceAuditLogs(
|
||||
params: AcadeniceAuditLogQuery = {},
|
||||
): Promise<AcadeniceAuditLogPage> {
|
||||
const resp = await api.get("/v1/audit-log", { params });
|
||||
return resp.data;
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
export interface AcadeniceAuditLogEntry {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
actorId: string | null;
|
||||
actorType: string;
|
||||
event: string;
|
||||
resourceType: string;
|
||||
resourceId: string | null;
|
||||
spaceId: string | null;
|
||||
changes: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ipAddress: string | null;
|
||||
createdAt: string;
|
||||
actorEmail: string | null;
|
||||
actorName: string | null;
|
||||
}
|
||||
|
||||
export interface AcadeniceAuditLogPage {
|
||||
items: AcadeniceAuditLogEntry[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface AcadeniceAuditLogQuery {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
userId?: string;
|
||||
action?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { LinkedReferencesPanel } from '../components/linked-references-panel';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
// Support optional defaultValue as second arg (same signature as t(key, defaultValue)).
|
||||
useTranslation: () => ({ t: (k: string, defaultValue?: string) => defaultValue ?? k }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Unit tests for LinkedReferencesPanel.
|
||||
*
|
||||
* The useBacklinks hook is mocked via vi.mock so we can control query state.
|
||||
* react-router-dom is mocked so navigate() calls are captured.
|
||||
*/
|
||||
|
||||
vi.mock('../queries/backlinks-query', () => ({
|
||||
useBacklinks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
import { useBacklinks } from '../queries/backlinks-query';
|
||||
|
||||
function renderPanel(pageId = 'page-1') {
|
||||
// Wrap in MantineProvider (required by Mantine components)
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<LinkedReferencesPanel pageId={pageId} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockResult = {
|
||||
wikilinks: [
|
||||
{
|
||||
source: {
|
||||
id: 'src-1',
|
||||
title: 'Source Page A',
|
||||
slugId: 'slug-a',
|
||||
icon: null,
|
||||
spaceSlug: 'main',
|
||||
spaceName: 'Main Space',
|
||||
},
|
||||
linkType: 'wikilink' as const,
|
||||
contextExcerpt: 'Check out [[Target Page]] for more info.',
|
||||
},
|
||||
],
|
||||
mentions: [],
|
||||
database_embeds: [],
|
||||
parentChild: [],
|
||||
total: 1,
|
||||
};
|
||||
|
||||
describe('LinkedReferencesPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
(useBacklinks as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
expect(screen.getByTestId('backlinks-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state with retry button', async () => {
|
||||
const refetch = vi.fn();
|
||||
(useBacklinks as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
data: undefined,
|
||||
refetch,
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
expect(screen.getByTestId('backlinks-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Retry')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Retry'));
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows empty state when no backlinks exist', () => {
|
||||
(useBacklinks as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { wikilinks: [], mentions: [], database_embeds: [], parentChild: [], total: 0 },
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
expect(screen.getByTestId('backlinks-empty')).toBeInTheDocument();
|
||||
expect(screen.getByText('No pages link here yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders wikilink entries with title and excerpt', () => {
|
||||
(useBacklinks as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: mockResult,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
expect(screen.getByTestId('backlinks-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Source Page A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Main Space')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Check out/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows total badge', () => {
|
||||
(useBacklinks as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: mockResult,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
// Multiple elements may contain "1" — verify at least one exists.
|
||||
expect(screen.getAllByText('1').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('navigates to source page on click', async () => {
|
||||
(useBacklinks as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: mockResult,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
|
||||
const row = screen.getByTestId('backlink-row-src-1');
|
||||
await userEvent.click(row);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/main/page/slug-a');
|
||||
});
|
||||
|
||||
it('renders multiple groups (wikilinks + mentions)', () => {
|
||||
(useBacklinks as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
wikilinks: [
|
||||
{ source: { id: 'src-1', title: 'Wiki Src', slugId: 's1', icon: null, spaceSlug: 'x', spaceName: 'X' }, linkType: 'wikilink', contextExcerpt: null },
|
||||
],
|
||||
mentions: [
|
||||
{ source: { id: 'src-2', title: 'Mention Src', slugId: 's2', icon: null, spaceSlug: 'x', spaceName: 'X' }, linkType: 'mention', contextExcerpt: null },
|
||||
],
|
||||
database_embeds: [],
|
||||
parentChild: [],
|
||||
total: 2,
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
expect(screen.getByText('Wiki Src')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mention Src')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
.backlinkRow {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
transition: background-color 80ms ease;
|
||||
}
|
||||
|
||||
.backlinkRow:hover {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconExternalLink,
|
||||
IconFileDescription,
|
||||
IconHash,
|
||||
IconInfoCircle,
|
||||
IconLink,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { buildPageUrl } from '@/features/page/page.utils.ts';
|
||||
import { useBacklinks } from '../queries/backlinks-query';
|
||||
import type { BacklinkEntry } from '../queries/backlinks-query';
|
||||
import classes from './linked-references-panel.module.css';
|
||||
|
||||
interface LinkedReferencesPanelProps {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel displaying all backlinks pointing to the current page, grouped by link type.
|
||||
*
|
||||
* Placement: rendered inside the page sidebar or as a bottom-panel sticky below
|
||||
* the editor (caller decides). The component is self-contained and stateless.
|
||||
*
|
||||
* Permission awareness: the backend already filters source pages the user cannot
|
||||
* read. This component renders what it receives — no additional client-side
|
||||
* permission check needed.
|
||||
*/
|
||||
export function LinkedReferencesPanel({ pageId }: LinkedReferencesPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError, refetch } = useBacklinks(pageId);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const result: Array<{ key: string; label: string; icon: React.ReactNode; entries: BacklinkEntry[] }> = [];
|
||||
|
||||
if (data.wikilinks.length > 0) {
|
||||
result.push({
|
||||
key: 'wikilinks',
|
||||
label: t('backlinks.group.wikilinks', 'Wikilinks'),
|
||||
icon: <IconLink size={14} />,
|
||||
entries: data.wikilinks,
|
||||
});
|
||||
}
|
||||
if (data.mentions.length > 0) {
|
||||
result.push({
|
||||
key: 'mentions',
|
||||
label: t('backlinks.group.mentions', 'Mentions'),
|
||||
icon: <IconHash size={14} />,
|
||||
entries: data.mentions,
|
||||
});
|
||||
}
|
||||
if (data.database_embeds.length > 0) {
|
||||
result.push({
|
||||
key: 'database_embeds',
|
||||
label: t('backlinks.group.embeds', 'Database embeds'),
|
||||
icon: <IconExternalLink size={14} />,
|
||||
entries: data.database_embeds,
|
||||
});
|
||||
}
|
||||
if (data.parentChild && data.parentChild.length > 0) {
|
||||
result.push({
|
||||
key: 'parent_child',
|
||||
label: t('backlinks.group.subpages', 'Sub-pages'),
|
||||
icon: <IconFileDescription size={14} />,
|
||||
entries: data.parentChild,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [data, t]);
|
||||
|
||||
const handleNavigate = (entry: BacklinkEntry) => {
|
||||
if (entry.source.slugId) {
|
||||
// Use the canonical builder so links land on the real
|
||||
// /s/<space>/p/<slug-id> route (same helper the wikilink node uses).
|
||||
// The previous hand-built `/<space>/page/<slugId>` path matched no
|
||||
// route, so clicking a reference did nothing.
|
||||
navigate(
|
||||
buildPageUrl(
|
||||
entry.source.spaceSlug ?? undefined,
|
||||
entry.source.slugId,
|
||||
entry.source.title ?? undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box py="md" px="sm" data-testid="backlinks-loading">
|
||||
<Loader size="xs" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={14} />}
|
||||
color="red"
|
||||
variant="light"
|
||||
py="xs"
|
||||
data-testid="backlinks-error"
|
||||
>
|
||||
<Group gap="xs">
|
||||
<Text size="xs">{t('backlinks.error', 'Could not load linked references.')}</Text>
|
||||
<UnstyledButton onClick={() => refetch()} style={{ textDecoration: 'underline' }}>
|
||||
<Text size="xs">{t('backlinks.retry', 'Retry')}</Text>
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.total === 0) {
|
||||
return (
|
||||
<Box py="md" px="sm" data-testid="backlinks-empty">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('backlinks.empty', 'No pages link here yet.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-testid="backlinks-panel">
|
||||
<Group gap="xs" px="sm" py="xs" align="center">
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t('backlinks.panel.title', 'Linked references')}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{data.total}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={groups.map((g) => g.key)}
|
||||
variant="default"
|
||||
radius="md"
|
||||
chevronSize={14}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<Accordion.Item key={group.key} value={group.key}>
|
||||
<Accordion.Control icon={group.icon} py={4} px="sm">
|
||||
<Text size="xs" fw={500}>
|
||||
{group.label}
|
||||
<Badge size="xs" variant="outline" color="gray" ml={6}>
|
||||
{group.entries.length}
|
||||
</Badge>
|
||||
</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel pb={4}>
|
||||
{group.entries.map((entry) => (
|
||||
<BacklinkRow
|
||||
key={`${entry.source.id}-${entry.linkType}`}
|
||||
entry={entry}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
))}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface BacklinkRowProps {
|
||||
entry: BacklinkEntry;
|
||||
onNavigate: (entry: BacklinkEntry) => void;
|
||||
}
|
||||
|
||||
function BacklinkRow({ entry, onNavigate }: BacklinkRowProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => onNavigate(entry)}
|
||||
className={classes.backlinkRow}
|
||||
data-testid={`backlink-row-${entry.source.id}`}
|
||||
px="sm"
|
||||
py={4}
|
||||
w="100%"
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
component="div"
|
||||
color="gray"
|
||||
size="sm"
|
||||
mt={1}
|
||||
aria-hidden="true"
|
||||
flex="0 0 auto"
|
||||
>
|
||||
{entry.source.icon ?? <IconFileDescription size={14} stroke={1.5} />}
|
||||
</ActionIcon>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{entry.source.title ?? t('backlinks.untitled', 'Untitled')}
|
||||
</Text>
|
||||
{entry.source.spaceName && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{entry.source.spaceName}
|
||||
</Text>
|
||||
)}
|
||||
{entry.contextExcerpt && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
lineClamp={2}
|
||||
mt={2}
|
||||
className={classes.excerpt}
|
||||
>
|
||||
{entry.contextExcerpt}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkedReferencesPanel;
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api-client';
|
||||
|
||||
/**
|
||||
* Mirrors the BacklinksResult shape from the backend (R3.2).
|
||||
*/
|
||||
export interface PageSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
slugId: string | null;
|
||||
icon: string | null;
|
||||
spaceSlug: string | null;
|
||||
spaceName: string | null;
|
||||
}
|
||||
|
||||
export interface BacklinkEntry {
|
||||
source: PageSummary;
|
||||
linkType: 'wikilink' | 'mention' | 'database_embed' | 'parent_child';
|
||||
contextExcerpt: string | null;
|
||||
}
|
||||
|
||||
export interface BacklinksResult {
|
||||
wikilinks: BacklinkEntry[];
|
||||
mentions: BacklinkEntry[];
|
||||
database_embeds: BacklinkEntry[];
|
||||
/** Direct sub-pages of the target page (page-tree hierarchy, Notion parity). */
|
||||
parentChild: BacklinkEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
async function fetchBacklinks(pageId: string): Promise<BacklinksResult> {
|
||||
const res = await api.get<BacklinksResult>(
|
||||
`/v1/pages/${pageId}/backlinks`,
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query hook that fetches backlinks for a given page.
|
||||
*
|
||||
* Cache policy:
|
||||
* - staleTime: 30s — backlinks are eventually consistent (indexed async).
|
||||
* - gcTime: 5 min — keep in memory while navigating between pages.
|
||||
*
|
||||
* The hook is a no-op when pageId is empty/undefined.
|
||||
*/
|
||||
export function useBacklinks(pageId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['acadenice', 'backlinks', pageId],
|
||||
queryFn: () => fetchBacklinks(pageId!),
|
||||
enabled: !!pageId,
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { clipperClient } from '../services/clipper-client';
|
||||
|
||||
vi.mock('axios');
|
||||
const mockedAxios = axios as unknown as {
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
post: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const sampleToken = {
|
||||
id: 'tk-1',
|
||||
userId: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
label: 'My token',
|
||||
lastUsedAt: null,
|
||||
createdAt: '2026-05-09T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
};
|
||||
|
||||
describe('clipperClient', () => {
|
||||
afterEach(() => vi.resetAllMocks());
|
||||
|
||||
describe('listTokens', () => {
|
||||
it('GETs /api/v1/clipper/tokens', async () => {
|
||||
mockedAxios.get = vi.fn().mockResolvedValue({ data: [sampleToken] });
|
||||
const result = await clipperClient.listTokens();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('tk-1');
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith('/api/v1/clipper/tokens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createToken', () => {
|
||||
it('POSTs and returns token + info', async () => {
|
||||
const response = { token: 'clip_abc123', tokenInfo: sampleToken };
|
||||
mockedAxios.post = vi.fn().mockResolvedValue({ data: response });
|
||||
|
||||
const result = await clipperClient.createToken({ label: 'My token', duration_days: 30 });
|
||||
|
||||
expect(result.token).toBe('clip_abc123');
|
||||
expect(result.tokenInfo.label).toBe('My token');
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/v1/clipper/tokens',
|
||||
{ label: 'My token', duration_days: 30 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeToken', () => {
|
||||
it('DELETEs the token by id', async () => {
|
||||
mockedAxios.delete = vi.fn().mockResolvedValue({});
|
||||
await clipperClient.revokeToken('tk-1');
|
||||
expect(mockedAxios.delete).toHaveBeenCalledWith('/api/v1/clipper/tokens/tk-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Button,
|
||||
Text,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
TextInput,
|
||||
Select,
|
||||
Alert,
|
||||
Code,
|
||||
CopyButton,
|
||||
Tooltip,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertTriangle,
|
||||
IconScissors,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import {
|
||||
useClipperTokens,
|
||||
useCreateClipperToken,
|
||||
useRevokeClipperToken,
|
||||
} from "../queries/clipper-query";
|
||||
import { CreateTokenPayload } from "../services/clipper-client";
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: "30", label: "30 days" },
|
||||
{ value: "90", label: "90 days" },
|
||||
{ value: "365", label: "1 year" },
|
||||
{ value: "null", label: "No expiry" },
|
||||
];
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function isExpired(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < new Date();
|
||||
}
|
||||
|
||||
export default function ClipperTokensPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: tokens = [], isLoading } = useClipperTokens();
|
||||
const createMutation = useCreateClipperToken();
|
||||
const revokeMutation = useRevokeClipperToken();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
const [duration, setDuration] = useState<string>("90");
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [tokenLabel, setTokenLabel] = useState<string>("");
|
||||
|
||||
function handleCreate() {
|
||||
if (!label.trim()) return;
|
||||
const payload: CreateTokenPayload = {
|
||||
label: label.trim(),
|
||||
duration_days: duration === "null" ? null : Number(duration),
|
||||
};
|
||||
createMutation.mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
setNewToken(data.token);
|
||||
setTokenLabel(label.trim());
|
||||
setLabel("");
|
||||
setDuration("90");
|
||||
setCreateOpen(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleRevoke(tokenId: string) {
|
||||
revokeMutation.mutate(tokenId);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Web Clipper tokens")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<SettingsTitle title={t("Web Clipper tokens")} />
|
||||
|
||||
<Stack gap="md">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(
|
||||
"Tokens allow the DocAdenice browser extension to clip web pages into your workspace. Each token is shown once — copy it before closing."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
{t("Generate token")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader size="sm" />
|
||||
) : tokens.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t("No tokens yet. Generate one to start clipping.")}
|
||||
</Text>
|
||||
) : (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Label")}</Table.Th>
|
||||
<Table.Th>{t("Last used")}</Table.Th>
|
||||
<Table.Th>{t("Created")}</Table.Th>
|
||||
<Table.Th>{t("Expires")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{tokens.map((tk) => (
|
||||
<Table.Tr key={tk.id}>
|
||||
<Table.Td>{tk.label ?? "—"}</Table.Td>
|
||||
<Table.Td>{formatDate(tk.lastUsedAt)}</Table.Td>
|
||||
<Table.Td>{formatDate(tk.createdAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
{tk.expiresAt ? formatDate(tk.expiresAt) : t("Never")}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isExpired(tk.expiresAt) ? (
|
||||
<Badge color="red">{t("Expired")}</Badge>
|
||||
) : (
|
||||
<Badge color="green">{t("Active")}</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label={t("Revoke token")} position="left">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={
|
||||
revokeMutation.isPending &&
|
||||
revokeMutation.variables === tk.id
|
||||
}
|
||||
onClick={() => handleRevoke(tk.id)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Create token modal */}
|
||||
<Modal
|
||||
opened={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
title={t("Generate Web Clipper token")}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t("Label")}
|
||||
placeholder={t("e.g. Chrome extension - home laptop")}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.currentTarget.value)}
|
||||
maxLength={100}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("Expiry")}
|
||||
data={DURATION_OPTIONS}
|
||||
value={duration}
|
||||
onChange={(v) => setDuration(v ?? "90")}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" onClick={() => setCreateOpen(false)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!label.trim()}
|
||||
>
|
||||
{t("Generate")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* One-time token reveal modal */}
|
||||
<Modal
|
||||
opened={!!newToken}
|
||||
onClose={() => setNewToken(null)}
|
||||
title={t("Your new token — copy it now")}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
color="yellow"
|
||||
title={t("This token will not be shown again")}
|
||||
>
|
||||
{t(
|
||||
"Copy this token and paste it into the extension settings. It cannot be recovered after you close this dialog."
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
{tokenLabel}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs" align="center">
|
||||
<Code
|
||||
block
|
||||
style={{ flex: 1, wordBreak: "break-all", fontSize: 13 }}
|
||||
>
|
||||
{newToken}
|
||||
</Code>
|
||||
<CopyButton value={newToken ?? ""} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? t("Copied") : t("Copy")}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
color={copied ? "teal" : "gray"}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={() => setNewToken(null)}>{t("Done")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { clipperClient, CreateTokenPayload } from '../services/clipper-client';
|
||||
|
||||
const TOKENS_KEY = ['clipper', 'tokens'];
|
||||
|
||||
export function useClipperTokens() {
|
||||
return useQuery({
|
||||
queryKey: TOKENS_KEY,
|
||||
queryFn: () => clipperClient.listTokens(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateClipperToken() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateTokenPayload) =>
|
||||
clipperClient.createToken(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: TOKENS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeClipperToken() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (tokenId: string) => clipperClient.revokeToken(tokenId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: TOKENS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import api from '@/lib/api-client';
|
||||
|
||||
const BASE = '/v1/clipper';
|
||||
|
||||
export interface ClipperTokenInfo {
|
||||
id: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
label: string | null;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreateTokenPayload {
|
||||
label: string;
|
||||
duration_days: number | null;
|
||||
}
|
||||
|
||||
export interface CreateTokenResponse {
|
||||
token: string;
|
||||
tokenInfo: ClipperTokenInfo;
|
||||
}
|
||||
|
||||
export const clipperClient = {
|
||||
async listTokens(): Promise<ClipperTokenInfo[]> {
|
||||
const r = await api.get<ClipperTokenInfo[]>(`${BASE}/tokens`);
|
||||
return r.data;
|
||||
},
|
||||
|
||||
async createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> {
|
||||
const r = await api.post<CreateTokenResponse>(`${BASE}/tokens`, payload);
|
||||
return r.data;
|
||||
},
|
||||
|
||||
async revokeToken(tokenId: string): Promise<void> {
|
||||
await api.delete(`${BASE}/tokens/${tokenId}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Unit tests for row-comments-client (R3.8 / R5.2).
|
||||
*
|
||||
* api-client is fully mocked — no network calls.
|
||||
* Routes updated to REST conventions in R5.2.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-client", () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
listRowComments,
|
||||
createRowComment,
|
||||
updateRowComment,
|
||||
resolveRowComment,
|
||||
deleteRowComment,
|
||||
countRowComments,
|
||||
} from "../services/row-comments-client";
|
||||
|
||||
const mockApi = api as unknown as {
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
post: ReturnType<typeof vi.fn>;
|
||||
patch: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const TABLE_ID = "table-1";
|
||||
const ROW_ID = "row-42";
|
||||
const COMMENT_ID = "c-00000000-0000-0000-0000-000000000000";
|
||||
|
||||
function makeComment() {
|
||||
return {
|
||||
id: COMMENT_ID,
|
||||
tableId: TABLE_ID,
|
||||
rowId: ROW_ID,
|
||||
content: { type: "doc", content: [] },
|
||||
authorUserId: "user-1",
|
||||
isResolved: false,
|
||||
createdAt: "2026-05-08T12:00:00Z",
|
||||
updatedAt: "2026-05-08T12:00:00Z",
|
||||
parentCommentId: null,
|
||||
workspaceId: "ws-1",
|
||||
resolvedAt: null,
|
||||
resolvedBy: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("row-comments-client", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("listRowComments GETs /v1/row-comments with query params", async () => {
|
||||
const comment = makeComment();
|
||||
mockApi.get.mockResolvedValueOnce({ data: [comment] });
|
||||
|
||||
const result = await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID });
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/v1/row-comments",
|
||||
{ params: { tableId: TABLE_ID, rowId: ROW_ID } },
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(COMMENT_ID);
|
||||
});
|
||||
|
||||
it("listRowComments forwards resolved filter as query param", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: [] });
|
||||
await listRowComments({ tableId: TABLE_ID, rowId: ROW_ID, resolved: true });
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/v1/row-comments",
|
||||
{ params: { tableId: TABLE_ID, rowId: ROW_ID, resolved: true } },
|
||||
);
|
||||
});
|
||||
|
||||
it("createRowComment POSTs to /v1/row-comments", async () => {
|
||||
const comment = makeComment();
|
||||
mockApi.post.mockResolvedValueOnce({ data: comment });
|
||||
|
||||
const params = {
|
||||
tableId: TABLE_ID,
|
||||
rowId: ROW_ID,
|
||||
content: JSON.stringify({ type: "doc" }),
|
||||
};
|
||||
const result = await createRowComment(params);
|
||||
|
||||
expect(mockApi.post).toHaveBeenCalledWith("/v1/row-comments", params);
|
||||
expect(result.id).toBe(COMMENT_ID);
|
||||
});
|
||||
|
||||
it("updateRowComment PATCHes /v1/row-comments/:id", async () => {
|
||||
const comment = makeComment();
|
||||
mockApi.patch.mockResolvedValueOnce({ data: comment });
|
||||
|
||||
await updateRowComment(COMMENT_ID, JSON.stringify({ type: "doc" }));
|
||||
|
||||
expect(mockApi.patch).toHaveBeenCalledWith(
|
||||
`/v1/row-comments/${COMMENT_ID}`,
|
||||
{ content: JSON.stringify({ type: "doc" }) },
|
||||
);
|
||||
});
|
||||
|
||||
it("resolveRowComment PATCHes /v1/row-comments/:id/resolve", async () => {
|
||||
const comment = { ...makeComment(), isResolved: true };
|
||||
mockApi.patch.mockResolvedValueOnce({ data: comment });
|
||||
|
||||
const result = await resolveRowComment(COMMENT_ID, true);
|
||||
|
||||
expect(mockApi.patch).toHaveBeenCalledWith(
|
||||
`/v1/row-comments/${COMMENT_ID}/resolve`,
|
||||
{ resolved: true },
|
||||
);
|
||||
expect(result.isResolved).toBe(true);
|
||||
});
|
||||
|
||||
it("deleteRowComment DELETEs /v1/row-comments/:id", async () => {
|
||||
mockApi.delete.mockResolvedValueOnce({});
|
||||
await deleteRowComment(COMMENT_ID);
|
||||
expect(mockApi.delete).toHaveBeenCalledWith(`/v1/row-comments/${COMMENT_ID}`);
|
||||
});
|
||||
|
||||
it("countRowComments GETs /v1/row-comments/count with query params", async () => {
|
||||
mockApi.get.mockResolvedValueOnce({ data: { count: 7 } });
|
||||
const count = await countRowComments(TABLE_ID, ROW_ID);
|
||||
expect(count).toBe(7);
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/v1/row-comments/count",
|
||||
{ params: { tableId: TABLE_ID, rowId: ROW_ID } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { RowCommentsPanel } from "../components/row-comments-panel";
|
||||
|
||||
/**
|
||||
* Render tests for RowCommentsPanel (R3.8).
|
||||
*
|
||||
* All hooks and translations are mocked — no network, no Jotai store.
|
||||
*/
|
||||
|
||||
vi.mock("../hooks/use-row-comments", () => ({
|
||||
useRowComments: vi.fn(),
|
||||
useCreateRowComment: vi.fn(),
|
||||
useResolveRowComment: vi.fn(),
|
||||
useDeleteRowComment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
useRowComments,
|
||||
useCreateRowComment,
|
||||
useResolveRowComment,
|
||||
useDeleteRowComment,
|
||||
} from "../hooks/use-row-comments";
|
||||
|
||||
function makeMutationResult() {
|
||||
return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false };
|
||||
}
|
||||
|
||||
const TABLE_ID = "table-1";
|
||||
const ROW_ID = "row-1";
|
||||
const USER_ID = "user-abc";
|
||||
|
||||
describe("RowCommentsPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCreateRowComment).mockReturnValue(makeMutationResult() as any);
|
||||
vi.mocked(useResolveRowComment).mockReturnValue(makeMutationResult() as any);
|
||||
vi.mocked(useDeleteRowComment).mockReturnValue(makeMutationResult() as any);
|
||||
});
|
||||
|
||||
it("shows empty state when no comments", () => {
|
||||
vi.mocked(useRowComments).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<MantineProvider>
|
||||
<RowCommentsPanel
|
||||
tableId={TABLE_ID}
|
||||
rowId={ROW_ID}
|
||||
currentUserId={USER_ID}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("acadenice.comments.empty")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows loading state", () => {
|
||||
vi.mocked(useRowComments).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<MantineProvider>
|
||||
<RowCommentsPanel
|
||||
tableId={TABLE_ID}
|
||||
rowId={ROW_ID}
|
||||
currentUserId={USER_ID}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// Mantine Loader renders a loading indicator; just verify panel mounts
|
||||
expect(screen.queryByText("acadenice.comments.empty")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders open/resolved tab buttons", () => {
|
||||
vi.mocked(useRowComments).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<MantineProvider>
|
||||
<RowCommentsPanel
|
||||
tableId={TABLE_ID}
|
||||
rowId={ROW_ID}
|
||||
currentUserId={USER_ID}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("acadenice.comments.open")).toBeDefined();
|
||||
expect(screen.getByText("acadenice.comments.resolved")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders compose textarea when panel is open", () => {
|
||||
vi.mocked(useRowComments).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<MantineProvider>
|
||||
<RowCommentsPanel
|
||||
tableId={TABLE_ID}
|
||||
rowId={ROW_ID}
|
||||
currentUserId={USER_ID}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("acadenice.comments.new_placeholder")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
Button,
|
||||
Textarea,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Loader,
|
||||
Paper,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useRowComments,
|
||||
useCreateRowComment,
|
||||
useResolveRowComment,
|
||||
useDeleteRowComment,
|
||||
} from "../hooks/use-row-comments";
|
||||
import type { RowComment } from "../services/row-comments-client";
|
||||
|
||||
interface RowCommentsPanelProps {
|
||||
tableId: string;
|
||||
rowId: string;
|
||||
/** Current user id — used to show own-comment controls */
|
||||
currentUserId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RowCommentsPanel — threaded comment panel for a Baserow row (R3.8).
|
||||
*
|
||||
* Shows unresolved threads by default; a "Resolved" tab toggles to archived.
|
||||
* Compose area creates root comments; "Reply" button creates a child.
|
||||
*
|
||||
* Architecture note: content is stored as Tiptap JSON on the server but
|
||||
* displayed as plain text here. A future enhancement could plug in a
|
||||
* read-only Tiptap renderer — out of scope for R3.8 (Notion-like phase 1).
|
||||
*/
|
||||
export function RowCommentsPanel({
|
||||
tableId,
|
||||
rowId,
|
||||
currentUserId,
|
||||
}: RowCommentsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showResolved, setShowResolved] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [replyTo, setReplyTo] = useState<string | null>(null);
|
||||
|
||||
const { data: comments = [], isLoading } = useRowComments(
|
||||
tableId,
|
||||
rowId,
|
||||
showResolved ? true : false,
|
||||
);
|
||||
|
||||
const createMutation = useCreateRowComment();
|
||||
const resolveMutation = useResolveRowComment();
|
||||
const deleteMutation = useDeleteRowComment();
|
||||
|
||||
function tiptapDocFromText(text: string) {
|
||||
return JSON.stringify({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||
});
|
||||
}
|
||||
|
||||
function extractText(content: Record<string, unknown> | string): string {
|
||||
if (!content) return "";
|
||||
const doc =
|
||||
typeof content === "string" ? JSON.parse(content) : content;
|
||||
if (!doc?.content) return "";
|
||||
const texts: string[] = [];
|
||||
function walk(node: Record<string, unknown>) {
|
||||
if (node.type === "text" && typeof node.text === "string") {
|
||||
texts.push(node.text);
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
(node.content as Record<string, unknown>[]).forEach(walk);
|
||||
}
|
||||
}
|
||||
(doc.content as Record<string, unknown>[]).forEach(walk);
|
||||
return texts.join(" ");
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
|
||||
await createMutation.mutateAsync({
|
||||
tableId,
|
||||
rowId,
|
||||
content: tiptapDocFromText(text),
|
||||
parentCommentId: replyTo ?? undefined,
|
||||
});
|
||||
|
||||
setDraft("");
|
||||
setReplyTo(null);
|
||||
}
|
||||
|
||||
const rootComments = comments.filter((c) => c.parentCommentId === null);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{/* Tab toggle */}
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={!showResolved ? "filled" : "subtle"}
|
||||
onClick={() => setShowResolved(false)}
|
||||
>
|
||||
{t("acadenice.comments.open")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={showResolved ? "filled" : "subtle"}
|
||||
onClick={() => setShowResolved(true)}
|
||||
>
|
||||
{t("acadenice.comments.resolved")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading && <Loader size="sm" />}
|
||||
|
||||
{!isLoading && rootComments.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("acadenice.comments.empty")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Thread list */}
|
||||
{rootComments.map((root) => {
|
||||
const replies = comments.filter((c) => c.parentCommentId === root.id);
|
||||
return (
|
||||
<Paper key={root.id} p="xs" withBorder radius="sm">
|
||||
<CommentItem
|
||||
comment={root}
|
||||
currentUserId={currentUserId}
|
||||
tableId={tableId}
|
||||
rowId={rowId}
|
||||
extractText={extractText}
|
||||
onReply={() => setReplyTo(root.id)}
|
||||
onResolve={(resolved) =>
|
||||
resolveMutation.mutate({
|
||||
commentId: root.id,
|
||||
resolved,
|
||||
tableId,
|
||||
rowId,
|
||||
})
|
||||
}
|
||||
onDelete={() =>
|
||||
deleteMutation.mutate({ commentId: root.id, tableId, rowId })
|
||||
}
|
||||
/>
|
||||
|
||||
{replies.map((reply) => (
|
||||
<Paper key={reply.id} ml="md" mt="xs" p="xs" withBorder radius="sm">
|
||||
<CommentItem
|
||||
comment={reply}
|
||||
currentUserId={currentUserId}
|
||||
tableId={tableId}
|
||||
rowId={rowId}
|
||||
extractText={extractText}
|
||||
onDelete={() =>
|
||||
deleteMutation.mutate({
|
||||
commentId: reply.id,
|
||||
tableId,
|
||||
rowId,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{replyTo === root.id && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Textarea
|
||||
placeholder={t("acadenice.comments.reply_placeholder")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
autosize
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
loading={createMutation.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t("acadenice.comments.send_reply")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setReplyTo(null);
|
||||
setDraft("");
|
||||
}}
|
||||
>
|
||||
{t("acadenice.comments.cancel")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Compose area — new root comment */}
|
||||
{!replyTo && !showResolved && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Textarea
|
||||
placeholder={t("acadenice.comments.new_placeholder")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
autosize
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
loading={createMutation.isPending}
|
||||
onClick={handleSubmit}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
{t("acadenice.comments.send")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: single comment display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: RowComment;
|
||||
currentUserId: string;
|
||||
tableId: string;
|
||||
rowId: string;
|
||||
extractText: (content: Record<string, unknown> | string) => string;
|
||||
onReply?: () => void;
|
||||
onResolve?: (resolved: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function CommentItem({
|
||||
comment,
|
||||
currentUserId,
|
||||
extractText,
|
||||
onReply,
|
||||
onResolve,
|
||||
onDelete,
|
||||
}: CommentItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const isOwn = comment.authorUserId === currentUserId;
|
||||
const text = extractText(comment.content as Record<string, unknown>);
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Text size="xs" fw={600}>
|
||||
{comment.author?.name ?? t("acadenice.comments.unknown_user")}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{comment.isResolved && (
|
||||
<Badge size="xs" color="green" variant="light">
|
||||
{t("acadenice.comments.resolved_badge")}
|
||||
</Badge>
|
||||
)}
|
||||
{onResolve && !comment.isResolved && (
|
||||
<Tooltip label={t("acadenice.comments.resolve_action")}>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="green"
|
||||
onClick={() => onResolve(true)}
|
||||
aria-label={t("acadenice.comments.resolve_action")}
|
||||
>
|
||||
✓
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onResolve && comment.isResolved && (
|
||||
<Tooltip label={t("acadenice.comments.reopen_action")}>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => onResolve(false)}
|
||||
aria-label={t("acadenice.comments.reopen_action")}
|
||||
>
|
||||
↻
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isOwn && onDelete && (
|
||||
<Tooltip label={t("acadenice.comments.delete_action")}>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={onDelete}
|
||||
aria-label={t("acadenice.comments.delete_action")}
|
||||
>
|
||||
✕
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Text size="sm">{text}</Text>
|
||||
|
||||
{onReply && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={onReply}
|
||||
mt={2}
|
||||
>
|
||||
{t("acadenice.comments.reply")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
listRowComments,
|
||||
createRowComment,
|
||||
updateRowComment,
|
||||
resolveRowComment,
|
||||
deleteRowComment,
|
||||
countRowComments,
|
||||
type RowComment,
|
||||
type CreateRowCommentParams,
|
||||
} from "../services/row-comments-client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query key factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ROW_COMMENTS_KEY = (tableId: string, rowId: string) =>
|
||||
["acadenice", "row-comments", tableId, rowId] as const;
|
||||
|
||||
export const ROW_COMMENT_COUNT_KEY = (tableId: string, rowId: string) =>
|
||||
["acadenice", "row-comment-count", tableId, rowId] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List hook (polls every 30s — consistent with R3.7 notification poll)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useRowComments(
|
||||
tableId: string,
|
||||
rowId: string,
|
||||
resolved?: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...ROW_COMMENTS_KEY(tableId, rowId), resolved],
|
||||
queryFn: () => listRowComments({ tableId, rowId, resolved }),
|
||||
refetchInterval: 30_000,
|
||||
enabled: Boolean(tableId) && Boolean(rowId),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Count hook (used for the badge on each row)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useRowCommentCount(tableId: string, rowId: string) {
|
||||
return useQuery({
|
||||
queryKey: ROW_COMMENT_COUNT_KEY(tableId, rowId),
|
||||
queryFn: () => countRowComments(tableId, rowId),
|
||||
refetchInterval: 30_000,
|
||||
enabled: Boolean(tableId) && Boolean(rowId),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create mutation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useCreateRowComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: CreateRowCommentParams) => createRowComment(params),
|
||||
onSuccess: (comment) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROW_COMMENTS_KEY(comment.tableId, comment.rowId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROW_COMMENT_COUNT_KEY(comment.tableId, comment.rowId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolve mutation (optimistic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useResolveRowComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
commentId,
|
||||
resolved,
|
||||
}: {
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
tableId: string;
|
||||
rowId: string;
|
||||
}) => resolveRowComment(commentId, resolved),
|
||||
|
||||
onMutate: async (variables) => {
|
||||
const key = ROW_COMMENTS_KEY(variables.tableId, variables.rowId);
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<RowComment[]>(key);
|
||||
|
||||
if (previous) {
|
||||
queryClient.setQueryData<RowComment[]>(
|
||||
key,
|
||||
previous.map((c) =>
|
||||
c.id === variables.commentId
|
||||
? { ...c, isResolved: variables.resolved }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { previous, key };
|
||||
},
|
||||
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.previous) {
|
||||
queryClient.setQueryData(ctx.key, ctx.previous);
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROW_COMMENTS_KEY(variables.tableId, variables.rowId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete mutation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useDeleteRowComment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
commentId,
|
||||
}: {
|
||||
commentId: string;
|
||||
tableId: string;
|
||||
rowId: string;
|
||||
}) => deleteRowComment(commentId),
|
||||
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROW_COMMENTS_KEY(variables.tableId, variables.rowId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ROW_COMMENT_COUNT_KEY(variables.tableId, variables.rowId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import api from "@/lib/api-client";
|
||||
|
||||
export interface RowComment {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
tableId: string;
|
||||
rowId: string;
|
||||
parentCommentId: string | null;
|
||||
content: Record<string, unknown>;
|
||||
authorUserId: string;
|
||||
isResolved: boolean;
|
||||
resolvedAt: string | null;
|
||||
resolvedBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author?: { id: string; name: string; avatarUrl: string | null };
|
||||
resolver?: { id: string; name: string; avatarUrl: string | null };
|
||||
}
|
||||
|
||||
export interface ListRowCommentsParams {
|
||||
tableId: string;
|
||||
rowId: string;
|
||||
resolved?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateRowCommentParams {
|
||||
tableId: string;
|
||||
rowId: string;
|
||||
content: string;
|
||||
parentCommentId?: string;
|
||||
}
|
||||
|
||||
export async function listRowComments(
|
||||
params: ListRowCommentsParams,
|
||||
): Promise<RowComment[]> {
|
||||
const res = await api.get<RowComment[]>("/v1/row-comments", { params });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createRowComment(
|
||||
params: CreateRowCommentParams,
|
||||
): Promise<RowComment> {
|
||||
const res = await api.post<RowComment>("/v1/row-comments", params);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateRowComment(
|
||||
commentId: string,
|
||||
content: string,
|
||||
): Promise<RowComment> {
|
||||
const res = await api.patch<RowComment>(`/v1/row-comments/${commentId}`, {
|
||||
content,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function resolveRowComment(
|
||||
commentId: string,
|
||||
resolved: boolean,
|
||||
): Promise<RowComment> {
|
||||
const res = await api.patch<RowComment>(
|
||||
`/v1/row-comments/${commentId}/resolve`,
|
||||
{ resolved },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteRowComment(commentId: string): Promise<void> {
|
||||
await api.delete(`/v1/row-comments/${commentId}`);
|
||||
}
|
||||
|
||||
export async function countRowComments(
|
||||
tableId: string,
|
||||
rowId: string,
|
||||
): Promise<number> {
|
||||
const res = await api.get<{ count: number }>("/v1/row-comments/count", {
|
||||
params: { tableId, rowId },
|
||||
});
|
||||
return res.data.count;
|
||||
}
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
/**
|
||||
* Tests for CalendarRenderer.
|
||||
*
|
||||
* Note on FullCalendar in JSDOM:
|
||||
* FullCalendar renders a full calendar grid with complex DOM and ResizeObserver
|
||||
* usage. Running it faithfully in JSDOM is unreliable and slow. We mock the
|
||||
* FullCalendar component itself to render a stub that exposes events and
|
||||
* interaction handlers via data attributes + callbacks. This is the standard
|
||||
* pattern for testing FullCalendar wrappers in Jest/Vitest.
|
||||
*
|
||||
* Covers:
|
||||
* - loading skeleton when isLoading
|
||||
* - error alert on error
|
||||
* - no_date_field alert when no date field exists
|
||||
* - events passed to FullCalendar stub from rows
|
||||
* - event click -> opens RowDetailModal
|
||||
* - event drop -> calls useUpdateRow.mutate with new date
|
||||
* - event drop with canWriteRows=false -> calls arg.revert()
|
||||
* - rows without a date value are excluded from events
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { CalendarRenderer } from "../renderers/calendar-renderer";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-view-data", () => ({
|
||||
VIEW_DATA_QUERY_KEY: "view-data",
|
||||
useViewData: vi.fn(),
|
||||
}));
|
||||
|
||||
const mutateMock = vi.fn();
|
||||
vi.mock("../hooks/use-update-row", () => ({
|
||||
useUpdateRow: vi.fn(() => ({ mutate: mutateMock })),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-database-realtime-updates", () => ({
|
||||
useDatabaseRealtimeUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-permissions", () => ({
|
||||
usePermissions: vi.fn(() => ({ canWriteRows: true, isAdmin: false, isResolved: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/hooks", () => ({
|
||||
useDisclosure: vi.fn(() => [false, { open: vi.fn(), close: vi.fn() }]),
|
||||
}));
|
||||
|
||||
// Stub FullCalendar to a simple div that exposes event handlers via test hooks.
|
||||
let capturedProps: Record<string, unknown> = {};
|
||||
vi.mock("@fullcalendar/react", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
capturedProps = props;
|
||||
const events = (props.events as { id: string; title: string; start: string }[]) ?? [];
|
||||
return (
|
||||
<div data-testid="fullcalendar-stub">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} data-testid={`event-${e.id}`} data-start={e.start}>
|
||||
{e.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@fullcalendar/daygrid", () => ({ default: {} }));
|
||||
vi.mock("@fullcalendar/timegrid", () => ({ default: {} }));
|
||||
vi.mock("@fullcalendar/interaction", () => ({ default: {} }));
|
||||
|
||||
import { useViewData } from "../hooks/use-view-data";
|
||||
import { usePermissions } from "../hooks/use-permissions";
|
||||
|
||||
const mockUseViewData = useViewData as ReturnType<typeof vi.fn>;
|
||||
const mockUsePermissions = usePermissions as ReturnType<typeof vi.fn>;
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const FIELDS = [
|
||||
{ id: "f1", name: "Title", type: "text", primary: true, options: null },
|
||||
{ id: "f2", name: "Due", type: "date", options: null },
|
||||
];
|
||||
|
||||
const ROWS = [
|
||||
{ id: "r1", tableId: "t1", fields: { Title: "Event A", Due: "2026-06-01T10:00:00Z" } },
|
||||
{ id: "r2", tableId: "t1", fields: { Title: "Event B", Due: "2026-06-15T14:00:00Z" } },
|
||||
{ id: "r3", tableId: "t1", fields: { Title: "No Date" } }, // no date — should be excluded
|
||||
];
|
||||
|
||||
describe("CalendarRenderer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedProps = {};
|
||||
mockUsePermissions.mockReturnValue({ canWriteRows: true, isAdmin: false, isResolved: true });
|
||||
});
|
||||
|
||||
it("shows skeleton when loading", () => {
|
||||
mockUseViewData.mockReturnValue({ isLoading: true, isError: false, data: null, error: null, refetch: vi.fn() });
|
||||
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.queryByTestId("fullcalendar-stub")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alert on error", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
data: null,
|
||||
error: { response: { status: 500 } },
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.getByText("database_view.error.generic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no_date_field alert when no date field exists", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
rows: [],
|
||||
fields: [{ id: "f1", name: "Title", type: "text", primary: true }],
|
||||
total: 0,
|
||||
hasNextPage: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.getByText("database_view.calendar.no_date_field")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders calendar stub when data is available", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.getByTestId("fullcalendar-stub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes only rows with a date value as events (excludes r3 with no date)", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.getByTestId("event-r1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("event-r2")).toBeInTheDocument();
|
||||
// r3 has no date — must be excluded.
|
||||
expect(screen.queryByTestId("event-r3")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("event titles come from primary field", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: [ROWS[0]], fields: FIELDS, total: 1, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.getByText("Event A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("eventDrop calls mutate with the new ISO date when canWriteRows is true", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
|
||||
// Simulate eventDrop by calling the captured handler directly.
|
||||
const newDate = new Date("2026-06-20T10:00:00Z");
|
||||
const revert = vi.fn();
|
||||
const dropArg = {
|
||||
event: { id: "r1", start: newDate },
|
||||
revert,
|
||||
};
|
||||
|
||||
const eventDropFn = capturedProps.eventDrop as (arg: typeof dropArg) => void;
|
||||
expect(typeof eventDropFn).toBe("function");
|
||||
eventDropFn(dropArg);
|
||||
|
||||
expect(mutateMock).toHaveBeenCalledWith(
|
||||
{
|
||||
rowId: "r1",
|
||||
payload: { fields: { Due: newDate.toISOString() } },
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("eventDrop calls revert when canWriteRows is false", () => {
|
||||
mockUsePermissions.mockReturnValue({ canWriteRows: false, isAdmin: false, isResolved: true });
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><CalendarRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
|
||||
const revert = vi.fn();
|
||||
const eventDropFn = capturedProps.eventDrop as (arg: {
|
||||
event: { id: string; start: Date };
|
||||
revert: () => void;
|
||||
}) => void;
|
||||
eventDropFn({ event: { id: "r1", start: new Date() }, revert });
|
||||
|
||||
expect(revert).toHaveBeenCalled();
|
||||
expect(mutateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
/**
|
||||
* Tests for DatabaseViewComponent (NodeViewWrapper).
|
||||
*
|
||||
* Covers (updated R3.1.d):
|
||||
* - renders TableRenderer when viewType is "grid" or "table"
|
||||
* - renders KanbanRenderer when viewType is "kanban"
|
||||
* - renders CalendarRenderer when viewType is "calendar"
|
||||
* - renders PlaceholderRenderer for unknown viewTypes
|
||||
* - passes tableId/viewId/bridgeUrl to each renderer
|
||||
* - shows "selected" class when the node is selected in ProseMirror
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { DatabaseViewComponent } from "../extension/database-view-component";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
|
||||
// Mock react-i18next to return the key as the translation.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
if (opts) {
|
||||
return `${key}:${JSON.stringify(opts)}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock TableRenderer to avoid actual React Query fetches.
|
||||
vi.mock("../renderers/table-renderer", () => ({
|
||||
TableRenderer: ({
|
||||
tableId,
|
||||
viewId,
|
||||
}: {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
}) => (
|
||||
<div data-testid="table-renderer">
|
||||
table:{tableId}:{viewId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock KanbanRenderer (R3.1.d).
|
||||
vi.mock("../renderers/kanban-renderer", () => ({
|
||||
KanbanRenderer: ({
|
||||
tableId,
|
||||
viewId,
|
||||
}: {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
}) => (
|
||||
<div data-testid="kanban-renderer">
|
||||
kanban:{tableId}:{viewId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock CalendarRenderer (R3.1.d).
|
||||
vi.mock("../renderers/calendar-renderer", () => ({
|
||||
CalendarRenderer: ({
|
||||
tableId,
|
||||
viewId,
|
||||
}: {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
}) => (
|
||||
<div data-testid="calendar-renderer">
|
||||
calendar:{tableId}:{viewId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock PlaceholderRenderer.
|
||||
vi.mock("../renderers/placeholder-renderer", () => ({
|
||||
PlaceholderRenderer: ({ viewType }: { viewType: string }) => (
|
||||
<div data-testid="placeholder-renderer">placeholder:{viewType}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function makeNodeViewProps(
|
||||
attrs: Record<string, unknown>,
|
||||
selected = false,
|
||||
): NodeViewProps {
|
||||
return {
|
||||
node: { attrs } as NodeViewProps["node"],
|
||||
selected,
|
||||
editor: {} as NodeViewProps["editor"],
|
||||
extension: {} as NodeViewProps["extension"],
|
||||
view: {} as NodeViewProps["view"],
|
||||
HTMLAttributes: {},
|
||||
getPos: () => 0,
|
||||
decorations: [],
|
||||
innerDecorations: {} as NodeViewProps["innerDecorations"],
|
||||
updateAttributes: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DatabaseViewComponent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders TableRenderer for viewType grid", () => {
|
||||
const props = makeNodeViewProps({
|
||||
tableId: "t1",
|
||||
viewId: "v1",
|
||||
viewType: "grid",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("table-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-renderer")).toHaveTextContent("table:t1:v1");
|
||||
});
|
||||
|
||||
it("renders TableRenderer for viewType table", () => {
|
||||
const props = makeNodeViewProps({
|
||||
tableId: "t2",
|
||||
viewId: "v2",
|
||||
viewType: "table",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("table-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders KanbanRenderer for viewType kanban (R3.1.d)", () => {
|
||||
const props = makeNodeViewProps({
|
||||
tableId: "t3",
|
||||
viewId: "v3",
|
||||
viewType: "kanban",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("kanban-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("kanban-renderer")).toHaveTextContent("kanban:t3:v3");
|
||||
});
|
||||
|
||||
it("renders CalendarRenderer for viewType calendar (R3.1.d)", () => {
|
||||
const props = makeNodeViewProps({
|
||||
tableId: "t4",
|
||||
viewId: "v4",
|
||||
viewType: "calendar",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("calendar-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("calendar-renderer")).toHaveTextContent("calendar:t4:v4");
|
||||
});
|
||||
|
||||
it("renders PlaceholderRenderer for unknown viewType", () => {
|
||||
const props = makeNodeViewProps({
|
||||
tableId: "t5",
|
||||
viewId: "v5",
|
||||
viewType: "gallery",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("placeholder-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("placeholder-renderer")).toHaveTextContent(
|
||||
"placeholder:gallery",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the node header label", () => {
|
||||
const props = makeNodeViewProps({
|
||||
tableId: "t1",
|
||||
viewId: "v1",
|
||||
viewType: "grid",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<DatabaseViewComponent {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
// The header label is the i18n key (mocked to return the key).
|
||||
expect(
|
||||
screen.getByText("database_view.node.header_label"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
/**
|
||||
* Tests for the DatabaseViewExtension Tiptap node.
|
||||
*
|
||||
* Covers:
|
||||
* - ProseMirror schema registration
|
||||
* - Attrs default values
|
||||
* - parseHTML / renderHTML round-trip
|
||||
* - insertDatabaseView command
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import DatabaseViewExtension from "../extension/database-view-extension";
|
||||
|
||||
function buildEditor() {
|
||||
return new Editor({
|
||||
extensions: [StarterKit, DatabaseViewExtension],
|
||||
content: "<p></p>",
|
||||
// Headless mode — no DOM node needed for schema/command tests.
|
||||
element: document.createElement("div"),
|
||||
});
|
||||
}
|
||||
|
||||
describe("DatabaseViewExtension schema", () => {
|
||||
it("registers the database-view node type in the schema", () => {
|
||||
const editor = buildEditor();
|
||||
expect(editor.schema.nodes["database-view"]).toBeDefined();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("has the correct default attrs", () => {
|
||||
const editor = buildEditor();
|
||||
const nodeType = editor.schema.nodes["database-view"];
|
||||
const node = nodeType.create();
|
||||
expect(node.attrs.tableId).toBe("");
|
||||
expect(node.attrs.viewId).toBe("");
|
||||
expect(node.attrs.viewType).toBe("grid");
|
||||
expect(node.attrs.bridgeUrl).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("accepts non-default attrs", () => {
|
||||
const editor = buildEditor();
|
||||
const nodeType = editor.schema.nodes["database-view"];
|
||||
const node = nodeType.create({
|
||||
tableId: "42",
|
||||
viewId: "7",
|
||||
viewType: "table",
|
||||
bridgeUrl: "http://localhost:4000",
|
||||
});
|
||||
expect(node.attrs.tableId).toBe("42");
|
||||
expect(node.attrs.viewId).toBe("7");
|
||||
expect(node.attrs.viewType).toBe("table");
|
||||
expect(node.attrs.bridgeUrl).toBe("http://localhost:4000");
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DatabaseViewExtension renderHTML / parseHTML round-trip", () => {
|
||||
it("renders data-* attributes and parses them back", () => {
|
||||
const editor = buildEditor();
|
||||
|
||||
// Insert a node with known attrs and serialise to HTML — this exercises
|
||||
// the full Tiptap renderHTML pipeline (attribute-level renderHTML callbacks
|
||||
// are merged by Tiptap and then passed to the extension's renderHTML).
|
||||
editor.commands.insertDatabaseView({
|
||||
tableId: "tbl-1",
|
||||
viewId: "view-1",
|
||||
viewType: "grid",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
|
||||
const html = editor.getHTML();
|
||||
|
||||
expect(html).toContain('data-node-type="database-view"');
|
||||
expect(html).toContain('data-table-id="tbl-1"');
|
||||
expect(html).toContain('data-view-id="view-1"');
|
||||
expect(html).toContain('data-view-type="grid"');
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("parseHTML rule matches div[data-node-type=database-view]", () => {
|
||||
const editor = buildEditor();
|
||||
|
||||
// Inject raw HTML containing a database-view node.
|
||||
const html =
|
||||
'<div data-node-type="database-view" data-table-id="42" data-view-id="7" data-view-type="table"></div>';
|
||||
|
||||
editor.commands.setContent(html);
|
||||
|
||||
const { doc } = editor.state;
|
||||
let found = false;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === "database-view") {
|
||||
found = true;
|
||||
expect(node.attrs.tableId).toBe("42");
|
||||
expect(node.attrs.viewId).toBe("7");
|
||||
expect(node.attrs.viewType).toBe("table");
|
||||
}
|
||||
});
|
||||
|
||||
expect(found).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DatabaseViewExtension insertDatabaseView command", () => {
|
||||
it("inserts a database-view node with the given attrs", () => {
|
||||
const editor = buildEditor();
|
||||
|
||||
editor.commands.insertDatabaseView({
|
||||
tableId: "t1",
|
||||
viewId: "v1",
|
||||
viewType: "grid",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
|
||||
const { doc } = editor.state;
|
||||
let inserted = false;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === "database-view") {
|
||||
inserted = true;
|
||||
expect(node.attrs.tableId).toBe("t1");
|
||||
expect(node.attrs.viewId).toBe("v1");
|
||||
expect(node.attrs.viewType).toBe("grid");
|
||||
}
|
||||
});
|
||||
|
||||
expect(inserted).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
/**
|
||||
* Tests for InlineEditor.
|
||||
*
|
||||
* Covers:
|
||||
* - double-click -> editor appears (text field)
|
||||
* - save on Enter -> calls onSave with the typed value
|
||||
* - cancel on Escape -> calls onCancel, no onSave
|
||||
* - save on blur -> calls onSave
|
||||
* - permission denied -> read-only span shown with tooltip, no editor
|
||||
* - number field -> NumberInput rendered
|
||||
* - select field -> Select rendered with options
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { InlineEditor } from "../components/inline-editor";
|
||||
import type { BridgeField } from "../types/database-view.types";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <MantineProvider>{children}</MantineProvider>;
|
||||
}
|
||||
|
||||
const textField: BridgeField = {
|
||||
id: "f1",
|
||||
name: "Name",
|
||||
type: "text",
|
||||
primary: true,
|
||||
options: null,
|
||||
};
|
||||
|
||||
const numberField: BridgeField = {
|
||||
id: "f2",
|
||||
name: "Score",
|
||||
type: "number",
|
||||
options: null,
|
||||
};
|
||||
|
||||
const selectField: BridgeField = {
|
||||
id: "f3",
|
||||
name: "Status",
|
||||
type: "single_select",
|
||||
options: {
|
||||
select_options: [
|
||||
{ id: 1, value: "Active" },
|
||||
{ id: 2, value: "Inactive" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe("InlineEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders read-only span when canWrite is false", () => {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<InlineEditor
|
||||
field={textField}
|
||||
initialValue="Alice"
|
||||
canWrite={false}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
// No input rendered.
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders text input when canWrite is true", () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<InlineEditor
|
||||
field={textField}
|
||||
initialValue="Alice"
|
||||
canWrite={true}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSave with updated value on Enter key", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<InlineEditor
|
||||
field={textField}
|
||||
initialValue="Alice"
|
||||
canWrite={true}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Bob{Enter}");
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("Bob");
|
||||
});
|
||||
|
||||
it("calls onCancel on Escape key", async () => {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<InlineEditor
|
||||
field={textField}
|
||||
initialValue="Alice"
|
||||
canWrite={true}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "Bob{Escape}");
|
||||
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSave on blur", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<InlineEditor
|
||||
field={textField}
|
||||
initialValue="Alice"
|
||||
canWrite={true}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Charlie");
|
||||
await user.tab(); // trigger blur
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("Charlie");
|
||||
});
|
||||
|
||||
it("renders a spinbutton (number input) for number field type", () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<InlineEditor
|
||||
field={numberField}
|
||||
initialValue={42}
|
||||
canWrite={true}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Mantine NumberInput uses role="spinbutton" or textbox depending on version.
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a combobox (Select) for single_select field type", async () => {
|
||||
const { container } = render(
|
||||
<Wrapper>
|
||||
<InlineEditor
|
||||
field={selectField}
|
||||
initialValue={{ id: 1, value: "Active" }}
|
||||
canWrite={true}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Mantine v8 Select renders a combobox input (role="combobox") or falls
|
||||
// back to a plain textbox. Accept either — key requirement is an input exists.
|
||||
await waitFor(() => {
|
||||
const input =
|
||||
container.querySelector('[role="combobox"]') ??
|
||||
container.querySelector('input[type="search"]') ??
|
||||
container.querySelector('input');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
/**
|
||||
* Tests for InsertDatabaseModal.
|
||||
*
|
||||
* Covers:
|
||||
* - renders step 1 (table list) on open
|
||||
* - shows loading state
|
||||
* - shows error state with retry
|
||||
* - selecting a table moves to step 2
|
||||
* - selecting a view enables the Insert button
|
||||
* - Insert button calls editor.commands.insertDatabaseView with correct attrs
|
||||
* - Back button returns to step 1
|
||||
* - empty table list shows empty state message
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { InsertDatabaseModal } from "../slash-command/insert-database-modal";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-tables", () => ({
|
||||
useTables: vi.fn(),
|
||||
TABLES_QUERY_KEY: ["bridge-tables"],
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-views", () => ({
|
||||
useViews: vi.fn(),
|
||||
viewsQueryKey: vi.fn(() => ["views"]),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-workspaces", () => ({
|
||||
useWorkspaces: vi.fn(),
|
||||
WORKSPACES_QUERY_KEY: ["bridge-admin-workspaces"],
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-databases", () => ({
|
||||
useDatabases: vi.fn(),
|
||||
DATABASES_QUERY_KEY: ["bridge-admin-databases"],
|
||||
}));
|
||||
|
||||
import { useTables } from "../hooks/use-tables";
|
||||
import { useViews } from "../hooks/use-views";
|
||||
import { useWorkspaces } from "../hooks/use-workspaces";
|
||||
import { useDatabases } from "../hooks/use-databases";
|
||||
|
||||
const mockUseTables = useTables as ReturnType<typeof vi.fn>;
|
||||
const mockUseViews = useViews as ReturnType<typeof vi.fn>;
|
||||
const mockUseWorkspaces = useWorkspaces as ReturnType<typeof vi.fn>;
|
||||
const mockUseDatabases = useDatabases as ReturnType<typeof vi.fn>;
|
||||
|
||||
const WORKSPACES = [{ id: 1, name: "WS" }];
|
||||
const DATABASES = [
|
||||
{ id: 10, name: "MyDB", workspace: { id: 1, name: "WS" }, tables: [] },
|
||||
];
|
||||
|
||||
const TABLES = [
|
||||
{ id: "t1", name: "Contacts" },
|
||||
{ id: "t2", name: "Projects" },
|
||||
];
|
||||
|
||||
const VIEWS = [
|
||||
{ id: "v1", name: "Grid view", type: "grid", tableId: "t1" },
|
||||
{ id: "v2", name: "Form view", type: "form", tableId: "t1" },
|
||||
];
|
||||
|
||||
function makeEditor() {
|
||||
return {
|
||||
commands: {
|
||||
insertDatabaseView: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("InsertDatabaseModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseTables.mockReturnValue({
|
||||
data: TABLES,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
mockUseViews.mockReturnValue({
|
||||
data: VIEWS,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
mockUseWorkspaces.mockReturnValue({
|
||||
data: WORKSPACES,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
mockUseDatabases.mockReturnValue({
|
||||
data: DATABASES,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
// Step 0 (source) auto-resolves workspace+database via useEffect when
|
||||
// there is a single one of each, then the user clicks "next" to land on
|
||||
// step 1 (table). Wait for the button to become enabled before clicking.
|
||||
async function advanceToTableStep() {
|
||||
await waitFor(() => {
|
||||
const next = screen.getByTestId("source-next-btn") as HTMLButtonElement;
|
||||
expect(next.disabled).toBe(false);
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("source-next-btn"));
|
||||
}
|
||||
|
||||
it("renders step 1 with table list when opened", async () => {
|
||||
const editor = makeEditor();
|
||||
render(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByText("database_view.modal.title")).toBeInTheDocument();
|
||||
await advanceToTableStep();
|
||||
expect(screen.getByText("Contacts")).toBeInTheDocument();
|
||||
expect(screen.getByText("Projects")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading state in step 1", async () => {
|
||||
mockUseTables.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
const editor = makeEditor();
|
||||
render(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
// No table items visible during loading.
|
||||
expect(screen.queryByText("Contacts")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alert and retry button on tables load failure", async () => {
|
||||
const refetch = vi.fn();
|
||||
mockUseTables.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
refetch,
|
||||
});
|
||||
const editor = makeEditor();
|
||||
render(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
expect(
|
||||
screen.getByText("database_view.error.tables_load"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("database_view.error.retry"));
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows empty state when no tables", async () => {
|
||||
mockUseTables.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
const editor = makeEditor();
|
||||
render(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
expect(
|
||||
screen.getByText("database_view.modal.no_tables"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves to step 2 when a table is selected", async () => {
|
||||
const editor = makeEditor();
|
||||
render(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
fireEvent.click(screen.getByText("Contacts"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Grid view")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("back button returns to step 1 from step 2", async () => {
|
||||
const editor = makeEditor();
|
||||
render(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={vi.fn()}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
fireEvent.click(screen.getByText("Contacts"));
|
||||
await waitFor(() => screen.getByText("Grid view"));
|
||||
// Two back buttons exist now: source<-table and table<-view. Click the
|
||||
// first one rendered (which is the view-step header back button).
|
||||
const backButtons = screen.getAllByText("database_view.modal.back");
|
||||
fireEvent.click(backButtons[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Contacts")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Grid view")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls insertDatabaseView with correct attrs on Insert click", async () => {
|
||||
const editor = makeEditor();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Wrapper>
|
||||
<InsertDatabaseModal
|
||||
opened={true}
|
||||
onClose={onClose}
|
||||
editor={editor as never}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
fireEvent.click(screen.getByText("Contacts"));
|
||||
await waitFor(() => screen.getByText("Grid view"));
|
||||
fireEvent.click(screen.getByText("Grid view"));
|
||||
fireEvent.click(screen.getByText("database_view.modal.insert"));
|
||||
|
||||
expect(editor.commands.insertDatabaseView).toHaveBeenCalledWith({
|
||||
tableId: "t1",
|
||||
viewId: "v1",
|
||||
viewType: "grid",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
/**
|
||||
* Integration test: Editor with a pre-inserted database-view node.
|
||||
*
|
||||
* Verifies that:
|
||||
* - The Editor correctly registers the DatabaseViewExtension
|
||||
* - A pre-inserted database-view node renders the DatabaseViewComponent
|
||||
* - The NodeViewWrapper dispatches to TableRenderer for grid viewType
|
||||
*
|
||||
* This test uses a headless Editor (no DOM), checking the ProseMirror
|
||||
* document structure. The React node view rendering is covered by the
|
||||
* unit tests in database-view-component.test.tsx.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import DatabaseViewExtension from "../extension/database-view-extension";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
vi.mock("../renderers/table-renderer", () => ({
|
||||
TableRenderer: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-database-realtime-updates", () => ({
|
||||
useDatabaseRealtimeUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-view-data", () => ({
|
||||
VIEW_DATA_QUERY_KEY: "view-data",
|
||||
useViewData: vi.fn().mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: [], fields: [], total: 0, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Editor integration with DatabaseViewExtension", () => {
|
||||
it("registers database-view in the schema", () => {
|
||||
const editor = new Editor({
|
||||
extensions: [StarterKit, DatabaseViewExtension],
|
||||
content: "<p>hello</p>",
|
||||
element: document.createElement("div"),
|
||||
});
|
||||
|
||||
expect(editor.schema.nodes["database-view"]).toBeDefined();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("accepts pre-inserted database-view node via HTML content", () => {
|
||||
const editor = new Editor({
|
||||
extensions: [StarterKit, DatabaseViewExtension],
|
||||
content:
|
||||
'<div data-node-type="database-view" data-table-id="42" data-view-id="7" data-view-type="grid"></div>',
|
||||
element: document.createElement("div"),
|
||||
});
|
||||
|
||||
const { doc } = editor.state;
|
||||
let count = 0;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === "database-view") {
|
||||
count++;
|
||||
expect(node.attrs.tableId).toBe("42");
|
||||
expect(node.attrs.viewId).toBe("7");
|
||||
expect(node.attrs.viewType).toBe("grid");
|
||||
}
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("inserts a database-view node via the insertDatabaseView command", () => {
|
||||
const editor = new Editor({
|
||||
extensions: [StarterKit, DatabaseViewExtension],
|
||||
content: "<p></p>",
|
||||
element: document.createElement("div"),
|
||||
});
|
||||
|
||||
editor.commands.insertDatabaseView({
|
||||
tableId: "tbl-99",
|
||||
viewId: "view-88",
|
||||
viewType: "table",
|
||||
bridgeUrl: "http://bridge.local:4000",
|
||||
});
|
||||
|
||||
const { doc } = editor.state;
|
||||
// doc.firstChild is a property not a function — use the PM Node type directly.
|
||||
let found: import("@tiptap/pm/model").Node | null = null;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === "database-view") found = node;
|
||||
});
|
||||
|
||||
expect(found).not.toBeNull();
|
||||
// Cast via unknown: PM Node.attrs is Attrs (plain object) not the specific keys.
|
||||
expect((found as unknown as { attrs: { tableId: string } }).attrs.tableId).toBe("tbl-99");
|
||||
expect((found as unknown as { attrs: { bridgeUrl: string } }).attrs.bridgeUrl).toBe(
|
||||
"http://bridge.local:4000",
|
||||
);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("serialises and re-parses the node via getHTML / setContent round-trip", () => {
|
||||
const editor = new Editor({
|
||||
extensions: [StarterKit, DatabaseViewExtension],
|
||||
content: "<p></p>",
|
||||
element: document.createElement("div"),
|
||||
});
|
||||
|
||||
editor.commands.insertDatabaseView({
|
||||
tableId: "t-round",
|
||||
viewId: "v-round",
|
||||
viewType: "grid",
|
||||
bridgeUrl: null,
|
||||
});
|
||||
|
||||
const html = editor.getHTML();
|
||||
|
||||
// Re-create a fresh editor with the serialised HTML.
|
||||
const editor2 = new Editor({
|
||||
extensions: [StarterKit, DatabaseViewExtension],
|
||||
content: html,
|
||||
element: document.createElement("div"),
|
||||
});
|
||||
|
||||
let restoredAttrs: { tableId: string; viewId: string } | null = null;
|
||||
editor2.state.doc.descendants((node) => {
|
||||
if (node.type.name === "database-view") {
|
||||
restoredAttrs = node.attrs as { tableId: string; viewId: string };
|
||||
}
|
||||
});
|
||||
|
||||
expect(restoredAttrs).not.toBeNull();
|
||||
expect(restoredAttrs!.tableId).toBe("t-round");
|
||||
expect(restoredAttrs!.viewId).toBe("v-round");
|
||||
|
||||
editor.destroy();
|
||||
editor2.destroy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
/**
|
||||
* Tests for KanbanRenderer.
|
||||
*
|
||||
* Covers:
|
||||
* - loading skeleton shown when isLoading
|
||||
* - error alert shown on error
|
||||
* - "no groupby field" alert when no single_select field
|
||||
* - columns rendered per unique field value
|
||||
* - empty column placeholder
|
||||
* - drag-drop triggers useUpdateRow mutation with new column value
|
||||
* - read-only mode indicator when canWriteRows is false
|
||||
* - permission denied: InlineEditor shows read-only span
|
||||
*
|
||||
* Note on drag-drop: @dnd-kit does not expose a simple test helper for
|
||||
* simulating DragEndEvent. We stub the DndContext to call handleDragEnd
|
||||
* directly via the exported test helper (not the component — we test the
|
||||
* mutation trigger by stubbing useUpdateRow).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { KanbanRenderer } from "../renderers/kanban-renderer";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-view-data", () => ({
|
||||
VIEW_DATA_QUERY_KEY: "view-data",
|
||||
useViewData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-update-row", () => ({
|
||||
useUpdateRow: vi.fn(() => ({ mutate: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-database-realtime-updates", () => ({
|
||||
useDatabaseRealtimeUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-permissions", () => ({
|
||||
usePermissions: vi.fn(() => ({ canWriteRows: true, isAdmin: false, isResolved: true })),
|
||||
}));
|
||||
|
||||
// Mock dnd-kit to avoid JSDOM pointer sensor issues.
|
||||
vi.mock("@dnd-kit/core", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||
DragOverlay: ({ children }: { children: React.ReactNode }) => React.createElement("div", { "data-testid": "drag-overlay" }, children),
|
||||
PointerSensor: class {},
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
closestCenter: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dnd-kit/sortable", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
SortableContext: ({ children }: { children: React.ReactNode }) => React.createElement("div", null, children),
|
||||
useSortable: vi.fn(() => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
transition: null,
|
||||
isDragging: false,
|
||||
})),
|
||||
verticalListSortingStrategy: {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dnd-kit/utilities", () => ({
|
||||
CSS: { Transform: { toString: vi.fn(() => "") } },
|
||||
}));
|
||||
|
||||
import { useViewData } from "../hooks/use-view-data";
|
||||
import { usePermissions } from "../hooks/use-permissions";
|
||||
|
||||
const mockUseViewData = useViewData as ReturnType<typeof vi.fn>;
|
||||
const mockUsePermissions = usePermissions as ReturnType<typeof vi.fn>;
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const FIELDS = [
|
||||
{ id: "f1", name: "Name", type: "text", primary: true, options: null },
|
||||
{
|
||||
id: "f2",
|
||||
name: "Status",
|
||||
type: "single_select",
|
||||
options: {
|
||||
select_options: [
|
||||
{ id: 1, value: "Todo" },
|
||||
{ id: 2, value: "Done" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const ROWS = [
|
||||
{ id: "r1", tableId: "t1", fields: { Name: "Task A", Status: { id: 1, value: "Todo" } } },
|
||||
{ id: "r2", tableId: "t1", fields: { Name: "Task B", Status: { id: 2, value: "Done" } } },
|
||||
{ id: "r3", tableId: "t1", fields: { Name: "Task C", Status: { id: 1, value: "Todo" } } },
|
||||
];
|
||||
|
||||
describe("KanbanRenderer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUsePermissions.mockReturnValue({ canWriteRows: true, isAdmin: false, isResolved: true });
|
||||
});
|
||||
|
||||
it("shows skeleton when loading", () => {
|
||||
mockUseViewData.mockReturnValue({ isLoading: true, isError: false, data: null, error: null, refetch: vi.fn() });
|
||||
const { container } = render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
// Skeleton renders Mantine Skeleton components — no columns visible.
|
||||
expect(container.querySelector("[data-testid='kanban-column']")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows error alert on error", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
data: null,
|
||||
error: { response: { status: 500 } },
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.getByText("database_view.error.generic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no_groupby_field alert when no single_select field exists", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
rows: [],
|
||||
fields: [{ id: "f1", name: "Name", type: "text", primary: true }],
|
||||
total: 0,
|
||||
hasNextPage: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.getByText("database_view.kanban.no_groupby_field")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one column per unique status value", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
// Both column headers should be visible.
|
||||
expect(screen.getByText("Todo")).toBeInTheDocument();
|
||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows card titles inside columns", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.getByText("Task A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task B")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task C")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty column placeholder when column has no rows", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
rows: [
|
||||
{ id: "r1", tableId: "t1", fields: { Name: "Task A", Status: { id: 1, value: "Todo" } } },
|
||||
],
|
||||
fields: FIELDS,
|
||||
total: 1,
|
||||
hasNextPage: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
// "Done" column has no rows.
|
||||
expect(screen.getByText("database_view.kanban.empty_column")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows read-only indicator when canWriteRows is false", () => {
|
||||
mockUsePermissions.mockReturnValue({ canWriteRows: false, isAdmin: false, isResolved: true });
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<Wrapper><KanbanRenderer tableId="t1" viewId="v1" /></Wrapper>);
|
||||
expect(screen.getByText("database_view.edit.read_only_mode")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,237 +0,0 @@
|
|||
/**
|
||||
* Tests for TableRenderer.
|
||||
*
|
||||
* Covers:
|
||||
* - loading state (skeleton)
|
||||
* - error states (generic, 403, 404)
|
||||
* - empty state
|
||||
* - data state (columns from fields, rows)
|
||||
* - pagination controls
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { TableRenderer } from "../renderers/table-renderer";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// We mock the hooks to control the data returned to the renderer.
|
||||
vi.mock("../hooks/use-view-data", () => ({
|
||||
VIEW_DATA_QUERY_KEY: "view-data",
|
||||
useViewData: vi.fn(),
|
||||
}));
|
||||
vi.mock("../hooks/use-database-realtime-updates", () => ({
|
||||
useDatabaseRealtimeUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useViewData } from "../hooks/use-view-data";
|
||||
|
||||
const mockUseViewData = useViewData as ReturnType<typeof vi.fn>;
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TableRenderer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading skeleton when isLoading is true", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: null,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
// Skeleton renders multiple elements — just verify no table rendered.
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows generic error alert on failure", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
data: null,
|
||||
error: { response: { status: 500 } },
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("database_view.error.generic"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows permission_denied message on 403", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
data: null,
|
||||
error: { response: { status: 403 } },
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("database_view.error.permission_denied"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows view_not_found message on 404", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
data: null,
|
||||
error: { response: { status: 404 } },
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("database_view.error.view_not_found"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when rows are empty", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: [], fields: [], total: 0, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("database_view.table.empty_state"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders column headers from fields", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
rows: [
|
||||
{ id: "r1", tableId: "t1", fields: { Name: "Alice", Age: "30" } },
|
||||
],
|
||||
fields: [
|
||||
{ id: "Name", name: "Name", type: "text", primary: true },
|
||||
{ id: "Age", name: "Age", type: "number" },
|
||||
],
|
||||
total: 1,
|
||||
hasNextPage: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Age")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple rows", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
rows: [
|
||||
{ id: "r1", tableId: "t1", fields: { Name: "Alice" } },
|
||||
{ id: "r2", tableId: "t1", fields: { Name: "Bob" } },
|
||||
],
|
||||
fields: [{ id: "Name", name: "Name", type: "text", primary: true }],
|
||||
total: 2,
|
||||
hasNextPage: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pagination when hasNextPage is true and increments page on click", () => {
|
||||
// First call returns page 1.
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
rows: [{ id: "r1", tableId: "t1", fields: { Name: "Alice" } }],
|
||||
fields: [{ id: "Name", name: "Name", type: "text", primary: true }],
|
||||
total: 100,
|
||||
hasNextPage: true,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
const nextBtn = screen.getByText("database_view.table.next");
|
||||
expect(nextBtn).toBeInTheDocument();
|
||||
fireEvent.click(nextBtn);
|
||||
// After click, useViewData should have been called again (via re-render
|
||||
// with page=2). We verify by checking the mock call count increased.
|
||||
expect(mockUseViewData).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockUseViewData.mock.calls[1][0];
|
||||
expect(secondCall.page).toBe(2);
|
||||
});
|
||||
|
||||
it("calls refetch when retry button is clicked on error", () => {
|
||||
const refetch = vi.fn();
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
data: null,
|
||||
error: { response: { status: 500 } },
|
||||
refetch,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TableRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("database_view.error.retry"));
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,406 +0,0 @@
|
|||
/**
|
||||
* Tests for TimelineRenderer.
|
||||
*
|
||||
* Approach: mock FullCalendar (same pattern as calendar-renderer.test.tsx),
|
||||
* mock useTimelineConfig and useViewData, verify UI states and interaction handlers.
|
||||
*
|
||||
* Covers:
|
||||
* 1. shows skeleton when data loading
|
||||
* 2. shows skeleton when config loading
|
||||
* 3. shows error alert on data load error
|
||||
* 4. shows config panel when no config saved (config === null)
|
||||
* 5. renders timeline stub when data + config present
|
||||
* 6. passes correct events from rows to FullCalendar
|
||||
* 7. rows missing start date are excluded from events
|
||||
* 8. end_date fallback: when endCol absent, end = start + 1 day
|
||||
* 9. resource swimlane: resourceId set on events when resourceCol configured
|
||||
* 10. eventResize calls updateRow.mutate with new end date when canWriteRows true
|
||||
* 11. eventResize calls revert when canWriteRows is false
|
||||
* 12. eventClick opens RowDetailModal
|
||||
* 13. configure button shows config panel
|
||||
* 14. empty events shows empty state alert
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { TimelineRenderer } from "../renderers/timeline-renderer";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-view-data", () => ({
|
||||
VIEW_DATA_QUERY_KEY: "view-data",
|
||||
useViewData: vi.fn(),
|
||||
}));
|
||||
|
||||
const mutateMock = vi.fn();
|
||||
vi.mock("../hooks/use-update-row", () => ({
|
||||
useUpdateRow: vi.fn(() => ({ mutate: mutateMock })),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-database-realtime-updates", () => ({
|
||||
useDatabaseRealtimeUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/use-permissions", () => ({
|
||||
usePermissions: vi.fn(() => ({ canWriteRows: true, isAdmin: false, isResolved: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/hooks", () => ({
|
||||
useDisclosure: vi.fn(() => [false, { open: vi.fn(), close: vi.fn() }]),
|
||||
}));
|
||||
|
||||
const saveConfigMock = vi.fn();
|
||||
vi.mock("../hooks/use-timeline-config", () => ({
|
||||
useTimelineConfig: vi.fn(() => ({
|
||||
config: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
saveConfig: saveConfigMock,
|
||||
isSaving: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub FullCalendar to expose captured props for handler testing.
|
||||
let capturedFcProps: Record<string, unknown> = {};
|
||||
vi.mock("@fullcalendar/react", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
capturedFcProps = props;
|
||||
const events = (props.events as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
resourceId?: string;
|
||||
}>) ?? [];
|
||||
return (
|
||||
<div data-testid="fullcalendar-stub">
|
||||
{events.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
data-testid={`event-${e.id}`}
|
||||
data-start={e.start}
|
||||
data-end={e.end}
|
||||
data-resource={e.resourceId ?? ""}
|
||||
>
|
||||
{e.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@fullcalendar/timeline", () => ({ default: {} }));
|
||||
vi.mock("@fullcalendar/resource-timeline", () => ({ default: {} }));
|
||||
vi.mock("@fullcalendar/interaction", () => ({ default: {} }));
|
||||
|
||||
import { useViewData } from "../hooks/use-view-data";
|
||||
import { usePermissions } from "../hooks/use-permissions";
|
||||
import { useTimelineConfig } from "../hooks/use-timeline-config";
|
||||
|
||||
const mockUseViewData = useViewData as ReturnType<typeof vi.fn>;
|
||||
const mockUsePermissions = usePermissions as ReturnType<typeof vi.fn>;
|
||||
const mockUseTimelineConfig = useTimelineConfig as ReturnType<typeof vi.fn>;
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const FIELDS = [
|
||||
{ id: "f1", name: "Name", type: "text", primary: true, options: null },
|
||||
{ id: "f2", name: "Start", type: "date", options: null },
|
||||
{ id: "f3", name: "End", type: "date", options: null },
|
||||
{ id: "f4", name: "Team", type: "text", options: null },
|
||||
];
|
||||
|
||||
const CONFIG = {
|
||||
titleCol: "Name",
|
||||
startCol: "Start",
|
||||
endCol: "End",
|
||||
resourceCol: null,
|
||||
};
|
||||
|
||||
const ROWS = [
|
||||
{
|
||||
id: "r1",
|
||||
tableId: "t1",
|
||||
fields: { Name: "Task A", Start: "2026-06-01T00:00:00Z", End: "2026-06-05T00:00:00Z" },
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
tableId: "t1",
|
||||
fields: { Name: "Task B", Start: "2026-06-10T00:00:00Z", End: "2026-06-20T00:00:00Z" },
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
tableId: "t1",
|
||||
fields: { Name: "No start date" },
|
||||
},
|
||||
];
|
||||
|
||||
function setupDefaults() {
|
||||
mockUsePermissions.mockReturnValue({ canWriteRows: true, isAdmin: false, isResolved: true });
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: ROWS, fields: FIELDS, total: 3, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
mockUseTimelineConfig.mockReturnValue({
|
||||
config: CONFIG,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
saveConfig: saveConfigMock,
|
||||
isSaving: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("TimelineRenderer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedFcProps = {};
|
||||
setupDefaults();
|
||||
});
|
||||
|
||||
it("shows skeleton when data is loading", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: null,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.queryByTestId("fullcalendar-stub")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("timeline-renderer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows skeleton when config is loading", () => {
|
||||
mockUseTimelineConfig.mockReturnValue({
|
||||
config: null,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
saveConfig: saveConfigMock,
|
||||
isSaving: false,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.queryByTestId("fullcalendar-stub")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alert on data load error", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
data: null,
|
||||
error: { response: { status: 500 } },
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByText("database_view.error.generic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows config panel when no config saved", () => {
|
||||
mockUseTimelineConfig.mockReturnValue({
|
||||
config: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
saveConfig: saveConfigMock,
|
||||
isSaving: false,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("timeline-config-panel")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("fullcalendar-stub")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders timeline stub when data and config are present", () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("timeline-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("fullcalendar-stub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes only rows with a valid start date as events (excludes r3)", () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("event-r1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("event-r2")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("event-r3")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets event end = start + 1 day when endCol is null", () => {
|
||||
mockUseTimelineConfig.mockReturnValue({
|
||||
config: { ...CONFIG, endCol: null },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
saveConfig: saveConfigMock,
|
||||
isSaving: false,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
const eventEl = screen.getByTestId("event-r1");
|
||||
const startStr = eventEl.getAttribute("data-start")!;
|
||||
const endStr = eventEl.getAttribute("data-end")!;
|
||||
const start = new Date(startStr);
|
||||
const end = new Date(endStr);
|
||||
const diffDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24);
|
||||
expect(diffDays).toBe(1);
|
||||
});
|
||||
|
||||
it("attaches resourceId to events when resourceCol is configured", () => {
|
||||
const rowsWithTeam = [
|
||||
{
|
||||
id: "r1",
|
||||
tableId: "t1",
|
||||
fields: {
|
||||
Name: "Task A",
|
||||
Start: "2026-06-01T00:00:00Z",
|
||||
End: "2026-06-05T00:00:00Z",
|
||||
Team: "Alpha",
|
||||
},
|
||||
},
|
||||
];
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: { rows: rowsWithTeam, fields: FIELDS, total: 1, hasNextPage: false },
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
mockUseTimelineConfig.mockReturnValue({
|
||||
config: { ...CONFIG, resourceCol: "Team" },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
saveConfig: saveConfigMock,
|
||||
isSaving: false,
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
const eventEl = screen.getByTestId("event-r1");
|
||||
expect(eventEl.getAttribute("data-resource")).toBe("Alpha");
|
||||
});
|
||||
|
||||
it("eventResize calls mutate with new end date when canWriteRows is true", () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
const newEnd = new Date("2026-06-25T00:00:00Z");
|
||||
const revert = vi.fn();
|
||||
const resizeArg = {
|
||||
event: { id: "r1", end: newEnd },
|
||||
revert,
|
||||
};
|
||||
const eventResizeFn = capturedFcProps.eventResize as (arg: typeof resizeArg) => void;
|
||||
expect(typeof eventResizeFn).toBe("function");
|
||||
eventResizeFn(resizeArg);
|
||||
expect(mutateMock).toHaveBeenCalledWith(
|
||||
{
|
||||
rowId: "r1",
|
||||
payload: { fields: { End: newEnd.toISOString() } },
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("eventResize calls revert when canWriteRows is false", () => {
|
||||
mockUsePermissions.mockReturnValue({ canWriteRows: false, isAdmin: false, isResolved: true });
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
const revert = vi.fn();
|
||||
const resizeFn = capturedFcProps.eventResize as (arg: {
|
||||
event: { id: string; end: Date };
|
||||
revert: () => void;
|
||||
}) => void;
|
||||
resizeFn({ event: { id: "r1", end: new Date() }, revert });
|
||||
expect(revert).toHaveBeenCalled();
|
||||
expect(mutateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows empty state alert when no events can be built", () => {
|
||||
mockUseViewData.mockReturnValue({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
rows: [{ id: "r1", tableId: "t1", fields: { Name: "No date" } }],
|
||||
fields: FIELDS,
|
||||
total: 1,
|
||||
hasNextPage: false,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByText("database_view.timeline.no_events")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("configure button re-shows the config panel", async () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(screen.getByTestId("timeline-renderer")).toBeInTheDocument();
|
||||
const configBtn = screen.getByRole("button", {
|
||||
name: "database_view.timeline.configure",
|
||||
});
|
||||
fireEvent.click(configBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("timeline-config-panel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("eventClick handler is a function exposed to FullCalendar", () => {
|
||||
render(
|
||||
<Wrapper>
|
||||
<TimelineRenderer tableId="t1" viewId="v1" />
|
||||
</Wrapper>,
|
||||
);
|
||||
expect(typeof capturedFcProps.eventClick).toBe("function");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
/**
|
||||
* Tests for useDatabaseRealtimeUpdates SSE hook.
|
||||
*
|
||||
* Covers:
|
||||
* - creates an EventSource with correct URL and credentials
|
||||
* - invalidates React Query cache on matching row.updated event
|
||||
* - ignores events from a different tableId
|
||||
* - ignores events from a different viewId
|
||||
* - closes the EventSource on unmount (cleanup)
|
||||
* - does not create EventSource when tableId/viewId are undefined
|
||||
* - handles malformed SSE data gracefully (no crash)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
|
||||
import { VIEW_DATA_QUERY_KEY } from "../hooks/use-view-data";
|
||||
|
||||
// Stub EventSource globally for jsdom (native EventSource not in jsdom).
|
||||
class MockEventSource {
|
||||
url: string;
|
||||
withCredentials: boolean;
|
||||
readyState = 0;
|
||||
private _listeners: Record<string, ((evt: MessageEvent) => void)[]> = {};
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(url: string, init?: { withCredentials?: boolean }) {
|
||||
this.url = url;
|
||||
this.withCredentials = init?.withCredentials ?? false;
|
||||
instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, handler: (evt: MessageEvent) => void) {
|
||||
if (!this._listeners[type]) this._listeners[type] = [];
|
||||
this._listeners[type].push(handler);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, handler: (evt: MessageEvent) => void) {
|
||||
this._listeners[type] = (this._listeners[type] ?? []).filter(
|
||||
(h) => h !== handler,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper for tests to emit events.
|
||||
emit(type: string, data: string) {
|
||||
for (const handler of this._listeners[type] ?? []) {
|
||||
handler({ data } as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
emitOpen() {
|
||||
this.readyState = 1;
|
||||
for (const handler of this._listeners["open"] ?? []) {
|
||||
handler({} as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
emitError() {
|
||||
for (const handler of this._listeners["error"] ?? []) {
|
||||
handler({} as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
close = vi.fn(() => {
|
||||
this.readyState = 2;
|
||||
});
|
||||
}
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
instances.length = 0;
|
||||
vi.stubGlobal("EventSource", MockEventSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function makeWrapper(qc: QueryClient) {
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(QueryClientProvider, { client: qc }, children);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDatabaseRealtimeUpdates", () => {
|
||||
it("creates an EventSource with withCredentials=true", () => {
|
||||
const qc = new QueryClient();
|
||||
renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), {
|
||||
wrapper: makeWrapper(qc),
|
||||
});
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0].withCredentials).toBe(true);
|
||||
expect(instances[0].url).toContain("/api/v1/events/sse");
|
||||
expect(instances[0].url).toContain("tables=t1");
|
||||
expect(instances[0].url).toContain("views=v1");
|
||||
});
|
||||
|
||||
it("invalidates view-data cache on row.updated event matching tableId+viewId", async () => {
|
||||
const qc = new QueryClient();
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), {
|
||||
wrapper: makeWrapper(qc),
|
||||
});
|
||||
|
||||
const es = instances[0];
|
||||
act(() => {
|
||||
es.emit(
|
||||
"row.updated",
|
||||
JSON.stringify({ event: "row.updated", tableId: "t1", viewId: "v1" }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: [VIEW_DATA_QUERY_KEY, "v1"],
|
||||
exact: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("also invalidates on generic 'message' event", async () => {
|
||||
const qc = new QueryClient();
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), {
|
||||
wrapper: makeWrapper(qc),
|
||||
});
|
||||
|
||||
const es = instances[0];
|
||||
act(() => {
|
||||
es.emit(
|
||||
"message",
|
||||
JSON.stringify({ event: "row.created", tableId: "t1", viewId: "v1" }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT invalidate when tableId in event does not match", async () => {
|
||||
const qc = new QueryClient();
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), {
|
||||
wrapper: makeWrapper(qc),
|
||||
});
|
||||
|
||||
const es = instances[0];
|
||||
act(() => {
|
||||
es.emit(
|
||||
"row.updated",
|
||||
JSON.stringify({ event: "row.updated", tableId: "t999", viewId: "v1" }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT invalidate when viewId in event does not match", async () => {
|
||||
const qc = new QueryClient();
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), {
|
||||
wrapper: makeWrapper(qc),
|
||||
});
|
||||
|
||||
const es = instances[0];
|
||||
act(() => {
|
||||
es.emit(
|
||||
"row.updated",
|
||||
JSON.stringify({ event: "row.updated", tableId: "t1", viewId: "v999" }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidates when no tableId/viewId in event payload (broadcast event)", async () => {
|
||||
const qc = new QueryClient();
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), {
|
||||
wrapper: makeWrapper(qc),
|
||||
});
|
||||
|
||||
const es = instances[0];
|
||||
act(() => {
|
||||
// Broadcast event without tableId/viewId = affects all tables.
|
||||
es.emit("row.updated", JSON.stringify({ event: "row.updated" }));
|
||||
});
|
||||
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not crash on malformed SSE data", () => {
|
||||
const qc = new QueryClient();
|
||||
renderHook(() => useDatabaseRealtimeUpdates("t1", "v1"), {
|
||||
wrapper: makeWrapper(qc),
|
||||
});
|
||||
|
||||
const es = instances[0];
|
||||
expect(() => {
|
||||
act(() => {
|
||||
es.emit("message", "not-valid-json{{{");
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("closes EventSource on unmount", () => {
|
||||
const qc = new QueryClient();
|
||||
const { unmount } = renderHook(
|
||||
() => useDatabaseRealtimeUpdates("t1", "v1"),
|
||||
{ wrapper: makeWrapper(qc) },
|
||||
);
|
||||
|
||||
const es = instances[0];
|
||||
unmount();
|
||||
expect(es.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not create EventSource when tableId is undefined", () => {
|
||||
const qc = new QueryClient();
|
||||
renderHook(() => useDatabaseRealtimeUpdates(undefined, "v1"), {
|
||||
wrapper: makeWrapper(qc),
|
||||
});
|
||||
expect(instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not create EventSource when viewId is undefined", () => {
|
||||
const qc = new QueryClient();
|
||||
renderHook(() => useDatabaseRealtimeUpdates("t1", undefined), {
|
||||
wrapper: makeWrapper(qc),
|
||||
});
|
||||
expect(instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
/**
|
||||
* Tests for usePermissions.
|
||||
*
|
||||
* Covers:
|
||||
* - admin:* -> isAdmin + canWriteRows both true
|
||||
* - rows:write only -> canWriteRows true, isAdmin false
|
||||
* - no permissions -> optimistic default (canWriteRows true, isResolved false)
|
||||
* - cookie fallback parsing
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { usePermissions } from "../hooks/use-permissions";
|
||||
|
||||
describe("usePermissions", () => {
|
||||
beforeEach(() => {
|
||||
// Clear the global cache between tests.
|
||||
if (typeof window !== "undefined") {
|
||||
delete (window as unknown as Record<string, unknown>)["__acadenice_perms"];
|
||||
}
|
||||
// Reset cookie.
|
||||
Object.defineProperty(document, "cookie", {
|
||||
writable: true,
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns isAdmin + canWriteRows when admin:* is in window global", () => {
|
||||
(window as unknown as Record<string, unknown>)["__acadenice_perms"] = [
|
||||
"admin:*",
|
||||
];
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.isAdmin).toBe(true);
|
||||
expect(result.current.canWriteRows).toBe(true);
|
||||
expect(result.current.isResolved).toBe(true);
|
||||
});
|
||||
|
||||
it("returns canWriteRows when rows:write is present but not admin:*", () => {
|
||||
(window as unknown as Record<string, unknown>)["__acadenice_perms"] = [
|
||||
"rows:read",
|
||||
"rows:write",
|
||||
"pages:read",
|
||||
];
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.isAdmin).toBe(false);
|
||||
expect(result.current.canWriteRows).toBe(true);
|
||||
expect(result.current.isResolved).toBe(true);
|
||||
});
|
||||
|
||||
it("returns canWriteRows false when only rows:read is present", () => {
|
||||
(window as unknown as Record<string, unknown>)["__acadenice_perms"] = [
|
||||
"rows:read",
|
||||
"pages:read",
|
||||
];
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canWriteRows).toBe(false);
|
||||
expect(result.current.isAdmin).toBe(false);
|
||||
expect(result.current.isResolved).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to optimistic default when no source is available", () => {
|
||||
// No global, no cookie.
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
// Optimistic: allow writes but mark as unresolved.
|
||||
expect(result.current.canWriteRows).toBe(true);
|
||||
expect(result.current.isResolved).toBe(false);
|
||||
});
|
||||
|
||||
it("reads permissions from acadenicePerms cookie when window global is absent", () => {
|
||||
Object.defineProperty(document, "cookie", {
|
||||
writable: true,
|
||||
value: `acadenicePerms=${encodeURIComponent(JSON.stringify(["rows:write"]))}`,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePermissions());
|
||||
expect(result.current.canWriteRows).toBe(true);
|
||||
expect(result.current.isResolved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
/**
|
||||
* Tests for useUpdateRow.
|
||||
*
|
||||
* Covers:
|
||||
* - successful PATCH -> optimistic update applied, cache invalidated
|
||||
* - PATCH failure -> rollback to previous cache state
|
||||
* - mutation fires the correct bridge endpoint
|
||||
* - onSettled always invalidates view-data queries
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useUpdateRow } from "../hooks/use-update-row";
|
||||
import { VIEW_DATA_QUERY_KEY } from "../hooks/use-view-data";
|
||||
import * as bridgeClientModule from "../services/bridge-client";
|
||||
|
||||
vi.mock("../services/bridge-client", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof bridgeClientModule>();
|
||||
return {
|
||||
...original,
|
||||
getBridgeClient: vi.fn(),
|
||||
resolveBridgeUrl: vi.fn(() => "http://localhost:4000"),
|
||||
};
|
||||
});
|
||||
|
||||
function makeWrapper(qc: QueryClient) {
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
const TABLE_ID = "tbl1";
|
||||
const VIEW_ID = "view1";
|
||||
const ROW_ID = "row1";
|
||||
|
||||
describe("useUpdateRow", () => {
|
||||
let qc: QueryClient;
|
||||
let patchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
patchMock = vi.fn();
|
||||
(bridgeClientModule.getBridgeClient as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
patch: patchMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies optimistic update before server responds", async () => {
|
||||
// Seed cache with existing row.
|
||||
const queryKey = [VIEW_DATA_QUERY_KEY, VIEW_ID, 1, 50, "http://localhost:4000"];
|
||||
qc.setQueryData(queryKey, {
|
||||
rows: [{ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "OldName" } }],
|
||||
fields: [{ id: "Name", name: "Name", type: "text", primary: true }],
|
||||
total: 1,
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
// PATCH resolves slowly — we check optimistic state before it completes.
|
||||
let resolvePatch!: (v: unknown) => void;
|
||||
patchMock.mockReturnValue(new Promise((r) => { resolvePatch = r; }));
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
|
||||
{ wrapper: makeWrapper(qc) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "NewName" } } });
|
||||
});
|
||||
|
||||
// Before PATCH resolves, check optimistic state.
|
||||
await waitFor(() => result.current.isPending);
|
||||
|
||||
const optimisticData = qc.getQueryData<{ rows: { id: string; fields: { Name: string } }[] }>(queryKey);
|
||||
expect(optimisticData?.rows[0].fields.Name).toBe("NewName");
|
||||
|
||||
// Resolve the PATCH.
|
||||
resolvePatch({ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "NewName" } });
|
||||
});
|
||||
|
||||
it("rolls back on PATCH error", async () => {
|
||||
const queryKey = [VIEW_DATA_QUERY_KEY, VIEW_ID, 1, 50, "http://localhost:4000"];
|
||||
qc.setQueryData(queryKey, {
|
||||
rows: [{ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "OriginalName" } }],
|
||||
fields: [{ id: "Name", name: "Name", type: "text", primary: true }],
|
||||
total: 1,
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
patchMock.mockRejectedValue(new Error("Server error"));
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
|
||||
{ wrapper: makeWrapper(qc) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "NewName" } } });
|
||||
});
|
||||
|
||||
await waitFor(() => result.current.isError);
|
||||
|
||||
// After error, cache should be rolled back to the original value.
|
||||
const rolledBack = qc.getQueryData<{ rows: { id: string; fields: { Name: string } }[] }>(queryKey);
|
||||
expect(rolledBack?.rows[0].fields.Name).toBe("OriginalName");
|
||||
});
|
||||
|
||||
it("calls PATCH on the correct endpoint", async () => {
|
||||
patchMock.mockResolvedValue({ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "New" } });
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
|
||||
{ wrapper: makeWrapper(qc) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "New" } } });
|
||||
});
|
||||
|
||||
await waitFor(() => result.current.isSuccess);
|
||||
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
`/api/v1/tables/${TABLE_ID}/rows/${ROW_ID}`,
|
||||
{ fields: { Name: "New" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates view-data queries on settled (success)", async () => {
|
||||
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
|
||||
patchMock.mockResolvedValue({ id: ROW_ID, tableId: TABLE_ID, fields: { Name: "A" } });
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
|
||||
{ wrapper: makeWrapper(qc) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "A" } } });
|
||||
});
|
||||
|
||||
await waitFor(() => result.current.isSuccess);
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: [VIEW_DATA_QUERY_KEY, VIEW_ID] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates view-data queries on settled (error)", async () => {
|
||||
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
|
||||
patchMock.mockRejectedValue(new Error("oops"));
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useUpdateRow({ tableId: TABLE_ID, viewId: VIEW_ID }),
|
||||
{ wrapper: makeWrapper(qc) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.mutate({ rowId: ROW_ID, payload: { fields: { Name: "X" } } });
|
||||
});
|
||||
|
||||
await waitFor(() => result.current.isError);
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: [VIEW_DATA_QUERY_KEY, VIEW_ID] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
TextInput,
|
||||
Select,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
Button,
|
||||
Group,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import {
|
||||
type FieldType,
|
||||
createField,
|
||||
updateField,
|
||||
} from "../services/admin-client";
|
||||
import type { BridgeField } from "../types/database-view.types";
|
||||
|
||||
const FIELD_TYPE_OPTIONS: Array<{ value: FieldType; label: string }> = [
|
||||
{ value: "text", label: "Texte court" },
|
||||
{ value: "long_text", label: "Texte long" },
|
||||
{ value: "number", label: "Nombre" },
|
||||
{ value: "rating", label: "Note (étoiles)" },
|
||||
{ value: "boolean", label: "Case à cocher" },
|
||||
{ value: "date", label: "Date" },
|
||||
{ value: "single_select", label: "Choix unique" },
|
||||
{ value: "multiple_select", label: "Choix multiple" },
|
||||
{ value: "url", label: "URL" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "phone_number", label: "Téléphone" },
|
||||
{ value: "duration", label: "Durée" },
|
||||
{ value: "formula", label: "Formule (calcul)" },
|
||||
];
|
||||
|
||||
interface FieldAdminModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Provided in create mode. */
|
||||
tableId?: number;
|
||||
/** Provided in edit mode. */
|
||||
field?: BridgeField | null;
|
||||
bridgeUrl?: string | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function FieldAdminModal({
|
||||
opened,
|
||||
onClose,
|
||||
tableId,
|
||||
field,
|
||||
bridgeUrl,
|
||||
onSuccess,
|
||||
}: FieldAdminModalProps) {
|
||||
const isEdit = Boolean(field);
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<FieldType>("text");
|
||||
const [formula, setFormula] = useState("");
|
||||
const [decimals, setDecimals] = useState<number>(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setError(null);
|
||||
setSubmitting(false);
|
||||
if (field) {
|
||||
setName(field.name ?? "");
|
||||
setType((field.type as FieldType) ?? "text");
|
||||
setFormula(((field as unknown) as { formula?: string }).formula ?? "");
|
||||
setDecimals(0);
|
||||
} else {
|
||||
setName("");
|
||||
setType("text");
|
||||
setFormula("");
|
||||
setDecimals(0);
|
||||
}
|
||||
}
|
||||
}, [opened, field]);
|
||||
|
||||
async function handleSubmit() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = { name: name.trim(), type };
|
||||
if (type === "formula") payload.formula = formula.trim();
|
||||
if (type === "number") payload.number_decimal_places = decimals;
|
||||
|
||||
if (isEdit && field) {
|
||||
await updateField(Number(field.id), payload as never, bridgeUrl);
|
||||
} else if (tableId) {
|
||||
await createField(tableId, payload as never, bridgeUrl);
|
||||
} else {
|
||||
throw new Error("tableId manquant en mode create");
|
||||
}
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
(type !== "formula" || formula.trim().length > 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={isEdit ? "Modifier la colonne" : "Ajouter une colonne"}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label="Nom"
|
||||
placeholder="Ex: Heures donnees"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Type"
|
||||
data={FIELD_TYPE_OPTIONS}
|
||||
value={type}
|
||||
onChange={(v) => v && setType(v as FieldType)}
|
||||
searchable
|
||||
/>
|
||||
{type === "formula" && (
|
||||
<Textarea
|
||||
label="Formule"
|
||||
description={
|
||||
<span>
|
||||
Syntaxe Baserow. Ex:
|
||||
<code>{`field('Total') - field('Donné')`}</code>
|
||||
</span>
|
||||
}
|
||||
placeholder="field('A') - field('B')"
|
||||
value={formula}
|
||||
onChange={(e) => setFormula(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
{type === "number" && (
|
||||
<NumberInput
|
||||
label="Décimales"
|
||||
value={decimals}
|
||||
onChange={(v) => setDecimals(typeof v === "number" ? v : 0)}
|
||||
min={0}
|
||||
max={10}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
)}
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose} disabled={submitting}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit} loading={submitting}>
|
||||
{isEdit ? "Enregistrer" : "Créer"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
.input {
|
||||
width: 100%;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
display: inline-block;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
border: 1px dashed var(--mantine-color-gray-4);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .readOnly {
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
import { useState, useEffect, useRef, KeyboardEvent } from "react";
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Select,
|
||||
MultiSelect,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BridgeField } from "../types/database-view.types";
|
||||
import styles from "./inline-editor.module.css";
|
||||
|
||||
export interface InlineEditorProps {
|
||||
field: BridgeField;
|
||||
initialValue: unknown;
|
||||
/** Called with the new value when the user confirms the edit. */
|
||||
onSave: (value: unknown) => void;
|
||||
/** Called when the user cancels (Escape). */
|
||||
onCancel: () => void;
|
||||
/** When false the editor is rendered as read-only display with a tooltip. */
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polymorphic inline cell editor.
|
||||
*
|
||||
* Why polymorphic via field.type discriminator:
|
||||
* Baserow field types (text, number, date, single_select, multiple_select)
|
||||
* each require a different input widget. We centralise the logic here so
|
||||
* every renderer (table, kanban) gets the same editing behaviour for free.
|
||||
*
|
||||
* Save triggers: blur or Enter (for single-line inputs).
|
||||
* Cancel trigger: Escape.
|
||||
* Permission denied: rendered as a read-only span with a tooltip.
|
||||
*/
|
||||
export function InlineEditor({
|
||||
field,
|
||||
initialValue,
|
||||
onSave,
|
||||
onCancel,
|
||||
canWrite,
|
||||
}: InlineEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState<unknown>(initialValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-focus on mount.
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
if (!canWrite) {
|
||||
return (
|
||||
<Tooltip label={t("database_view.edit.permission_denied")} withArrow>
|
||||
<span
|
||||
className={styles.readOnly}
|
||||
data-testid="inline-editor-readonly"
|
||||
>
|
||||
{formatDisplayValue(initialValue)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
onSave(value);
|
||||
} else if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
onSave(value);
|
||||
};
|
||||
|
||||
// Derive select options from field metadata.
|
||||
const selectOptions = deriveSelectOptions(field);
|
||||
|
||||
switch (field.type) {
|
||||
case "number":
|
||||
case "rating":
|
||||
return (
|
||||
<NumberInput
|
||||
ref={inputRef as React.Ref<HTMLInputElement>}
|
||||
value={typeof value === "number" ? value : Number(value) || 0}
|
||||
onChange={(v) => setValue(v)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown as React.KeyboardEventHandler}
|
||||
className={styles.input}
|
||||
size="xs"
|
||||
hideControls
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
case "created_on":
|
||||
case "last_modified":
|
||||
return (
|
||||
<DateInput
|
||||
value={parseDate(value)}
|
||||
onChange={(v) => {
|
||||
// Mantine v8 DateInput returns a DateStringValue (string) or null —
|
||||
// store as-is; callers expect an ISO string or null.
|
||||
setValue(v ?? null);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
className={styles.input}
|
||||
size="xs"
|
||||
clearable
|
||||
/>
|
||||
);
|
||||
|
||||
case "single_select":
|
||||
return (
|
||||
<Select
|
||||
ref={inputRef as React.Ref<HTMLInputElement>}
|
||||
value={extractSelectId(value)}
|
||||
data={selectOptions}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
onSave(v);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
className={styles.input}
|
||||
size="xs"
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
);
|
||||
|
||||
case "multiple_select":
|
||||
return (
|
||||
<MultiSelect
|
||||
ref={inputRef as React.Ref<HTMLInputElement>}
|
||||
value={extractMultiSelectIds(value)}
|
||||
data={selectOptions}
|
||||
onChange={(v) => setValue(v)}
|
||||
onBlur={() => {
|
||||
onSave(value);
|
||||
}}
|
||||
className={styles.input}
|
||||
size="xs"
|
||||
searchable
|
||||
/>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
// Boolean is toggled directly — no inline text input.
|
||||
return (
|
||||
<Select
|
||||
ref={inputRef as React.Ref<HTMLInputElement>}
|
||||
value={value ? "true" : "false"}
|
||||
data={[
|
||||
{ value: "true", label: "true" },
|
||||
{ value: "false", label: "false" },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
const next = v === "true";
|
||||
setValue(next);
|
||||
onSave(next);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
className={styles.input}
|
||||
size="xs"
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
// text, long_text, url, email, phone_number, formula, uuid, auto_number
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={typeof value === "string" ? value : String(value ?? "")}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={styles.input}
|
||||
size="xs"
|
||||
data-testid="inline-editor-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
function formatDisplayValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((v) =>
|
||||
typeof v === "object" && v !== null
|
||||
? (v as { value?: string }).value ?? JSON.stringify(v)
|
||||
: String(v),
|
||||
)
|
||||
.join(", ");
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return (value as { value?: string }).value ?? JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function parseDate(value: unknown): Date | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value as string);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function extractSelectId(value: unknown): string | null {
|
||||
if (!value) return null;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const v = value as { id?: string | number; value?: string };
|
||||
if (v.id !== undefined) return String(v.id);
|
||||
if (v.value !== undefined) return v.value;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function extractMultiSelectIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((v) => {
|
||||
if (typeof v === "object" && v !== null) {
|
||||
const item = v as { id?: string | number; value?: string };
|
||||
if (item.id !== undefined) return String(item.id);
|
||||
if (item.value !== undefined) return item.value;
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
}
|
||||
|
||||
function deriveSelectOptions(
|
||||
field: BridgeField,
|
||||
): { value: string; label: string }[] {
|
||||
const opts = field.options as
|
||||
| {
|
||||
select_options?: { id: number | string; value: string; color?: string }[];
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
if (!opts?.select_options) return [];
|
||||
return opts.select_options.map((o) => ({
|
||||
value: String(o.id),
|
||||
label: o.value,
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { Modal, Stack, Text, Group, Badge, Divider, Tabs } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import type { BridgeRow, BridgeField } from "../types/database-view.types";
|
||||
import { RowCommentsPanel } from "@/features/acadenice/comments/components/row-comments-panel";
|
||||
|
||||
interface RowDetailModalProps {
|
||||
row: BridgeRow | null;
|
||||
fields: BridgeField[];
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple row detail modal — opened when the user clicks on a calendar event.
|
||||
*
|
||||
* Why simple in R3.1.d:
|
||||
* Full inline editing inside the modal is a larger UX investment (field-level
|
||||
* save, validation, optimistic feedback). The priority here is to make the
|
||||
* calendar renderer clickable and show meaningful data. Inline edit from the
|
||||
* modal is slated for R3.1.e / R3.2.
|
||||
*/
|
||||
export function RowDetailModal({ row, fields, opened, onClose }: RowDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("database_view.row_detail.title")}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Tabs defaultValue="fields">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="fields">
|
||||
{t("database_view.row_detail.tab_fields")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="comments">
|
||||
{t("database_view.row_detail.tab_comments")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="fields" pt="xs">
|
||||
<Stack gap="xs">
|
||||
{fields.map((field) => {
|
||||
const rawValue = row.fields[field.name] ?? row.fields[field.id];
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<Group gap="xs" mb={2}>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{field.name}
|
||||
</Text>
|
||||
{field.primary && (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{t("database_view.row_detail.primary_badge")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm">{formatValue(rawValue)}</Text>
|
||||
<Divider mt="xs" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("database_view.row_detail.no_fields")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="comments" pt="xs">
|
||||
{currentUser && (
|
||||
<RowCommentsPanel
|
||||
tableId={String(row.tableId ?? "")}
|
||||
rowId={String(row.id)}
|
||||
currentUserId={currentUser.user.id}
|
||||
/>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((v) =>
|
||||
typeof v === "object" && v !== null
|
||||
? (v as { value?: string }).value ?? JSON.stringify(v)
|
||||
: String(v),
|
||||
)
|
||||
.join(", ");
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return (value as { value?: string }).value ?? JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import type { DatabaseViewAttrs, ViewType } from "../types/database-view.types";
|
||||
import { TableRenderer } from "../renderers/table-renderer";
|
||||
import { KanbanRenderer } from "../renderers/kanban-renderer";
|
||||
import { CalendarRenderer } from "../renderers/calendar-renderer";
|
||||
import { TimelineRenderer } from "../renderers/timeline-renderer";
|
||||
import { PlaceholderRenderer } from "../renderers/placeholder-renderer";
|
||||
import styles from "./database-view.module.css";
|
||||
|
||||
/**
|
||||
* React NodeViewWrapper for the `database-view` Tiptap node.
|
||||
*
|
||||
* Dispatches on `attrs.viewType`:
|
||||
* - "grid" | "table" -> TableRenderer (R3.1.c, now with inline edit R3.1.d)
|
||||
* - "kanban" -> KanbanRenderer (R3.1.d)
|
||||
* - "calendar" -> CalendarRenderer (R3.1.d)
|
||||
* - anything else -> PlaceholderRenderer
|
||||
*/
|
||||
export function DatabaseViewComponent({ node, selected }: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const attrs = node.attrs as DatabaseViewAttrs;
|
||||
const { tableId, viewId, viewType, bridgeUrl } = attrs;
|
||||
|
||||
function renderContent(vt: ViewType) {
|
||||
switch (vt) {
|
||||
case "grid":
|
||||
case "table":
|
||||
return <TableRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
|
||||
case "kanban":
|
||||
return <KanbanRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
|
||||
case "calendar":
|
||||
return <CalendarRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
|
||||
case "timeline":
|
||||
return <TimelineRenderer tableId={tableId} viewId={viewId} bridgeUrl={bridgeUrl} />;
|
||||
default:
|
||||
return <PlaceholderRenderer viewType={vt} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className={clsx(styles.wrapper, { [styles.selected]: selected })}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerIcon}>
|
||||
<IconTable size={14} />
|
||||
</span>
|
||||
<span className={styles.headerTitle}>
|
||||
{t("database_view.node.header_label")}
|
||||
</span>
|
||||
<span>{viewType}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{renderContent(viewType as ViewType)}
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { DatabaseView } from "@docmost/editor-ext";
|
||||
import { DatabaseViewComponent } from "./database-view-component";
|
||||
|
||||
/**
|
||||
* Client-side database-view extension.
|
||||
*
|
||||
* Extends the shared @docmost/editor-ext DatabaseView node (registered on the
|
||||
* Hocuspocus server) to attach the React NodeView. The schema (attrs, parse,
|
||||
* render) lives in the shared node so server collab saves don't strip it.
|
||||
*/
|
||||
const DatabaseViewExtension = DatabaseView.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(DatabaseViewComponent);
|
||||
},
|
||||
});
|
||||
|
||||
export default DatabaseViewExtension;
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
.wrapper {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
overflow: hidden;
|
||||
margin: 4px 0;
|
||||
transition: border-color 100ms ease;
|
||||
background-color: var(--mantine-color-white);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .wrapper {
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
|
||||
.wrapper:hover {
|
||||
border-color: var(--mantine-color-gray-4);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .wrapper:hover {
|
||||
border-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
|
||||
/* ProseMirror node selection state */
|
||||
.wrapper.selected {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
box-shadow: 0 0 0 2px var(--mantine-color-blue-2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .wrapper.selected {
|
||||
border-color: var(--mantine-color-blue-4);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mantine-color-blue-4) 30%, transparent);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--mantine-color-gray-2);
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-gray-6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .header {
|
||||
border-bottom-color: var(--mantine-color-dark-5);
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
color: var(--mantine-color-dark-2);
|
||||
}
|
||||
|
||||
.headerIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
|
||||
import type { BridgeRow } from "../types/database-view.types";
|
||||
import { VIEW_DATA_QUERY_KEY } from "./use-view-data";
|
||||
|
||||
interface UseCreateRowOptions {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
bridgeUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new row in the table via the bridge, then invalidate the view
|
||||
* cache so the row appears.
|
||||
*
|
||||
* Server-first (no optimistic insert): the bridge assigns the row id, and the
|
||||
* inline editor PATCHes by id afterwards — fabricating a temporary id here
|
||||
* would desync the first edit. An empty payload creates a blank row (Baserow
|
||||
* fills defaults); callers may pass initial field values.
|
||||
*/
|
||||
export function useCreateRow({ tableId, viewId, bridgeUrl }: UseCreateRowOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const url = resolveBridgeUrl(bridgeUrl);
|
||||
|
||||
return useMutation<BridgeRow, Error, Record<string, unknown> | void>({
|
||||
mutationFn: async (fields) => {
|
||||
const client = getBridgeClient(url);
|
||||
return (await (client.post(
|
||||
`/api/v1/tables/${tableId}/rows`,
|
||||
fields ?? {},
|
||||
) as unknown)) as BridgeRow;
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Reconcile with the server. The bridge also emits an SSE row.created
|
||||
// event which triggers the same invalidation — idempotent.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
|
||||
exact: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { VIEW_DATA_QUERY_KEY } from "./use-view-data";
|
||||
import { resolveBridgeUrl } from "../services/bridge-client";
|
||||
|
||||
/**
|
||||
* SSE consumer for realtime row/view updates from the bridge.
|
||||
*
|
||||
* Connects to `GET /api/events/sse?tables=<tableId>&views=<viewId>` and
|
||||
* listens for events whose type starts with `row.` or `view.`. On match, it
|
||||
* invalidates the React Query cache for the affected view so the table
|
||||
* re-fetches silently.
|
||||
*
|
||||
* SSE auth strategy:
|
||||
* EventSource native does NOT support custom headers. The bridge is configured
|
||||
* to accept the DocAdenice JWT via HttpOnly cookie (R2.3b), so credentials
|
||||
* are sent automatically when the bridge is same-site. If your deployment
|
||||
* serves the bridge on a different domain, either:
|
||||
* - proxy /api/bridge/* through the Docmost Nginx so the cookie is same-site, or
|
||||
* - switch to the `event-source-polyfill` package to inject an Authorization
|
||||
* header (add it as a dep in package.json — R3.1.d decision).
|
||||
* For now we use native EventSource with credentials.
|
||||
*
|
||||
* Reconnection: we implement exponential backoff (1s -> 2s -> 4s -> ... up to
|
||||
* 30s) instead of relying on the browser's built-in reconnect because we want
|
||||
* to filter by tableId/viewId and avoid reconnecting with a stale URL.
|
||||
*/
|
||||
export function useDatabaseRealtimeUpdates(
|
||||
tableId: string | undefined,
|
||||
viewId: string | undefined,
|
||||
bridgeUrl?: string | null,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const retryDelayRef = useRef<number>(1000);
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableId || !viewId) return;
|
||||
|
||||
const url = resolveBridgeUrl(bridgeUrl);
|
||||
// The bridge mounts the SSE router at /api/events (NOT under /api/v1) on
|
||||
// purpose, to keep it out of the v1 mutation rate-limiter. See bridge
|
||||
// src/index.ts: app.route('/api', eventsRouter) + eventsRoutes '/events'.
|
||||
const sseUrl = `${url}/api/events/sse?tables=${encodeURIComponent(tableId)}&views=${encodeURIComponent(viewId)}`;
|
||||
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function connect() {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const es = new EventSource(sseUrl, { withCredentials: true });
|
||||
esRef.current = es;
|
||||
|
||||
es.addEventListener("open", () => {
|
||||
// Reset backoff on successful connection.
|
||||
retryDelayRef.current = 1000;
|
||||
});
|
||||
|
||||
// The bridge emits named events: `row.created`, `row.updated`, `row.deleted`,
|
||||
// `view.updated`, etc. We listen on the generic `message` event as a catch-all
|
||||
// and also register explicit named listeners below.
|
||||
es.addEventListener("message", (evt) => {
|
||||
handleEvent(evt.data);
|
||||
});
|
||||
|
||||
const ROW_VIEW_EVENTS = [
|
||||
"row.created",
|
||||
"row.updated",
|
||||
"row.deleted",
|
||||
"view.updated",
|
||||
];
|
||||
|
||||
for (const eventName of ROW_VIEW_EVENTS) {
|
||||
es.addEventListener(eventName, (evt) => {
|
||||
handleEvent((evt as MessageEvent).data);
|
||||
});
|
||||
}
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// Exponential backoff capped at 30s.
|
||||
const delay = Math.min(retryDelayRef.current, 30_000);
|
||||
retryDelayRef.current = Math.min(retryDelayRef.current * 2, 30_000);
|
||||
|
||||
retryTimeout = setTimeout(() => {
|
||||
if (isMountedRef.current) connect();
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
function handleEvent(rawData: string) {
|
||||
// Filter by tableId/viewId: only invalidate if the event concerns us.
|
||||
// Bridge payload: { event, tableId?, viewId?, rowId?, ... }
|
||||
try {
|
||||
const payload = JSON.parse(rawData) as {
|
||||
tableId?: string;
|
||||
viewId?: string;
|
||||
event?: string;
|
||||
};
|
||||
|
||||
const affectsOurTable =
|
||||
!payload.tableId || payload.tableId === tableId;
|
||||
const affectsOurView = !payload.viewId || payload.viewId === viewId;
|
||||
|
||||
if (affectsOurTable && affectsOurView) {
|
||||
// Invalidate all pages of this view's data.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
|
||||
exact: false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Unparseable SSE data — ignore rather than crash.
|
||||
}
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
if (retryTimeout !== null) clearTimeout(retryTimeout);
|
||||
};
|
||||
}, [tableId, viewId, bridgeUrl, queryClient]);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { listDatabases, type BaserowDatabase } from "../services/admin-client";
|
||||
|
||||
export const DATABASES_QUERY_KEY = ["bridge-admin-databases"] as const;
|
||||
|
||||
export function useDatabases(
|
||||
workspaceId?: number | null,
|
||||
bridgeUrl?: string | null,
|
||||
) {
|
||||
return useQuery<BaserowDatabase[]>({
|
||||
queryKey: [...DATABASES_QUERY_KEY, workspaceId ?? null, bridgeUrl ?? null],
|
||||
queryFn: () => {
|
||||
if (!workspaceId) return Promise.resolve([] as BaserowDatabase[]);
|
||||
return listDatabases(workspaceId, bridgeUrl);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Reads the user's Acadenice permissions from the auth context.
|
||||
*
|
||||
* Why not a server call here:
|
||||
* The permissions are already resolved by R2.3a (`GET /api/v1/permissions/me`)
|
||||
* and cached in the application-level React Query cache. This hook reads from
|
||||
* that cache or falls back to parsing the `acadenice_permissions` claim from any
|
||||
* JS-readable cookie. It does NOT perform a new HTTP request — callers that need
|
||||
* fresh permissions should use the RBAC hook from the rbac feature.
|
||||
*
|
||||
* For the database-view inline editing use case we only need to know whether
|
||||
* the current user can write rows (`database.rows.write`). We resolve this from
|
||||
* the standard Acadenice permission `rows:write`.
|
||||
*/
|
||||
export interface UsePermissionsResult {
|
||||
/** True when the user has the `rows:write` permission. */
|
||||
canWriteRows: boolean;
|
||||
/** True when the user has `admin:*` (covers all permissions). */
|
||||
isAdmin: boolean;
|
||||
/** True when permissions have been resolved (not still loading). */
|
||||
isResolved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads acadenice_permissions from any available JS-accessible source:
|
||||
* 1. The `__acadenice_perms` global injected by the app bootstrap (if present).
|
||||
* 2. A non-HttpOnly cookie `acadenicePerms` (serialised JSON array).
|
||||
* 3. The legacy jotai `authTokens` atom value decoded shallowly.
|
||||
*
|
||||
* Falls back to { canWriteRows: true, isAdmin: false } so that editing is
|
||||
* optimistically enabled — the server will reject the PATCH with 403 if the
|
||||
* user truly lacks the permission, and the UI rolls back (see useUpdateRow).
|
||||
*/
|
||||
export function usePermissions(): UsePermissionsResult {
|
||||
return useMemo(() => {
|
||||
// Try the window-level cache set by the RBAC hook (R2.3a) when the query resolves.
|
||||
// The cache key is `window.__acadenice_perms`.
|
||||
const fromGlobal = (
|
||||
typeof window !== "undefined" &&
|
||||
(window as unknown as Record<string, unknown>)["__acadenice_perms"]
|
||||
);
|
||||
|
||||
if (Array.isArray(fromGlobal)) {
|
||||
return resolveFromPermissions(fromGlobal as string[]);
|
||||
}
|
||||
|
||||
// Try the cookie fallback.
|
||||
const fromCookie = readPermissionsFromCookie();
|
||||
if (fromCookie !== null) {
|
||||
return resolveFromPermissions(fromCookie);
|
||||
}
|
||||
|
||||
// Optimistic default: allow writes (server is the guard).
|
||||
return { canWriteRows: true, isAdmin: false, isResolved: false };
|
||||
}, []);
|
||||
}
|
||||
|
||||
function resolveFromPermissions(permissions: string[]): UsePermissionsResult {
|
||||
const isAdmin = permissions.includes("admin:*");
|
||||
const canWriteRows = isAdmin || permissions.includes("rows:write");
|
||||
return { canWriteRows, isAdmin, isResolved: true };
|
||||
}
|
||||
|
||||
function readPermissionsFromCookie(): string[] | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
try {
|
||||
const raw = document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith("acadenicePerms="));
|
||||
if (!raw) return null;
|
||||
const val = decodeURIComponent(raw.slice("acadenicePerms=".length));
|
||||
const parsed = JSON.parse(val);
|
||||
return Array.isArray(parsed) ? (parsed as string[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { listTables, type BaserowTableSummary } from "../services/admin-client";
|
||||
import type { BridgeTable } from "../types/database-view.types";
|
||||
|
||||
export const TABLES_QUERY_KEY = ["bridge-admin-tables"] as const;
|
||||
|
||||
/**
|
||||
* Fetch tables of a Baserow database via the bridge admin endpoint.
|
||||
*
|
||||
* The legacy `GET /api/v1/tables` requires a Baserow user JWT and a databaseId
|
||||
* filter — neither was wired into the modal — so we list via the admin client
|
||||
* (`GET /api/v1/admin/tables?databaseId=X`) which uses a service-account JWT.
|
||||
*
|
||||
* The query is disabled until a databaseId is provided.
|
||||
*/
|
||||
export function useTables(
|
||||
databaseId?: number | null,
|
||||
bridgeUrl?: string | null,
|
||||
) {
|
||||
return useQuery<BridgeTable[]>({
|
||||
queryKey: [...TABLES_QUERY_KEY, databaseId ?? null, bridgeUrl ?? null],
|
||||
queryFn: async () => {
|
||||
if (!databaseId) return [];
|
||||
const rows = await listTables(databaseId, bridgeUrl);
|
||||
return rows.map(
|
||||
(t: BaserowTableSummary): BridgeTable => ({
|
||||
id: String(t.id),
|
||||
name: t.name,
|
||||
databaseId: t.database_id,
|
||||
}),
|
||||
);
|
||||
},
|
||||
enabled: Boolean(databaseId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
/**
|
||||
* Hook to read and write timeline column-mapping config.
|
||||
*
|
||||
* Config is persisted in bridge Redis (TTL 30d) keyed by viewId.
|
||||
* GET /api/views/:viewId/timeline-config
|
||||
* POST /api/views/:viewId/timeline-config
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
|
||||
|
||||
export interface TimelineConfig {
|
||||
startCol: string;
|
||||
endCol: string | null;
|
||||
resourceCol: string | null;
|
||||
titleCol: string;
|
||||
}
|
||||
|
||||
interface BridgeConfigResponse {
|
||||
data: TimelineConfig | null;
|
||||
}
|
||||
|
||||
export const TIMELINE_CONFIG_QUERY_KEY = "timeline-config";
|
||||
|
||||
export function timelineConfigQueryKey(viewId: string, bridgeUrl: string) {
|
||||
return [TIMELINE_CONFIG_QUERY_KEY, viewId, bridgeUrl] as const;
|
||||
}
|
||||
|
||||
interface UseTimelineConfigOptions {
|
||||
viewId: string;
|
||||
bridgeUrl?: string | null;
|
||||
}
|
||||
|
||||
export function useTimelineConfig({ viewId, bridgeUrl }: UseTimelineConfigOptions) {
|
||||
const url = resolveBridgeUrl(bridgeUrl);
|
||||
const queryClient = useQueryClient();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const query = useQuery<TimelineConfig | null>({
|
||||
queryKey: timelineConfigQueryKey(viewId, url),
|
||||
enabled: Boolean(viewId),
|
||||
queryFn: async () => {
|
||||
const client = getBridgeClient(url);
|
||||
const res = await (client.get(
|
||||
`/api/v1/views/${viewId}/timeline-config`,
|
||||
) as unknown as Promise<BridgeConfigResponse>);
|
||||
return res.data ?? null;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
async function saveConfig(config: TimelineConfig): Promise<void> {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const client = getBridgeClient(url);
|
||||
await client.post(`/api/v1/views/${viewId}/timeline-config`, config);
|
||||
// Optimistically update the cache so the UI switches to timeline immediately.
|
||||
queryClient.setQueryData(timelineConfigQueryKey(viewId, url), config);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
saveConfig,
|
||||
isSaving,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
|
||||
import type { BridgeRow } from "../types/database-view.types";
|
||||
import { VIEW_DATA_QUERY_KEY } from "./use-view-data";
|
||||
|
||||
export interface UpdateRowPayload {
|
||||
/** Partial field values to patch — only changed fields. */
|
||||
fields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateRowContext {
|
||||
/** Previous paginated cache entries for rollback on error. */
|
||||
previousData: Map<string, unknown>;
|
||||
}
|
||||
|
||||
interface UseUpdateRowOptions {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
bridgeUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic PATCH row mutation with optimistic update and rollback on error.
|
||||
*
|
||||
* Why optimistic here instead of server-first:
|
||||
* Inline editing feels sluggish if the UI waits for the network round-trip.
|
||||
* We apply the change immediately, roll back silently on error, and let React
|
||||
* Query reconcile the truth on the next cache invalidation (also triggered by
|
||||
* the SSE event the bridge emits after the write).
|
||||
*
|
||||
* Pattern: React Query v5 `onMutate` snapshot + `onError` rollback + `onSettled`
|
||||
* invalidation.
|
||||
*/
|
||||
export function useUpdateRow({ tableId, viewId, bridgeUrl }: UseUpdateRowOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const url = resolveBridgeUrl(bridgeUrl);
|
||||
|
||||
return useMutation<BridgeRow, Error, { rowId: string; payload: UpdateRowPayload }, UpdateRowContext>({
|
||||
mutationFn: async ({ rowId, payload }) => {
|
||||
const client = getBridgeClient(url);
|
||||
return (await (client.patch(
|
||||
`/api/v1/tables/${tableId}/rows/${rowId}`,
|
||||
payload,
|
||||
) as unknown)) as BridgeRow;
|
||||
},
|
||||
|
||||
onMutate: async ({ rowId, payload }) => {
|
||||
// Cancel in-flight queries to avoid clobbering the optimistic update.
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
// Snapshot all cache entries for this view (all pages).
|
||||
const previousData = new Map<string, unknown>();
|
||||
const cache = queryClient.getQueriesData<{
|
||||
rows: BridgeRow[];
|
||||
fields: unknown[];
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
}>({
|
||||
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
for (const [queryKey, data] of cache) {
|
||||
const keyStr = JSON.stringify(queryKey);
|
||||
previousData.set(keyStr, data);
|
||||
|
||||
if (data) {
|
||||
// Apply optimistic patch — merge fields.
|
||||
const updatedRows = data.rows.map((row) =>
|
||||
row.id === rowId
|
||||
? { ...row, fields: { ...row.fields, ...payload.fields } }
|
||||
: row,
|
||||
);
|
||||
queryClient.setQueryData(queryKey, { ...data, rows: updatedRows });
|
||||
}
|
||||
}
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
|
||||
onError: (_error, _variables, context) => {
|
||||
if (!context) return;
|
||||
|
||||
// Rollback all snapshot entries.
|
||||
const cache = queryClient.getQueriesData<unknown>({
|
||||
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
for (const [queryKey] of cache) {
|
||||
const keyStr = JSON.stringify(queryKey);
|
||||
const previous = context.previousData.get(keyStr);
|
||||
if (previous !== undefined) {
|
||||
queryClient.setQueryData(queryKey, previous);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
// Always invalidate after mutation so the cache reconciles with the server.
|
||||
// The SSE event from the bridge will also trigger this, making it idempotent.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [VIEW_DATA_QUERY_KEY, viewId],
|
||||
exact: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
|
||||
import type {
|
||||
BridgeField,
|
||||
BridgeRow,
|
||||
BridgeViewDataResponse,
|
||||
ViewDataParams,
|
||||
} from "../types/database-view.types";
|
||||
|
||||
export const VIEW_DATA_QUERY_KEY = "view-data";
|
||||
|
||||
export const viewDataQueryKey = (
|
||||
viewId: string,
|
||||
page: number,
|
||||
size: number,
|
||||
bridgeUrl: string,
|
||||
) => [VIEW_DATA_QUERY_KEY, viewId, page, size, bridgeUrl] as const;
|
||||
|
||||
export interface UseViewDataResult {
|
||||
rows: BridgeRow[];
|
||||
fields: BridgeField[];
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches paginated rows + field schema for a given view.
|
||||
*
|
||||
* The bridge endpoint `GET /api/v1/views/:viewId/data` returns both the rows
|
||||
* and the column definitions needed to build the table header.
|
||||
*
|
||||
* We keep fields + rows separate so the renderer can build TanStack Table
|
||||
* column definitions from `fields` and row data from `rows`.
|
||||
*/
|
||||
export function useViewData({
|
||||
viewId,
|
||||
tableId,
|
||||
bridgeUrl,
|
||||
page = 1,
|
||||
size = 50,
|
||||
}: ViewDataParams) {
|
||||
const url = resolveBridgeUrl(bridgeUrl);
|
||||
|
||||
return useQuery<UseViewDataResult>({
|
||||
queryKey: viewDataQueryKey(viewId, page, size, url),
|
||||
enabled: Boolean(viewId) && Boolean(tableId),
|
||||
queryFn: async () => {
|
||||
const client = getBridgeClient(url);
|
||||
// tableId is mandatory: the bridge route GET /views/:viewId/data returns
|
||||
// 400 "tableId query param required" without it.
|
||||
const res = await (client.get(`/api/v1/views/${viewId}/data`, {
|
||||
params: { page, size, tableId },
|
||||
}) as unknown as Promise<BridgeViewDataResponse>);
|
||||
|
||||
// Normalise: bridge may return top-level array or wrapped envelope.
|
||||
const rows: BridgeRow[] = Array.isArray(res)
|
||||
? (res as unknown as BridgeRow[])
|
||||
: res.data ?? [];
|
||||
|
||||
// Field definitions come from the meta block or are inferred from rows.
|
||||
// The bridge embeds fields in the response when the table has fields registered.
|
||||
const fields: BridgeField[] =
|
||||
(res as unknown as { fields?: BridgeField[] }).fields ??
|
||||
deriveFieldsFromRows(rows);
|
||||
|
||||
const total: number = res.total ?? rows.length;
|
||||
const hasNextPage: boolean =
|
||||
res.meta?.hasNextPage ?? rows.length === size;
|
||||
|
||||
return { rows, fields, total, hasNextPage };
|
||||
},
|
||||
staleTime: 10_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When the bridge does not return explicit field metadata, infer column names
|
||||
* from the union of all keys present across all row.fields objects.
|
||||
* This is a degraded fallback — the bridge should always return fields.
|
||||
*/
|
||||
function deriveFieldsFromRows(rows: BridgeRow[]): BridgeField[] {
|
||||
const keySet = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const key of Object.keys(row.fields ?? {})) {
|
||||
keySet.add(key);
|
||||
}
|
||||
}
|
||||
return Array.from(keySet).map((k, i) => ({
|
||||
id: k,
|
||||
name: k,
|
||||
type: "text",
|
||||
primary: i === 0,
|
||||
options: null,
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
|
||||
import type { BridgeView } from "../types/database-view.types";
|
||||
|
||||
export const viewsQueryKey = (tableId: string, bridgeUrl: string) =>
|
||||
["bridge-views", tableId, bridgeUrl] as const;
|
||||
|
||||
/**
|
||||
* Fetches the list of views for a given table.
|
||||
* Used in the insert-database-modal step 2.
|
||||
*/
|
||||
export function useViews(
|
||||
tableId: string | null | undefined,
|
||||
bridgeUrl?: string | null,
|
||||
) {
|
||||
const url = resolveBridgeUrl(bridgeUrl);
|
||||
|
||||
return useQuery<BridgeView[]>({
|
||||
queryKey: viewsQueryKey(tableId ?? "", url),
|
||||
enabled: Boolean(tableId),
|
||||
queryFn: async () => {
|
||||
const client = getBridgeClient(url);
|
||||
const res = await (client.get(
|
||||
`/api/v1/views/table/${tableId}`,
|
||||
) as unknown as Promise<{ data: BridgeView[] } | BridgeView[]>);
|
||||
return Array.isArray(res) ? res : (res as { data: BridgeView[] }).data ?? [];
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { listWorkspaces, type BaserowWorkspace } from "../services/admin-client";
|
||||
|
||||
export const WORKSPACES_QUERY_KEY = ["bridge-admin-workspaces"] as const;
|
||||
|
||||
export function useWorkspaces(bridgeUrl?: string | null) {
|
||||
return useQuery<BaserowWorkspace[]>({
|
||||
queryKey: [...WORKSPACES_QUERY_KEY, bridgeUrl ?? null],
|
||||
queryFn: () => listWorkspaces(bridgeUrl),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
/**
|
||||
* Public exports for the database-view feature (R3.1.c).
|
||||
*
|
||||
* Import DatabaseViewExtension in the editor extensions array.
|
||||
* Import buildDatabaseSlashItem to register the slash command.
|
||||
*/
|
||||
export { default as DatabaseViewExtension } from "./extension/database-view-extension";
|
||||
export { buildDatabaseSlashItem } from "./slash-command/database-slash-command";
|
||||
export { buildCreateDatabaseSlashItem } from "./slash-command/create-database-slash";
|
||||
export { useDatabaseRealtimeUpdates } from "./hooks/use-database-realtime-updates";
|
||||
export type { DatabaseViewAttrs, ViewType } from "./types/database-view.types";
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.calendarWrapper {
|
||||
/* FullCalendar needs a defined context for layout. */
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
/* Mantine-compatible token overrides for FullCalendar's default theme. */
|
||||
.calendarWrapper :global(.fc-toolbar-title) {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendarWrapper :global(.fc-button) {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
color: var(--mantine-color-gray-8);
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.calendarWrapper :global(.fc-button:hover) {
|
||||
background-color: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
.calendarWrapper :global(.fc-button-primary:not(:disabled):active),
|
||||
.calendarWrapper :global(.fc-button-primary:not(:disabled).fc-button-active) {
|
||||
background-color: var(--mantine-primary-color-filled);
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
color: white;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .calendarWrapper :global(.fc-button) {
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
color: var(--mantine-color-dark-0);
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .calendarWrapper :global(.fc-button:hover) {
|
||||
background-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
.calendarEvent {
|
||||
cursor: pointer;
|
||||
border-radius: var(--mantine-radius-xs) !important;
|
||||
font-size: var(--mantine-font-size-xs) !important;
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
/**
|
||||
* DEPENDENCIES NEEDED (not installed — convention fork):
|
||||
* @fullcalendar/react@^6
|
||||
* @fullcalendar/daygrid@^6
|
||||
* @fullcalendar/timegrid@^6
|
||||
* @fullcalendar/interaction@^6
|
||||
*
|
||||
* Rationale for FullCalendar over react-big-calendar:
|
||||
* - More mature accessibility (ARIA roles, keyboard nav)
|
||||
* - Better drag-drop support via @fullcalendar/interaction
|
||||
* - Built-in month/week/day views with consistent API
|
||||
* - Active maintenance and large community
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Text, Stack, Skeleton, Alert, Button, SegmentedControl } from "@mantine/core";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
// FullCalendar imports — these will resolve once the deps are installed.
|
||||
import FullCalendar from "@fullcalendar/react";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import { EventClickArg, EventDropArg } from "@fullcalendar/core";
|
||||
import { useViewData } from "../hooks/use-view-data";
|
||||
import { useUpdateRow } from "../hooks/use-update-row";
|
||||
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
|
||||
import { usePermissions } from "../hooks/use-permissions";
|
||||
import { RowDetailModal } from "../components/row-detail-modal";
|
||||
import type { BridgeRow, BridgeField } from "../types/database-view.types";
|
||||
import styles from "./calendar-renderer.module.css";
|
||||
|
||||
const PAGE_SIZE = 500;
|
||||
|
||||
type CalendarView = "dayGridMonth" | "timeGridWeek" | "timeGridDay";
|
||||
|
||||
interface CalendarRendererProps {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
bridgeUrl?: string | null;
|
||||
}
|
||||
|
||||
/** Resolve the date field used to position events on the calendar. */
|
||||
function resolveDateField(
|
||||
fields: BridgeField[],
|
||||
viewMeta?: { dateFieldId?: string },
|
||||
): BridgeField | undefined {
|
||||
if (viewMeta?.dateFieldId) {
|
||||
return fields.find((f) => f.id === viewMeta.dateFieldId);
|
||||
}
|
||||
return fields.find((f) => f.type === "date" || f.type === "created_on");
|
||||
}
|
||||
|
||||
/** Convert a row to a FullCalendar EventInput. Returns null when no date. */
|
||||
function rowToEvent(
|
||||
row: BridgeRow,
|
||||
dateField: BridgeField,
|
||||
primaryField?: BridgeField,
|
||||
): { id: string; title: string; start: string; extendedProps: { row: BridgeRow; fields: BridgeField[] } } | null {
|
||||
const rawDate = row.fields[dateField.name] ?? row.fields[dateField.id];
|
||||
if (!rawDate) return null;
|
||||
|
||||
const dateStr = typeof rawDate === "string" ? rawDate : String(rawDate);
|
||||
// Validate the date string.
|
||||
if (isNaN(new Date(dateStr).getTime())) return null;
|
||||
|
||||
const primaryValue = primaryField
|
||||
? (row.fields[primaryField.name] ?? row.fields[primaryField.id])
|
||||
: row.id;
|
||||
|
||||
const title =
|
||||
typeof primaryValue === "string"
|
||||
? primaryValue
|
||||
: typeof primaryValue === "number"
|
||||
? String(primaryValue)
|
||||
: row.id;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: title || row.id,
|
||||
start: dateStr,
|
||||
extendedProps: { row, fields: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function CalendarSkeleton() {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Skeleton height={32} width={200} />
|
||||
<Skeleton height={400} radius="sm" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarRenderer({ tableId, viewId, bridgeUrl }: CalendarRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
const [calView, setCalView] = useState<CalendarView>("dayGridMonth");
|
||||
const [selectedRow, setSelectedRow] = useState<BridgeRow | null>(null);
|
||||
const [selectedFields, setSelectedFields] = useState<BridgeField[]>([]);
|
||||
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useViewData({
|
||||
viewId,
|
||||
tableId,
|
||||
bridgeUrl,
|
||||
page: 1,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const { canWriteRows } = usePermissions();
|
||||
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
|
||||
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
|
||||
|
||||
if (isLoading) return <CalendarSkeleton />;
|
||||
|
||||
if (isError) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status;
|
||||
let message = t("database_view.error.generic");
|
||||
if (status === 403) message = t("database_view.error.permission_denied");
|
||||
else if (status === 404) message = t("database_view.error.view_not_found");
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
color="red"
|
||||
title={t("database_view.error.title")}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{message}</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetch()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { rows, fields } = data ?? { rows: [], fields: [] };
|
||||
const dateField = resolveDateField(fields);
|
||||
|
||||
if (!dateField) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="yellow">
|
||||
<Text size="sm">{t("database_view.calendar.no_date_field")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryField = fields.find((f) => f.primary) ?? fields[0];
|
||||
|
||||
const events = rows
|
||||
.map((row) => rowToEvent(row, dateField, primaryField))
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null)
|
||||
.map((e) => ({ ...e, extendedProps: { ...e.extendedProps, fields } }));
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const row = arg.event.extendedProps.row as BridgeRow;
|
||||
const eventFields = arg.event.extendedProps.fields as BridgeField[];
|
||||
setSelectedRow(row);
|
||||
setSelectedFields(eventFields);
|
||||
openModal();
|
||||
}
|
||||
|
||||
function handleEventDrop(arg: EventDropArg) {
|
||||
if (!canWriteRows) {
|
||||
arg.revert();
|
||||
return;
|
||||
}
|
||||
|
||||
const rowId = arg.event.id;
|
||||
const newStart = arg.event.start;
|
||||
if (!newStart) {
|
||||
arg.revert();
|
||||
return;
|
||||
}
|
||||
|
||||
updateRow.mutate(
|
||||
{
|
||||
rowId,
|
||||
payload: {
|
||||
fields: {
|
||||
[dateField.name]: newStart.toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
arg.revert();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const viewOptions = [
|
||||
{ label: t("database_view.calendar.view_month"), value: "dayGridMonth" },
|
||||
{ label: t("database_view.calendar.view_week"), value: "timeGridWeek" },
|
||||
{ label: t("database_view.calendar.view_day"), value: "timeGridDay" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div data-testid="calendar-renderer">
|
||||
<div className={styles.toolbar}>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={calView}
|
||||
onChange={(v) => setCalView(v as CalendarView)}
|
||||
data={viewOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.calendarWrapper}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView={calView}
|
||||
key={calView}
|
||||
events={events}
|
||||
editable={canWriteRows}
|
||||
droppable={canWriteRows}
|
||||
eventClick={handleEventClick}
|
||||
eventDrop={handleEventDrop}
|
||||
headerToolbar={{
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "",
|
||||
}}
|
||||
height="auto"
|
||||
eventClassNames={() => [styles.calendarEvent]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RowDetailModal
|
||||
row={selectedRow}
|
||||
fields={selectedFields}
|
||||
opened={modalOpened}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
.board {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
align-items: flex-start;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 0 0 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 1px solid var(--mantine-color-gray-2);
|
||||
overflow: hidden;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .column {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
border-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--mantine-color-gray-2);
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .columnHeader {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
border-bottom-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.columnBody {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.emptyColumn {
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
border: 2px dashed var(--mantine-color-gray-3);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .emptyColumn {
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
.cardWrapper {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cardTitle:hover {
|
||||
text-decoration: underline;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.dragOverlayCard {
|
||||
cursor: grabbing;
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
|
@ -1,431 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Text,
|
||||
Card,
|
||||
Stack,
|
||||
Badge,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Button,
|
||||
Tooltip,
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { IconAlertCircle, IconLock } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewData } from "../hooks/use-view-data";
|
||||
import { useUpdateRow } from "../hooks/use-update-row";
|
||||
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
|
||||
import { usePermissions } from "../hooks/use-permissions";
|
||||
import { InlineEditor } from "../components/inline-editor";
|
||||
import type { BridgeRow, BridgeField } from "../types/database-view.types";
|
||||
import styles from "./kanban-renderer.module.css";
|
||||
|
||||
/**
|
||||
* DEPENDENCIES NEEDED (not installed — convention fork):
|
||||
* @dnd-kit/core@^6
|
||||
* @dnd-kit/sortable@^8
|
||||
* @dnd-kit/utilities@^3
|
||||
*
|
||||
* DEPENDENCY NEEDED (already listed in R3.1.c):
|
||||
* @tanstack/react-table@^8
|
||||
*/
|
||||
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
interface KanbanRendererProps {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
bridgeUrl?: string | null;
|
||||
}
|
||||
|
||||
interface Column {
|
||||
id: string;
|
||||
label: string;
|
||||
rows: BridgeRow[];
|
||||
}
|
||||
|
||||
/** Resolve the groupBy field: use singleSelectFieldId from view meta, or first single_select field. */
|
||||
function resolveGroupByField(
|
||||
fields: BridgeField[],
|
||||
viewMeta?: { singleSelectFieldId?: string },
|
||||
): BridgeField | undefined {
|
||||
if (viewMeta?.singleSelectFieldId) {
|
||||
return fields.find((f) => f.id === viewMeta.singleSelectFieldId);
|
||||
}
|
||||
return fields.find((f) => f.type === "single_select");
|
||||
}
|
||||
|
||||
/** Extract column value from a row for a given field. */
|
||||
function getColumnValue(row: BridgeRow, field: BridgeField): string {
|
||||
const raw = row.fields[field.name] ?? row.fields[field.id];
|
||||
if (!raw) return "";
|
||||
if (typeof raw === "object" && raw !== null) {
|
||||
const v = raw as { value?: string; id?: string | number };
|
||||
return v.value ?? String(v.id ?? "");
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
/** Build column definitions from the set of unique field values across all rows. */
|
||||
function buildColumns(rows: BridgeRow[], groupByField: BridgeField): Column[] {
|
||||
// Collect options from field.options (Baserow single_select has select_options).
|
||||
const opts = groupByField.options as {
|
||||
select_options?: { id: number | string; value: string }[];
|
||||
} | null | undefined;
|
||||
|
||||
const definedOptions: { id: string; label: string }[] = (
|
||||
opts?.select_options ?? []
|
||||
).map((o) => ({ id: String(o.id), label: o.value }));
|
||||
|
||||
// Group rows by column value.
|
||||
const columnMap = new Map<string, BridgeRow[]>();
|
||||
|
||||
// Ensure defined options are always present (even if empty).
|
||||
for (const opt of definedOptions) {
|
||||
columnMap.set(opt.label, []);
|
||||
}
|
||||
columnMap.set("", []); // unassigned column
|
||||
|
||||
for (const row of rows) {
|
||||
const colLabel = getColumnValue(row, groupByField);
|
||||
if (!columnMap.has(colLabel)) {
|
||||
columnMap.set(colLabel, []);
|
||||
}
|
||||
columnMap.get(colLabel)!.push(row);
|
||||
}
|
||||
|
||||
// Remove empty unassigned column if not needed.
|
||||
const unassigned = columnMap.get("") ?? [];
|
||||
if (unassigned.length === 0) {
|
||||
columnMap.delete("");
|
||||
}
|
||||
|
||||
return Array.from(columnMap.entries()).map(([label, colRows]) => ({
|
||||
id: label || "__unassigned__",
|
||||
label: label || "Unassigned",
|
||||
rows: colRows,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Card components ---
|
||||
|
||||
interface KanbanCardProps {
|
||||
row: BridgeRow;
|
||||
primaryField: BridgeField | undefined;
|
||||
canWrite: boolean;
|
||||
onRename: (row: BridgeRow, newTitle: string) => void;
|
||||
}
|
||||
|
||||
function KanbanCard({ row, primaryField, canWrite, onRename }: KanbanCardProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: row.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
};
|
||||
|
||||
const primaryValue = primaryField
|
||||
? (row.fields[primaryField.name] ?? row.fields[primaryField.id])
|
||||
: row.id;
|
||||
|
||||
const title =
|
||||
typeof primaryValue === "string"
|
||||
? primaryValue
|
||||
: typeof primaryValue === "number"
|
||||
? String(primaryValue)
|
||||
: row.id;
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (canWrite && primaryField) {
|
||||
setEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={styles.cardWrapper}
|
||||
data-testid={`kanban-card-${row.id}`}
|
||||
>
|
||||
<Card
|
||||
shadow="xs"
|
||||
padding="xs"
|
||||
radius="sm"
|
||||
className={styles.card}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{editing && primaryField ? (
|
||||
<InlineEditor
|
||||
field={primaryField}
|
||||
initialValue={title}
|
||||
canWrite={canWrite}
|
||||
onSave={(v) => {
|
||||
setEditing(false);
|
||||
onRename(row, String(v ?? ""));
|
||||
}}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
size="sm"
|
||||
className={styles.cardTitle}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
title={canWrite ? undefined : "read-only"}
|
||||
>
|
||||
{title || row.id}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: Column;
|
||||
primaryField: BridgeField | undefined;
|
||||
canWrite: boolean;
|
||||
onCardRename: (row: BridgeRow, newTitle: string) => void;
|
||||
}
|
||||
|
||||
function KanbanColumn({ column, primaryField, canWrite, onCardRename }: KanbanColumnProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.column}
|
||||
data-testid={`kanban-column-${column.label}`}
|
||||
>
|
||||
<div className={styles.columnHeader}>
|
||||
<Text size="sm" fw={600} className={styles.columnTitle}>
|
||||
{column.label}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{column.rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className={styles.columnBody}>
|
||||
<SortableContext
|
||||
items={column.rows.map((r) => r.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{column.rows.length === 0 ? (
|
||||
<div className={styles.emptyColumn}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("database_view.kanban.empty_column")}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
column.rows.map((row) => (
|
||||
<KanbanCard
|
||||
key={row.id}
|
||||
row={row}
|
||||
primaryField={primaryField}
|
||||
canWrite={canWrite}
|
||||
onRename={onCardRename}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Skeleton ---
|
||||
|
||||
function KanbanSkeleton() {
|
||||
return (
|
||||
<div className={styles.board}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className={styles.column}>
|
||||
<Skeleton height={24} mb="sm" />
|
||||
<Stack gap="xs">
|
||||
<Skeleton height={56} radius="sm" />
|
||||
<Skeleton height={56} radius="sm" />
|
||||
<Skeleton height={56} radius="sm" />
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function KanbanRenderer({ tableId, viewId, bridgeUrl }: KanbanRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useViewData({
|
||||
viewId,
|
||||
tableId,
|
||||
bridgeUrl,
|
||||
page: 1,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const { canWriteRows } = usePermissions();
|
||||
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
|
||||
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
// Require 5px movement before drag starts — prevents accidental drags on click.
|
||||
distance: 5,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (isLoading) return <KanbanSkeleton />;
|
||||
|
||||
if (isError) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status;
|
||||
let message = t("database_view.error.generic");
|
||||
if (status === 403) message = t("database_view.error.permission_denied");
|
||||
else if (status === 404) message = t("database_view.error.view_not_found");
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
color="red"
|
||||
title={t("database_view.error.title")}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{message}</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetch()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { rows, fields } = data ?? { rows: [], fields: [] };
|
||||
|
||||
const groupByField = resolveGroupByField(fields);
|
||||
|
||||
if (!groupByField) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="yellow">
|
||||
<Text size="sm">{t("database_view.kanban.no_groupby_field")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryField = fields.find((f) => f.primary) ?? fields[0];
|
||||
const columns = buildColumns(rows, groupByField);
|
||||
|
||||
const activeRow = activeId ? rows.find((r) => r.id === activeId) : null;
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveId(String(event.active.id));
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
// Find which column the card was dropped into.
|
||||
// over.id could be a row id (dropped over another row) or a column id.
|
||||
const overRowId = String(over.id);
|
||||
|
||||
// Find the column the target row belongs to.
|
||||
const targetColumn = columns.find(
|
||||
(col) =>
|
||||
col.id === overRowId || col.rows.some((r) => r.id === overRowId),
|
||||
);
|
||||
|
||||
if (!targetColumn) return;
|
||||
|
||||
const newColumnLabel = targetColumn.id === "__unassigned__" ? "" : targetColumn.label;
|
||||
|
||||
// Build the patch payload: set the single_select field to the new column.
|
||||
updateRow.mutate({
|
||||
rowId: String(active.id),
|
||||
payload: {
|
||||
fields: {
|
||||
[groupByField.name]: newColumnLabel || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleCardRename(row: BridgeRow, newTitle: string) {
|
||||
if (!primaryField || !canWriteRows) return;
|
||||
updateRow.mutate({
|
||||
rowId: row.id,
|
||||
payload: { fields: { [primaryField.name]: newTitle } },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!canWriteRows && (
|
||||
<Group gap="xs" mb="xs">
|
||||
<IconLock size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("database_view.edit.read_only_mode")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className={styles.board} data-testid="kanban-board">
|
||||
{columns.map((col) => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
column={col}
|
||||
primaryField={primaryField}
|
||||
canWrite={canWriteRows}
|
||||
onCardRename={handleCardRename}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeRow && (
|
||||
<Card shadow="md" padding="xs" radius="sm" className={styles.dragOverlayCard}>
|
||||
<Text size="sm">
|
||||
{String(
|
||||
(primaryField
|
||||
? activeRow.fields[primaryField.name] ?? activeRow.fields[primaryField.id]
|
||||
: activeRow.id) ?? activeRow.id,
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { Text, Stack, ThemeIcon } from "@mantine/core";
|
||||
import { IconTableOff } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ViewType } from "../types/database-view.types";
|
||||
|
||||
interface PlaceholderRendererProps {
|
||||
viewType: ViewType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displayed for viewType values not yet implemented (kanban, calendar).
|
||||
* These renderers arrive in R3.1.d.
|
||||
*/
|
||||
export function PlaceholderRenderer({ viewType }: PlaceholderRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack align="center" py="xl" gap="xs">
|
||||
<ThemeIcon variant="light" color="gray" size="lg">
|
||||
<IconTableOff size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("database_view.placeholder.not_supported", { viewType })}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
.wrapper {
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.th {
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
border-bottom: 1px solid var(--mantine-color-gray-3);
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .th {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
border-bottom-color: var(--mantine-color-dark-4);
|
||||
color: var(--mantine-color-dark-1);
|
||||
}
|
||||
|
||||
.tr {
|
||||
border-bottom: 1px solid var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .tr {
|
||||
border-bottom-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .tr:hover {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
.td {
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 300px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--mantine-color-gray-2);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-gray-6);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .pagination {
|
||||
border-top-color: var(--mantine-color-dark-5);
|
||||
color: var(--mantine-color-dark-2);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Alert,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconPlus,
|
||||
IconDots,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useViewData } from "../hooks/use-view-data";
|
||||
import { useUpdateRow } from "../hooks/use-update-row";
|
||||
import { useCreateRow } from "../hooks/use-create-row";
|
||||
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
|
||||
import { usePermissions } from "../hooks/use-permissions";
|
||||
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
|
||||
import { InlineEditor } from "../components/inline-editor";
|
||||
import { FieldAdminModal } from "../components/field-admin-modal";
|
||||
import { deleteField } from "../services/admin-client";
|
||||
import type { BridgeField, BridgeRow } from "../types/database-view.types";
|
||||
import styles from "./table-renderer.module.css";
|
||||
|
||||
/**
|
||||
* NOTE: This renderer uses plain HTML table elements.
|
||||
*
|
||||
* TanStack Table v8 (@tanstack/react-table) is NOT yet installed in
|
||||
* apps/client/package.json. The headless table logic here is a faithful
|
||||
* placeholder that mirrors the TanStack Table v8 mental model (columns derived
|
||||
* from BridgeField[], rows from BridgeRow[]) so migration is a drop-in.
|
||||
*
|
||||
* To migrate: install @tanstack/react-table@^8, then replace the manual
|
||||
* column/row loops with useReactTable() + flexRender(). The data shape
|
||||
* (fields + rows) is already correct.
|
||||
*
|
||||
* DEPENDENCY NEEDED: @tanstack/react-table@^8
|
||||
*/
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface TableRendererProps {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
bridgeUrl?: string | null;
|
||||
}
|
||||
|
||||
interface EditingCell {
|
||||
rowId: string;
|
||||
fieldId: string;
|
||||
}
|
||||
|
||||
/** Display format for a raw cell value — keeps the table readable without edit. */
|
||||
function formatCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "object") {
|
||||
// Arrays of strings/objects (select, link fields in Baserow)
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((v) => (typeof v === "object" && v !== null ? (v as { value?: string }).value ?? JSON.stringify(v) : String(v)))
|
||||
.join(", ");
|
||||
}
|
||||
// Objects (file fields, etc.)
|
||||
return (value as { value?: string }).value ?? JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Loading skeleton mimicking the table layout. */
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<div className={styles.skeleton}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className={styles.skeletonRow}>
|
||||
<Skeleton height={18} width="20%" />
|
||||
<Skeleton height={18} width="30%" />
|
||||
<Skeleton height={18} width="25%" />
|
||||
<Skeleton height={18} width="15%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableBodyProps {
|
||||
fields: BridgeField[];
|
||||
rows: BridgeRow[];
|
||||
editingCell: EditingCell | null;
|
||||
canWrite: boolean;
|
||||
onCellDoubleClick: (rowId: string, fieldId: string) => void;
|
||||
onCellSave: (rowId: string, field: BridgeField, value: unknown) => void;
|
||||
onCellCancel: () => void;
|
||||
}
|
||||
|
||||
function TableBody({
|
||||
fields,
|
||||
rows,
|
||||
editingCell,
|
||||
canWrite,
|
||||
onCellDoubleClick,
|
||||
onCellSave,
|
||||
onCellCancel,
|
||||
}: TableBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={fields.length} className={styles.emptyState}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("database_view.table.empty_state")}
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className={styles.tr}>
|
||||
{fields.map((field) => {
|
||||
const isEditing =
|
||||
editingCell?.rowId === row.id && editingCell?.fieldId === field.id;
|
||||
const cellValue = row.fields[field.name] ?? row.fields[field.id];
|
||||
|
||||
return (
|
||||
<td
|
||||
key={field.id}
|
||||
className={styles.td}
|
||||
data-testid={`cell-${row.id}-${field.name}`}
|
||||
onDoubleClick={() => {
|
||||
if (!isEditing) {
|
||||
onCellDoubleClick(row.id, field.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<InlineEditor
|
||||
field={field}
|
||||
initialValue={cellValue}
|
||||
canWrite={canWrite}
|
||||
onSave={(v) => onCellSave(row.id, field, v)}
|
||||
onCancel={onCellCancel}
|
||||
/>
|
||||
) : (
|
||||
formatCellValue(cellValue)
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(1);
|
||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useViewData({
|
||||
viewId,
|
||||
tableId,
|
||||
bridgeUrl,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const { canWriteRows } = usePermissions();
|
||||
// Acadenice OSS: tout user (role Member par defaut) a tables:write.
|
||||
// Le gate reste pour les roles custom restrictifs.
|
||||
const acadenicePerms = useAcadenicePermissions();
|
||||
const canAdminTables = acadenicePerms.hasPermission("tables:write");
|
||||
const queryClient = useQueryClient();
|
||||
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
|
||||
const createRow = useCreateRow({ tableId, viewId, bridgeUrl });
|
||||
|
||||
// Field admin modal state.
|
||||
const [fieldModalOpen, setFieldModalOpen] = useState(false);
|
||||
const [editingField, setEditingField] = useState<BridgeField | null>(null);
|
||||
|
||||
function openCreateField() {
|
||||
setEditingField(null);
|
||||
setFieldModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditField(field: BridgeField) {
|
||||
setEditingField(field);
|
||||
setFieldModalOpen(true);
|
||||
}
|
||||
|
||||
function refreshAfterFieldChange() {
|
||||
// Invalidate the view-data cache so the table re-renders with new fields.
|
||||
queryClient.invalidateQueries({ queryKey: ["database-view", viewId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["database-view"] });
|
||||
}
|
||||
|
||||
function confirmDeleteField(field: BridgeField) {
|
||||
modals.openConfirmModal({
|
||||
title: "Supprimer la colonne",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
La colonne <strong>{field.name}</strong> et toutes ses valeurs seront
|
||||
définitivement supprimées. Action irréversible.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: "Supprimer", cancel: "Annuler" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteField(Number(field.id), bridgeUrl);
|
||||
refreshAfterFieldChange();
|
||||
} catch (e) {
|
||||
// best-effort surface — invalidate to refresh UI
|
||||
refreshAfterFieldChange();
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("delete field failed", e);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to SSE updates — invalidates React Query cache on row/view events.
|
||||
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
const axiosError = error as { response?: { status?: number } };
|
||||
const status = axiosError?.response?.status;
|
||||
|
||||
let message = t("database_view.error.generic");
|
||||
if (status === 403) message = t("database_view.error.permission_denied");
|
||||
else if (status === 404) message = t("database_view.error.view_not_found");
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
color="red"
|
||||
title={t("database_view.error.title")}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{message}</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetch()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { rows, fields, total, hasNextPage } = data ?? {
|
||||
rows: [],
|
||||
fields: [],
|
||||
total: 0,
|
||||
hasNextPage: false,
|
||||
};
|
||||
|
||||
function handleCellSave(rowId: string, field: BridgeField, value: unknown) {
|
||||
setEditingCell(null);
|
||||
updateRow.mutate({
|
||||
rowId,
|
||||
payload: { fields: { [field.name]: value } },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.wrapper}>
|
||||
<table className={styles.table} data-testid="table-renderer">
|
||||
<thead>
|
||||
<tr>
|
||||
{fields.map((field) => (
|
||||
<th key={field.id} className={styles.th}>
|
||||
<Group gap={4} wrap="nowrap" justify="space-between">
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{field.name}
|
||||
</Text>
|
||||
{canAdminTables && !field.primary && (
|
||||
<Menu shadow="md" width={180} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
aria-label={`Options ${field.name}`}
|
||||
>
|
||||
<IconDots size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={14} />}
|
||||
onClick={() => openEditField(field)}
|
||||
>
|
||||
Modifier
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconTrash size={14} />}
|
||||
color="red"
|
||||
onClick={() => confirmDeleteField(field)}
|
||||
>
|
||||
Supprimer
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</th>
|
||||
))}
|
||||
{canAdminTables && (
|
||||
<th className={styles.th} style={{ width: 32 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={openCreateField}
|
||||
aria-label="Ajouter une colonne"
|
||||
title="Ajouter une colonne"
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</ActionIcon>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<TableBody
|
||||
fields={fields}
|
||||
rows={rows}
|
||||
editingCell={editingCell}
|
||||
canWrite={canWriteRows}
|
||||
onCellDoubleClick={(rowId, fieldId) =>
|
||||
setEditingCell({ rowId, fieldId })
|
||||
}
|
||||
onCellSave={handleCellSave}
|
||||
onCellCancel={() => setEditingCell(null)}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{canWriteRows && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => createRow.mutate()}
|
||||
loading={createRow.isPending}
|
||||
mt="xs"
|
||||
>
|
||||
{t("database_view.table.add_row", "Ajouter une ligne")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Pagination — only shown when there is more than one page. */}
|
||||
{(page > 1 || hasNextPage) && (
|
||||
<div className={styles.pagination}>
|
||||
<Text size="xs">
|
||||
{t("database_view.table.page_info", {
|
||||
page,
|
||||
total,
|
||||
size: PAGE_SIZE,
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={page === 1}
|
||||
leftSection={<IconChevronLeft size={14} />}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("database_view.table.prev")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={!hasNextPage}
|
||||
rightSection={<IconChevronRight size={14} />}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("database_view.table.next")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
.timelineWrapper {
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.timelineWrapper :global(.fc-toolbar-title) {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timelineWrapper :global(.fc-button) {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
color: var(--mantine-color-gray-8);
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.timelineWrapper :global(.fc-button:hover) {
|
||||
background-color: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
.timelineWrapper :global(.fc-button-primary:not(:disabled):active),
|
||||
.timelineWrapper :global(.fc-button-primary:not(:disabled).fc-button-active) {
|
||||
background-color: var(--mantine-primary-color-filled);
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
color: white;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .timelineWrapper :global(.fc-button) {
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
color: var(--mantine-color-dark-0);
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .timelineWrapper :global(.fc-button:hover) {
|
||||
background-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
.timelineEvent {
|
||||
cursor: pointer;
|
||||
border-radius: var(--mantine-radius-xs) !important;
|
||||
font-size: var(--mantine-font-size-xs) !important;
|
||||
}
|
||||
|
||||
/* Resource lane styling */
|
||||
.timelineWrapper :global(.fc-resource-timeline-divider) {
|
||||
width: 3px;
|
||||
}
|
||||
|
|
@ -1,458 +0,0 @@
|
|||
/**
|
||||
* TimelineRenderer — Gantt-style timeline view using FullCalendar Timeline plugin.
|
||||
*
|
||||
* Column mapping (resolved from user config stored in bridge Redis):
|
||||
* - titleCol : required — row field used as the event label
|
||||
* - startCol : required — ISO date field for event start
|
||||
* - endCol : optional — ISO date field for event end (fallback: start + 1 day)
|
||||
* - resourceCol : optional — field value used as swimlane resource id/title
|
||||
*
|
||||
* When no config exists the user is prompted to configure via the inline
|
||||
* TimelineConfigPanel before the calendar renders.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Text,
|
||||
Stack,
|
||||
Skeleton,
|
||||
Alert,
|
||||
Button,
|
||||
Select,
|
||||
Group,
|
||||
Paper,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertCircle, IconSettings } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import FullCalendar from "@fullcalendar/react";
|
||||
import timelinePlugin from "@fullcalendar/timeline";
|
||||
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import type { EventClickArg } from "@fullcalendar/core";
|
||||
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||
import { useViewData } from "../hooks/use-view-data";
|
||||
import { useUpdateRow } from "../hooks/use-update-row";
|
||||
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
|
||||
import { usePermissions } from "../hooks/use-permissions";
|
||||
import { useTimelineConfig } from "../hooks/use-timeline-config";
|
||||
import { RowDetailModal } from "../components/row-detail-modal";
|
||||
import type { BridgeRow, BridgeField } from "../types/database-view.types";
|
||||
import styles from "./timeline-renderer.module.css";
|
||||
|
||||
const PAGE_SIZE = 500;
|
||||
|
||||
interface TimelineRendererProps {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
bridgeUrl?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseDateSafe(raw: unknown): Date | null {
|
||||
if (!raw) return null;
|
||||
const str = typeof raw === "string" ? raw : String(raw);
|
||||
const d = new Date(str);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function addOneDay(d: Date): Date {
|
||||
const copy = new Date(d);
|
||||
copy.setDate(copy.getDate() + 1);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row -> FullCalendar EventInput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MappedConfig {
|
||||
titleCol: string;
|
||||
startCol: string;
|
||||
endCol: string | null;
|
||||
resourceCol: string | null;
|
||||
}
|
||||
|
||||
function rowToEvent(
|
||||
row: BridgeRow,
|
||||
config: MappedConfig,
|
||||
fields: BridgeField[],
|
||||
): {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
resourceId?: string;
|
||||
extendedProps: { row: BridgeRow; fields: BridgeField[] };
|
||||
} | null {
|
||||
// Resolve field value by name or id to handle Baserow's dual key format.
|
||||
function resolveField(colName: string): unknown {
|
||||
const field = fields.find((f) => f.name === colName || f.id === colName);
|
||||
if (!field) return row.fields[colName];
|
||||
return row.fields[field.name] ?? row.fields[field.id];
|
||||
}
|
||||
|
||||
const startRaw = resolveField(config.startCol);
|
||||
const startDate = parseDateSafe(startRaw);
|
||||
if (!startDate) return null;
|
||||
|
||||
const endRaw = config.endCol ? resolveField(config.endCol) : null;
|
||||
const endDate = endRaw ? (parseDateSafe(endRaw) ?? addOneDay(startDate)) : addOneDay(startDate);
|
||||
|
||||
const titleRaw = resolveField(config.titleCol);
|
||||
const title =
|
||||
typeof titleRaw === "string" && titleRaw.trim()
|
||||
? titleRaw
|
||||
: typeof titleRaw === "number"
|
||||
? String(titleRaw)
|
||||
: row.id;
|
||||
|
||||
const resourceRaw = config.resourceCol ? resolveField(config.resourceCol) : null;
|
||||
const resourceId =
|
||||
resourceRaw !== null && resourceRaw !== undefined ? String(resourceRaw) : undefined;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title,
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
...(resourceId !== undefined ? { resourceId } : {}),
|
||||
extendedProps: { row, fields },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TimelineSkeleton() {
|
||||
return (
|
||||
<Stack gap="xs" aria-busy="true" aria-label="Loading timeline">
|
||||
<Skeleton height={32} width={240} />
|
||||
<Skeleton height={60} radius="sm" />
|
||||
<Skeleton height={300} radius="sm" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config panel (shown when no config saved yet)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TimelineConfigPanelProps {
|
||||
fields: BridgeField[];
|
||||
onSave: (config: MappedConfig) => Promise<void>;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
function TimelineConfigPanel({ fields, onSave, saving }: TimelineConfigPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dateFields = fields.filter(
|
||||
(f) => f.type === "date" || f.type === "created_on" || f.type === "last_modified",
|
||||
);
|
||||
const allFields = fields;
|
||||
|
||||
const fieldOptions = allFields.map((f) => ({ value: f.name, label: f.name }));
|
||||
const dateOptions = dateFields.map((f) => ({ value: f.name, label: f.name }));
|
||||
|
||||
const [titleCol, setTitleCol] = useState<string>(
|
||||
fields.find((f) => f.primary)?.name ?? fields[0]?.name ?? "",
|
||||
);
|
||||
const [startCol, setStartCol] = useState<string>(dateFields[0]?.name ?? "");
|
||||
const [endCol, setEndCol] = useState<string | null>(dateFields[1]?.name ?? null);
|
||||
const [resourceCol, setResourceCol] = useState<string | null>(null);
|
||||
|
||||
const isValid = Boolean(titleCol && startCol);
|
||||
|
||||
async function handleSave() {
|
||||
if (!isValid) return;
|
||||
await onSave({ titleCol, startCol, endCol, resourceCol });
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder aria-label={t("database_view.timeline.config_panel_label")}>
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<IconSettings size={18} />
|
||||
<Title order={5}>{t("database_view.timeline.config_title")}</Title>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("database_view.timeline.config_description")}
|
||||
</Text>
|
||||
|
||||
<Select
|
||||
label={t("database_view.timeline.title_col")}
|
||||
description={t("database_view.timeline.title_col_desc")}
|
||||
data={fieldOptions}
|
||||
value={titleCol}
|
||||
onChange={(v) => setTitleCol(v ?? "")}
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t("database_view.timeline.start_col")}
|
||||
description={t("database_view.timeline.start_col_desc")}
|
||||
data={dateOptions.length > 0 ? dateOptions : fieldOptions}
|
||||
value={startCol}
|
||||
onChange={(v) => setStartCol(v ?? "")}
|
||||
required
|
||||
aria-required="true"
|
||||
error={!startCol ? t("database_view.timeline.start_col_required") : undefined}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t("database_view.timeline.end_col")}
|
||||
description={t("database_view.timeline.end_col_desc")}
|
||||
data={[
|
||||
{ value: "__none__", label: t("database_view.timeline.none") },
|
||||
...(dateOptions.length > 0 ? dateOptions : fieldOptions),
|
||||
]}
|
||||
value={endCol ?? "__none__"}
|
||||
onChange={(v) => setEndCol(v === "__none__" ? null : (v ?? null))}
|
||||
clearable={false}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t("database_view.timeline.resource_col")}
|
||||
description={t("database_view.timeline.resource_col_desc")}
|
||||
data={[
|
||||
{ value: "__none__", label: t("database_view.timeline.none") },
|
||||
...fieldOptions,
|
||||
]}
|
||||
value={resourceCol ?? "__none__"}
|
||||
onChange={(v) => setResourceCol(v === "__none__" ? null : (v ?? null))}
|
||||
clearable={false}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!isValid || saving}
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
aria-label={t("database_view.timeline.save_config")}
|
||||
>
|
||||
{t("database_view.timeline.save_config")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TimelineRenderer({ tableId, viewId, bridgeUrl }: TimelineRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedRow, setSelectedRow] = useState<BridgeRow | null>(null);
|
||||
const [selectedFields, setSelectedFields] = useState<BridgeField[]>([]);
|
||||
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
|
||||
const [configPanelVisible, setConfigPanelVisible] = useState(false);
|
||||
|
||||
const { data, isLoading: dataLoading, isError: dataError, error, refetch } = useViewData({
|
||||
viewId,
|
||||
tableId,
|
||||
bridgeUrl,
|
||||
page: 1,
|
||||
size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
const {
|
||||
config,
|
||||
isLoading: configLoading,
|
||||
isError: configError,
|
||||
saveConfig,
|
||||
isSaving,
|
||||
} = useTimelineConfig({ viewId, bridgeUrl });
|
||||
|
||||
const { canWriteRows } = usePermissions();
|
||||
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
|
||||
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
|
||||
|
||||
const isLoading = dataLoading || configLoading;
|
||||
|
||||
const { rows, fields } = data ?? { rows: [], fields: [] };
|
||||
|
||||
// Build FullCalendar events from rows — always computed so hooks are never conditional.
|
||||
const events = useMemo(
|
||||
() =>
|
||||
!isLoading && !dataError && !configError && config
|
||||
? rows
|
||||
.map((row) => rowToEvent(row, config, fields))
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null)
|
||||
: [],
|
||||
[rows, config, fields, isLoading, dataError, configError],
|
||||
);
|
||||
|
||||
// Build resource list when resourceCol is set.
|
||||
const resources = useMemo(() => {
|
||||
if (!config?.resourceCol) return undefined;
|
||||
const seen = new Set<string>();
|
||||
const list: { id: string; title: string }[] = [];
|
||||
for (const event of events) {
|
||||
const rid = event.resourceId;
|
||||
if (rid && !seen.has(rid)) {
|
||||
seen.add(rid);
|
||||
list.push({ id: rid, title: rid });
|
||||
}
|
||||
}
|
||||
return list.length > 0 ? list : undefined;
|
||||
}, [events, config?.resourceCol]);
|
||||
|
||||
if (isLoading) return <TimelineSkeleton />;
|
||||
|
||||
if (dataError) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status;
|
||||
let message = t("database_view.error.generic");
|
||||
if (status === 403) message = t("database_view.error.permission_denied");
|
||||
else if (status === 404) message = t("database_view.error.view_not_found");
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
color="red"
|
||||
title={t("database_view.error.title")}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{message}</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetch()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (configError) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="orange">
|
||||
<Text size="sm">{t("database_view.timeline.config_load_error")}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Show config panel when: no config saved, or user opened it manually.
|
||||
const showConfigPanel = configPanelVisible || !config;
|
||||
|
||||
if (showConfigPanel) {
|
||||
return (
|
||||
<Stack gap="sm" data-testid="timeline-config-panel">
|
||||
{fields.length === 0 ? (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="yellow">
|
||||
<Text size="sm">{t("database_view.timeline.no_fields")}</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<TimelineConfigPanel
|
||||
fields={fields}
|
||||
saving={isSaving}
|
||||
onSave={async (cfg) => {
|
||||
await saveConfig(cfg);
|
||||
setConfigPanelVisible(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const row = arg.event.extendedProps.row as BridgeRow;
|
||||
const eventFields = arg.event.extendedProps.fields as BridgeField[];
|
||||
setSelectedRow(row);
|
||||
setSelectedFields(eventFields);
|
||||
openModal();
|
||||
}
|
||||
|
||||
function handleEventResize(arg: EventResizeDoneArg) {
|
||||
if (!canWriteRows || !config) {
|
||||
arg.revert();
|
||||
return;
|
||||
}
|
||||
const rowId = arg.event.id;
|
||||
const newEnd = arg.event.end;
|
||||
if (!newEnd) {
|
||||
arg.revert();
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (config.endCol) {
|
||||
payload[config.endCol] = newEnd.toISOString();
|
||||
}
|
||||
if (Object.keys(payload).length === 0) {
|
||||
arg.revert();
|
||||
return;
|
||||
}
|
||||
updateRow.mutate(
|
||||
{ rowId, payload: { fields: payload } },
|
||||
{ onError: () => arg.revert() },
|
||||
);
|
||||
}
|
||||
|
||||
const initialView = resources ? "resourceTimelineMonth" : "timelineMonth";
|
||||
|
||||
return (
|
||||
<div data-testid="timeline-renderer">
|
||||
<Group justify="flex-end" mb="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconSettings size={14} />}
|
||||
onClick={() => setConfigPanelVisible(true)}
|
||||
aria-label={t("database_view.timeline.configure")}
|
||||
>
|
||||
{t("database_view.timeline.configure")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{events.length === 0 && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="blue" mb="sm">
|
||||
<Text size="sm">{t("database_view.timeline.no_events")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={styles.timelineWrapper}
|
||||
aria-label={t("database_view.timeline.aria_label")}
|
||||
>
|
||||
<FullCalendar
|
||||
plugins={
|
||||
resources
|
||||
? [resourceTimelinePlugin, timelinePlugin, interactionPlugin]
|
||||
: [timelinePlugin, interactionPlugin]
|
||||
}
|
||||
initialView={initialView}
|
||||
events={events}
|
||||
resources={resources}
|
||||
editable={canWriteRows}
|
||||
eventResize={handleEventResize}
|
||||
eventClick={handleEventClick}
|
||||
headerToolbar={{
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: resources ? "resourceTimelineMonth,resourceTimelineWeek" : "timelineMonth,timelineWeek",
|
||||
}}
|
||||
height="auto"
|
||||
schedulerLicenseKey="GPL-My-Project-Is-Open-Source"
|
||||
eventClassNames={() => [styles.timelineEvent]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RowDetailModal
|
||||
row={selectedRow}
|
||||
fields={selectedFields}
|
||||
opened={modalOpened}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
/**
|
||||
* Admin client for bridge CRUD endpoints (Phase B).
|
||||
* Reuses the bridge-client (cookie-auth via authToken) and targets
|
||||
* /api/v1/admin/* routes that proxy to Baserow user-JWT operations.
|
||||
*/
|
||||
|
||||
import { getBridgeClient, resolveBridgeUrl } from './bridge-client';
|
||||
|
||||
export type FieldType =
|
||||
| 'text'
|
||||
| 'long_text'
|
||||
| 'number'
|
||||
| 'rating'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'url'
|
||||
| 'email'
|
||||
| 'phone_number'
|
||||
| 'single_select'
|
||||
| 'multiple_select'
|
||||
| 'link_row'
|
||||
| 'formula'
|
||||
| 'autonumber'
|
||||
| 'duration';
|
||||
|
||||
export interface BaserowDatabase {
|
||||
id: number;
|
||||
name: string;
|
||||
workspace: { id: number; name: string };
|
||||
tables: Array<{ id: number; name: string; database_id: number }>;
|
||||
}
|
||||
|
||||
export interface BaserowTableSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
database_id: number;
|
||||
}
|
||||
|
||||
export interface BaserowFieldSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
type: FieldType | string;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateFieldInput {
|
||||
name: string;
|
||||
type: FieldType;
|
||||
// Type-specific extras passed through to Baserow.
|
||||
// formula: { formula: "field('A') + field('B')" }
|
||||
// number: { number_decimal_places: 2 }
|
||||
// single_select: { select_options: [{value, color}] }
|
||||
// link_row: { link_row_table_id: <id> }
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function unwrap<T>(p: Promise<unknown>): Promise<T> {
|
||||
// The bridge-client's response interceptor returns `response.data` (the
|
||||
// body), which itself wraps the payload in `{ data: ... }` for admin
|
||||
// endpoints. We unwrap once more to expose just the payload.
|
||||
return p.then((body) => (body as { data: T }).data);
|
||||
}
|
||||
|
||||
export interface BaserowWorkspace {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function listWorkspaces(
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<BaserowWorkspace[]> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<BaserowWorkspace[]>(api.get(`/api/v1/admin/workspaces`));
|
||||
}
|
||||
|
||||
export async function listDatabases(
|
||||
workspaceId: number,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<BaserowDatabase[]> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<BaserowDatabase[]>(
|
||||
api.get(`/api/v1/admin/databases`, { params: { workspaceId } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createDatabase(
|
||||
workspaceId: number,
|
||||
name: string,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<BaserowDatabase> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<BaserowDatabase>(
|
||||
api.post(`/api/v1/admin/databases`, { workspaceId, name }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listTables(
|
||||
databaseId: number,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<BaserowTableSummary[]> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<BaserowTableSummary[]>(
|
||||
api.get(`/api/v1/admin/tables`, { params: { databaseId } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTable(
|
||||
databaseId: number,
|
||||
name: string,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<BaserowTableSummary> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<BaserowTableSummary>(
|
||||
api.post(`/api/v1/admin/tables`, { databaseId, name }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateTable(
|
||||
tableId: number,
|
||||
name: string,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<BaserowTableSummary> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<BaserowTableSummary>(
|
||||
api.patch(`/api/v1/admin/tables/${tableId}`, { name }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteTable(
|
||||
tableId: number,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<void> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
await api.delete(`/api/v1/admin/tables/${tableId}`);
|
||||
}
|
||||
|
||||
export async function createField(
|
||||
tableId: number,
|
||||
payload: CreateFieldInput,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<BaserowFieldSummary> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<BaserowFieldSummary>(
|
||||
api.post(`/api/v1/admin/tables/${tableId}/fields`, payload),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateField(
|
||||
fieldId: number,
|
||||
payload: Partial<CreateFieldInput>,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<BaserowFieldSummary> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<BaserowFieldSummary>(
|
||||
api.patch(`/api/v1/admin/fields/${fieldId}`, payload),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteField(
|
||||
fieldId: number,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<void> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
await api.delete(`/api/v1/admin/fields/${fieldId}`);
|
||||
}
|
||||
|
||||
export async function listViews(
|
||||
tableId: number,
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<Array<{ id: number; name: string; type: string }>> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<Array<{ id: number; name: string; type: string }>>(
|
||||
api.get(`/api/v1/admin/tables/${tableId}/views`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createView(
|
||||
tableId: number,
|
||||
payload: { name: string; type: 'grid' | 'gallery' | 'kanban' | 'calendar' | 'timeline' | 'form' },
|
||||
bridgeUrl?: string | null,
|
||||
): Promise<{ id: number; name: string; type: string }> {
|
||||
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
|
||||
return unwrap<{ id: number; name: string; type: string }>(
|
||||
api.post(`/api/v1/admin/tables/${tableId}/views`, payload),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
/**
|
||||
* Thin HTTP wrapper for the bridge API.
|
||||
*
|
||||
* Auth strategy: the bridge accepts the Docmost session cookie `authToken`
|
||||
* (HttpOnly, host-only) directly via `getCookie('authToken')` in its
|
||||
* middleware. This works only when the bridge is served on the *same origin*
|
||||
* as Docmost — in dev via the Vite proxy `/bridge -> :4000`, in prod via a
|
||||
* Traefik route on `doc.stark.a3n.fr/bridge -> bridge:4000`. Cross-subdomain
|
||||
* (`bridge.stark.a3n.fr`) does NOT work because the cookie is host-only and
|
||||
* not Domain=.stark.a3n.fr.
|
||||
*
|
||||
* `withCredentials: true` is enough — the browser sends the HttpOnly cookie
|
||||
* automatically. We do NOT try to read it from `document.cookie` (it's
|
||||
* HttpOnly by design).
|
||||
*
|
||||
* `VITE_BRIDGE_TOKEN` is a dev-only fallback that injects a service token
|
||||
* (`brg_*`) when the cookie route is not in place yet.
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
|
||||
/** Resolved bridge base URL: per-instance override > env var > same-origin proxy default. */
|
||||
export function resolveBridgeUrl(bridgeUrlOverride?: string | null): string {
|
||||
const metaEnv = (import.meta as unknown as { env?: { VITE_BRIDGE_URL?: string } }).env;
|
||||
return bridgeUrlOverride ?? metaEnv?.VITE_BRIDGE_URL ?? "/bridge";
|
||||
}
|
||||
|
||||
/** Build a one-shot axios instance targeting the resolved bridge URL. */
|
||||
export function createBridgeClient(bridgeUrl: string): AxiosInstance {
|
||||
const instance = axios.create({
|
||||
baseURL: bridgeUrl,
|
||||
withCredentials: true,
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const envToken: string | undefined = (
|
||||
import.meta as unknown as { env?: { VITE_BRIDGE_TOKEN?: string } }
|
||||
).env?.VITE_BRIDGE_TOKEN;
|
||||
|
||||
if (envToken) {
|
||||
instance.interceptors.request.use((config) => {
|
||||
config.headers["Authorization"] = `Bearer ${envToken}`;
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => Promise.reject(err),
|
||||
);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Singleton map: bridgeUrl -> axios instance (one per origin). */
|
||||
const _clients = new Map<string, AxiosInstance>();
|
||||
|
||||
export function getBridgeClient(bridgeUrl: string): AxiosInstance {
|
||||
if (!_clients.has(bridgeUrl)) {
|
||||
_clients.set(bridgeUrl, createBridgeClient(bridgeUrl));
|
||||
}
|
||||
return _clients.get(bridgeUrl)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a single row on the bridge.
|
||||
*
|
||||
* Why a named helper and not a direct client.patch():
|
||||
* Callers (useUpdateRow) would have to resolve the URL themselves. This helper
|
||||
* keeps the URL construction in one place and makes the intent explicit.
|
||||
*
|
||||
* The response is typed as unknown — callers should not assume a specific shape
|
||||
* as the bridge returns the updated row in its envelope format.
|
||||
*/
|
||||
export async function patchRow(
|
||||
tableId: string,
|
||||
rowId: string,
|
||||
payload: { fields: Record<string, unknown> },
|
||||
bridgeUrl: string,
|
||||
): Promise<unknown> {
|
||||
const client = getBridgeClient(bridgeUrl);
|
||||
return (client.patch(
|
||||
`/api/v1/tables/${tableId}/rows/${rowId}`,
|
||||
payload,
|
||||
) as unknown) as unknown;
|
||||
}
|
||||
|
|
@ -1,392 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
TextInput,
|
||||
Button,
|
||||
Group,
|
||||
Text,
|
||||
Select,
|
||||
Alert,
|
||||
ActionIcon,
|
||||
Textarea,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Title,
|
||||
Paper,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconAlertCircle,
|
||||
IconChevronLeft,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import {
|
||||
type FieldType,
|
||||
type BaserowDatabase,
|
||||
listDatabases,
|
||||
listWorkspaces,
|
||||
createDatabase,
|
||||
createTable,
|
||||
createField,
|
||||
listViews,
|
||||
} from "../services/admin-client";
|
||||
|
||||
type Step = "name" | "fields" | "creating";
|
||||
|
||||
interface FieldDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FieldType;
|
||||
// For formula type
|
||||
formula?: string;
|
||||
// For number type
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
interface CreateDatabaseModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
editor: Editor;
|
||||
bridgeUrl?: string | null;
|
||||
/** Default workspace where databases live. Required to list/create. */
|
||||
workspaceId?: number;
|
||||
}
|
||||
|
||||
const FIELD_TYPE_OPTIONS: Array<{ value: FieldType; label: string; description?: string }> = [
|
||||
{ value: "text", label: "Texte court" },
|
||||
{ value: "long_text", label: "Texte long" },
|
||||
{ value: "number", label: "Nombre" },
|
||||
{ value: "rating", label: "Note (étoiles)" },
|
||||
{ value: "boolean", label: "Case à cocher" },
|
||||
{ value: "date", label: "Date" },
|
||||
{ value: "single_select", label: "Choix unique" },
|
||||
{ value: "multiple_select", label: "Choix multiple" },
|
||||
{ value: "url", label: "URL" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "phone_number", label: "Téléphone" },
|
||||
{ value: "duration", label: "Durée" },
|
||||
{ value: "formula", label: "Formule (calcul)" , description: "Ex: field('Total') - field('Donné')"},
|
||||
];
|
||||
|
||||
function newFieldDraft(): FieldDraft {
|
||||
return {
|
||||
id: Math.random().toString(36).slice(2, 10),
|
||||
name: "",
|
||||
type: "text",
|
||||
};
|
||||
}
|
||||
|
||||
export function CreateDatabaseModal({
|
||||
opened,
|
||||
onClose,
|
||||
editor,
|
||||
bridgeUrl,
|
||||
workspaceId,
|
||||
}: CreateDatabaseModalProps) {
|
||||
const [step, setStep] = useState<Step>("name");
|
||||
const [tableName, setTableName] = useState("");
|
||||
const [databases, setDatabases] = useState<BaserowDatabase[]>([]);
|
||||
const [databaseId, setDatabaseId] = useState<number | null>(null);
|
||||
const [newDatabaseName, setNewDatabaseName] = useState("");
|
||||
const [fields, setFields] = useState<FieldDraft[]>([newFieldDraft()]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingDbs, setLoadingDbs] = useState(false);
|
||||
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<number | undefined>(workspaceId);
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setStep("name");
|
||||
setTableName("");
|
||||
setDatabaseId(null);
|
||||
setNewDatabaseName("");
|
||||
setFields([newFieldDraft()]);
|
||||
setError(null);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Resolve workspace if not provided: pick the first one the bridge service
|
||||
// account can see. With our current setup there's a single workspace.
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
if (resolvedWorkspaceId !== undefined) return;
|
||||
listWorkspaces(bridgeUrl)
|
||||
.then((list) => {
|
||||
const first = list[0];
|
||||
if (first?.id) setResolvedWorkspaceId(first.id);
|
||||
else setError("Aucun workspace Baserow accessible");
|
||||
})
|
||||
.catch((e) => setError(`Impossible de lister les workspaces : ${(e as Error).message}`));
|
||||
}, [opened, resolvedWorkspaceId, bridgeUrl]);
|
||||
|
||||
// Load databases when workspace is known
|
||||
useEffect(() => {
|
||||
if (!opened || !resolvedWorkspaceId) return;
|
||||
setLoadingDbs(true);
|
||||
listDatabases(resolvedWorkspaceId, bridgeUrl)
|
||||
.then((list) => {
|
||||
setDatabases(list);
|
||||
if (list.length > 0 && databaseId === null) setDatabaseId(list[0].id);
|
||||
})
|
||||
.catch((e) => setError(`Impossible de lister les databases : ${(e as Error).message}`))
|
||||
.finally(() => setLoadingDbs(false));
|
||||
}, [opened, resolvedWorkspaceId, bridgeUrl]);
|
||||
|
||||
const databaseOptions = useMemo(
|
||||
() =>
|
||||
databases.map((db) => ({ value: String(db.id), label: db.name })).concat(
|
||||
{ value: "__new__", label: "+ Créer une nouvelle database" },
|
||||
),
|
||||
[databases],
|
||||
);
|
||||
|
||||
function addField() {
|
||||
setFields((prev) => [...prev, newFieldDraft()]);
|
||||
}
|
||||
|
||||
function removeField(id: string) {
|
||||
setFields((prev) => (prev.length > 1 ? prev.filter((f) => f.id !== id) : prev));
|
||||
}
|
||||
|
||||
function patchField(id: string, patch: Partial<FieldDraft>) {
|
||||
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
setError(null);
|
||||
setStep("creating");
|
||||
try {
|
||||
// Step 1: resolve database (existing or create)
|
||||
let dbId = databaseId;
|
||||
if (databaseId === null && newDatabaseName.trim()) {
|
||||
if (!resolvedWorkspaceId) throw new Error("workspaceId non résolu");
|
||||
const created = await createDatabase(resolvedWorkspaceId, newDatabaseName.trim(), bridgeUrl);
|
||||
dbId = created.id;
|
||||
}
|
||||
if (!dbId) throw new Error("Aucune database choisie");
|
||||
|
||||
// Step 2: create table
|
||||
const table = await createTable(dbId, tableName.trim(), bridgeUrl);
|
||||
|
||||
// Step 3: create fields (the table has a default 'Name' primary field;
|
||||
// we add ours on top). Run sequentially so ordering is stable.
|
||||
for (const f of fields) {
|
||||
if (!f.name.trim()) continue;
|
||||
const payload: Record<string, unknown> = { name: f.name.trim(), type: f.type };
|
||||
if (f.type === "formula") {
|
||||
payload.formula = f.formula?.trim() ?? "";
|
||||
}
|
||||
if (f.type === "number" && typeof f.decimals === "number") {
|
||||
payload.number_decimal_places = f.decimals;
|
||||
}
|
||||
await createField(table.id, payload as never, bridgeUrl);
|
||||
}
|
||||
|
||||
// Step 4: resolve the auto-created Grid view of the new table, then
|
||||
// insert the embed node with a real viewId (without it the renderer
|
||||
// shows "No rows found in this view").
|
||||
const views = await listViews(table.id, bridgeUrl);
|
||||
const defaultView =
|
||||
views.find((v) => v.type === "grid") ?? views[0] ?? null;
|
||||
if (!defaultView) {
|
||||
throw new Error(
|
||||
"Table creee mais aucune vue par defaut trouvee. Reessayez ou contactez un admin.",
|
||||
);
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertDatabaseView({
|
||||
tableId: String(table.id),
|
||||
viewId: String(defaultView.id),
|
||||
viewType: "grid",
|
||||
bridgeUrl: bridgeUrl ?? null,
|
||||
})
|
||||
.run();
|
||||
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setStep("fields");
|
||||
}
|
||||
}
|
||||
|
||||
const canProceedToFields =
|
||||
tableName.trim().length > 0 &&
|
||||
(databaseId !== null || newDatabaseName.trim().length > 0);
|
||||
|
||||
const canCreate =
|
||||
step === "fields" &&
|
||||
fields.every((f) => f.name.trim().length > 0) &&
|
||||
fields.every((f) => f.type !== "formula" || (f.formula?.trim().length ?? 0) > 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={4}>Créer une nouvelle table</Title>}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step === "name" && (
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Nom de la table"
|
||||
placeholder="Ex: Heures par personne"
|
||||
value={tableName}
|
||||
onChange={(e) => setTableName(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Database parente"
|
||||
placeholder={loadingDbs ? "Chargement..." : "Choisir une database"}
|
||||
data={databaseOptions}
|
||||
value={databaseId !== null ? String(databaseId) : null}
|
||||
onChange={(v) => {
|
||||
if (v === "__new__") {
|
||||
setDatabaseId(null);
|
||||
} else if (v) {
|
||||
setDatabaseId(Number(v));
|
||||
setNewDatabaseName("");
|
||||
}
|
||||
}}
|
||||
searchable
|
||||
/>
|
||||
{databaseId === null && (
|
||||
<TextInput
|
||||
label="Nom de la nouvelle database"
|
||||
placeholder="Ex: Projet AcadeNice"
|
||||
value={newDatabaseName}
|
||||
onChange={(e) => setNewDatabaseName(e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button disabled={!canProceedToFields} onClick={() => setStep("fields")}>
|
||||
Suivant
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{step === "fields" && (
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Définis les colonnes. Une colonne primaire <strong>Name</strong> est ajoutée
|
||||
automatiquement par Baserow ; pas besoin de la redéfinir.
|
||||
</Text>
|
||||
|
||||
{fields.map((f, idx) => (
|
||||
<Paper key={f.id} p="sm" withBorder>
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
label={idx === 0 ? "Nom" : undefined}
|
||||
placeholder="Ex: Personne"
|
||||
value={f.name}
|
||||
onChange={(e) => patchField(f.id, { name: e.currentTarget.value })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
label={idx === 0 ? "Type" : undefined}
|
||||
data={FIELD_TYPE_OPTIONS.map(({ value, label }) => ({ value, label }))}
|
||||
value={f.type}
|
||||
onChange={(v) => v && patchField(f.id, { type: v as FieldType })}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => removeField(f.id)}
|
||||
disabled={fields.length === 1}
|
||||
aria-label="Supprimer la colonne"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{f.type === "formula" && (
|
||||
<Textarea
|
||||
label="Formule"
|
||||
description={
|
||||
<span>
|
||||
Syntaxe Baserow. Ex:
|
||||
<code>{`field('Heures totales') - field('Heures donnees')`}</code>
|
||||
</span>
|
||||
}
|
||||
placeholder="field('A') - field('B')"
|
||||
value={f.formula ?? ""}
|
||||
onChange={(e) => patchField(f.id, { formula: e.currentTarget.value })}
|
||||
minRows={2}
|
||||
mt="xs"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{f.type === "number" && (
|
||||
<NumberInput
|
||||
label="Décimales"
|
||||
value={f.decimals ?? 0}
|
||||
onChange={(v) =>
|
||||
patchField(f.id, { decimals: typeof v === "number" ? v : 0 })
|
||||
}
|
||||
min={0}
|
||||
max={10}
|
||||
mt="xs"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={addField}
|
||||
>
|
||||
Ajouter une colonne
|
||||
</Button>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconChevronLeft size={14} />}
|
||||
onClick={() => setStep("name")}
|
||||
>
|
||||
Retour
|
||||
</Button>
|
||||
<Group>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button disabled={!canCreate} onClick={handleCreate}>
|
||||
Créer la table
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{step === "creating" && (
|
||||
<Stack align="center" py="xl">
|
||||
<Loader />
|
||||
<Text>Création en cours…</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* Slash item `/new database` — wizard de création de table Baserow depuis l'UI
|
||||
* AcadeDoc. Utilise le bridge admin (Phase B) puis insère la nouvelle table
|
||||
* comme un node Tiptap database-view.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Range } from "@tiptap/core";
|
||||
import { IconTablePlus } from "@tabler/icons-react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { CreateDatabaseModal } from "./create-database-modal";
|
||||
|
||||
interface CreateDatabaseSlashCommandProps {
|
||||
editor: Editor;
|
||||
onDone: () => void;
|
||||
bridgeUrl?: string | null;
|
||||
workspaceId?: number;
|
||||
}
|
||||
|
||||
function CreateDatabaseSlashWrapper({
|
||||
editor,
|
||||
onDone,
|
||||
bridgeUrl,
|
||||
workspaceId,
|
||||
}: CreateDatabaseSlashCommandProps) {
|
||||
const [opened, setOpened] = useState(true);
|
||||
|
||||
return (
|
||||
<CreateDatabaseModal
|
||||
opened={opened}
|
||||
onClose={() => {
|
||||
setOpened(false);
|
||||
onDone();
|
||||
}}
|
||||
editor={editor}
|
||||
bridgeUrl={bridgeUrl}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCreateDatabaseSlashItem(opts?: {
|
||||
bridgeUrl?: string | null;
|
||||
workspaceId?: number;
|
||||
}) {
|
||||
return {
|
||||
title: "New database",
|
||||
description: "Create a new Baserow table with custom columns and formulas.",
|
||||
searchTerms: [
|
||||
"new",
|
||||
"database",
|
||||
"create",
|
||||
"table",
|
||||
"baserow",
|
||||
"nouvelle",
|
||||
"creer",
|
||||
"formule",
|
||||
"heures",
|
||||
],
|
||||
icon: IconTablePlus,
|
||||
command: ({ editor, range }: { editor: Editor; range: Range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const teardown = () => {
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(container)) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
import("react-dom/client").then(({ createRoot }) => {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>
|
||||
<CreateDatabaseSlashWrapper
|
||||
editor={editor}
|
||||
bridgeUrl={opts?.bridgeUrl ?? null}
|
||||
workspaceId={opts?.workspaceId}
|
||||
onDone={() => {
|
||||
root.unmount();
|
||||
teardown();
|
||||
}}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Range } from "@tiptap/core";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { InsertDatabaseModal } from "./insert-database-modal";
|
||||
|
||||
/**
|
||||
* Slash command handler for `/database`.
|
||||
*
|
||||
* The handler is invoked synchronously by the Tiptap slash-command machinery
|
||||
* (it receives editor + range, deletes the slash trigger text, then must open
|
||||
* a modal). We render the modal as a portal outside the editor DOM via a
|
||||
* React root mounted on document.body — the same technique Docmost uses for
|
||||
* other slash commands that need a picker.
|
||||
*
|
||||
* Props passed through from the slash menu item command callback.
|
||||
*/
|
||||
interface DatabaseSlashCommandProps {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
bridgeUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone component that renders the InsertDatabaseModal.
|
||||
* Receives a `onDone` callback to tear down when done.
|
||||
*/
|
||||
export function DatabaseSlashCommandModal({
|
||||
editor,
|
||||
bridgeUrl,
|
||||
onDone,
|
||||
}: Omit<DatabaseSlashCommandProps, "range"> & { onDone: () => void }) {
|
||||
const [opened, setOpened] = useState(true);
|
||||
|
||||
function handleClose() {
|
||||
setOpened(false);
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<InsertDatabaseModal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
editor={editor}
|
||||
bridgeUrl={bridgeUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the slash menu item descriptor for the `/database` command.
|
||||
* Import this and add it to the `CommandGroups` in menu-items.ts (upstream patch).
|
||||
*/
|
||||
export function buildDatabaseSlashItem(bridgeUrl?: string | null) {
|
||||
return {
|
||||
title: "Database view",
|
||||
description: "Embed a Baserow table or view inline.",
|
||||
searchTerms: [
|
||||
"database",
|
||||
"table",
|
||||
"baserow",
|
||||
"view",
|
||||
"grid",
|
||||
"embed",
|
||||
"data",
|
||||
],
|
||||
icon: IconTable,
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
}: {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}) => {
|
||||
// Delete the slash trigger text before opening the modal.
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// Mount a temporary React root for the modal — mirrors the Docmost pattern.
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
function teardown() {
|
||||
// Defer so modal close animation finishes.
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(container)) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Lazy import to avoid circular deps at module init time.
|
||||
import("react-dom/client").then(({ createRoot }) => {
|
||||
// We need QueryClient + MantineProvider because this root is detached
|
||||
// from the main app tree. Reuse existing providers when possible in
|
||||
// future iterations; for now a fresh QueryClient is acceptable since
|
||||
// this is a short-lived modal.
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MantineProvider>
|
||||
<DatabaseSlashCommandModal
|
||||
editor={editor}
|
||||
bridgeUrl={bridgeUrl}
|
||||
onDone={() => {
|
||||
root.unmount();
|
||||
teardown();
|
||||
}}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
.stepIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: var(--mantine-color-gray-6);
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--mantine-color-gray-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepDot.active {
|
||||
background-color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.tableList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tableItem {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--mantine-color-gray-2);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 80ms ease, border-color 80ms ease;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .tableItem {
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
.tableItem:hover,
|
||||
.tableItem.selected {
|
||||
background-color: var(--mantine-color-blue-0);
|
||||
border-color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .tableItem:hover,
|
||||
[data-mantine-color-scheme="dark"] .tableItem.selected {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-blue-9) 20%, transparent);
|
||||
border-color: var(--mantine-color-blue-6);
|
||||
}
|
||||
|
||||
.viewBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
transition: background-color 80ms ease, border-color 80ms ease;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .viewBadge {
|
||||
border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
.viewBadge:hover,
|
||||
.viewBadge.selected {
|
||||
background-color: var(--mantine-color-blue-0);
|
||||
border-color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .viewBadge:hover,
|
||||
[data-mantine-color-scheme="dark"] .viewBadge.selected {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-blue-9) 20%, transparent);
|
||||
border-color: var(--mantine-color-blue-6);
|
||||
}
|
||||
|
||||
.viewGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
|
@ -1,628 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Stack,
|
||||
Group,
|
||||
Button,
|
||||
Alert,
|
||||
Loader,
|
||||
TextInput,
|
||||
Select,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconTable,
|
||||
IconChevronLeft,
|
||||
IconTimeline,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { useTables } from "../hooks/use-tables";
|
||||
import { useViews } from "../hooks/use-views";
|
||||
import { useViewData } from "../hooks/use-view-data";
|
||||
import { useTimelineConfig } from "../hooks/use-timeline-config";
|
||||
import { useWorkspaces } from "../hooks/use-workspaces";
|
||||
import { useDatabases } from "../hooks/use-databases";
|
||||
import { resolveBridgeUrl } from "../services/bridge-client";
|
||||
import type { BridgeTable, BridgeView, BridgeField } from "../types/database-view.types";
|
||||
import styles from "./insert-database-modal.module.css";
|
||||
|
||||
type Step = "source" | "table" | "view" | "timeline-mapping";
|
||||
|
||||
interface InsertDatabaseModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
editor: Editor;
|
||||
bridgeUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-step Mantine modal to insert a database-view node.
|
||||
*
|
||||
* Step 1 : pick a table
|
||||
* Step 2 : pick a view (selecting a timeline view enables step 3)
|
||||
* Step 3 : configure column mapping for timeline views
|
||||
*
|
||||
* On confirmation, inserts the node via editor.commands.insertDatabaseView().
|
||||
* For timeline views, the mapping config is saved to bridge Redis before insert.
|
||||
*/
|
||||
export function InsertDatabaseModal({
|
||||
opened,
|
||||
onClose,
|
||||
editor,
|
||||
bridgeUrl,
|
||||
}: InsertDatabaseModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [step, setStep] = useState<Step>("source");
|
||||
const [workspaceId, setWorkspaceId] = useState<number | null>(null);
|
||||
const [databaseId, setDatabaseId] = useState<number | null>(null);
|
||||
const [selectedTable, setSelectedTable] = useState<BridgeTable | null>(null);
|
||||
const [selectedView, setSelectedView] = useState<BridgeView | null>(null);
|
||||
const [tableSearch, setTableSearch] = useState("");
|
||||
|
||||
const {
|
||||
data: workspaces,
|
||||
isLoading: workspacesLoading,
|
||||
isError: workspacesError,
|
||||
refetch: refetchWorkspaces,
|
||||
} = useWorkspaces(bridgeUrl);
|
||||
|
||||
const {
|
||||
data: databases,
|
||||
isLoading: databasesLoading,
|
||||
isError: databasesError,
|
||||
refetch: refetchDatabases,
|
||||
} = useDatabases(workspaceId, bridgeUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId && workspaces?.length === 1) {
|
||||
setWorkspaceId(workspaces[0].id);
|
||||
}
|
||||
}, [workspaces, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId && !databaseId && databases?.length === 1) {
|
||||
setDatabaseId(databases[0].id);
|
||||
}
|
||||
}, [databases, databaseId, workspaceId]);
|
||||
|
||||
// Timeline column mapping state
|
||||
const [titleCol, setTitleCol] = useState("");
|
||||
const [startCol, setStartCol] = useState("");
|
||||
const [endCol, setEndCol] = useState<string | null>(null);
|
||||
const [resourceCol, setResourceCol] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: tables,
|
||||
isLoading: tablesLoading,
|
||||
isError: tablesError,
|
||||
refetch: refetchTables,
|
||||
} = useTables(databaseId, bridgeUrl);
|
||||
|
||||
const {
|
||||
data: views,
|
||||
isLoading: viewsLoading,
|
||||
isError: viewsError,
|
||||
refetch: refetchViews,
|
||||
} = useViews(selectedTable?.id, bridgeUrl);
|
||||
|
||||
// Fetch field metadata when on the timeline-mapping step.
|
||||
const isTimelineStep = step === "timeline-mapping";
|
||||
const resolvedUrl = resolveBridgeUrl(bridgeUrl);
|
||||
const {
|
||||
data: viewData,
|
||||
isLoading: fieldsLoading,
|
||||
isError: fieldsError,
|
||||
} = useViewData({
|
||||
viewId: selectedView?.id ?? "",
|
||||
tableId: selectedTable?.id ?? "",
|
||||
bridgeUrl,
|
||||
page: 1,
|
||||
size: 1,
|
||||
});
|
||||
const mappableFields: BridgeField[] = isTimelineStep ? (viewData?.fields ?? []) : [];
|
||||
|
||||
const { saveConfig, isSaving } = useTimelineConfig({
|
||||
viewId: selectedView?.id ?? "",
|
||||
bridgeUrl,
|
||||
});
|
||||
|
||||
function handleReset() {
|
||||
setStep("source");
|
||||
setWorkspaceId(null);
|
||||
setDatabaseId(null);
|
||||
setSelectedTable(null);
|
||||
setSelectedView(null);
|
||||
setTableSearch("");
|
||||
setTitleCol("");
|
||||
setStartCol("");
|
||||
setEndCol(null);
|
||||
setResourceCol(null);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
handleReset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleTableSelect(table: BridgeTable) {
|
||||
setSelectedTable(table);
|
||||
setSelectedView(null);
|
||||
setStep("view");
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (step === "timeline-mapping") {
|
||||
setStep("view");
|
||||
} else if (step === "view") {
|
||||
setStep("table");
|
||||
setSelectedView(null);
|
||||
} else if (step === "table") {
|
||||
setStep("source");
|
||||
setSelectedTable(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewSelect(view: BridgeView) {
|
||||
setSelectedView(view);
|
||||
}
|
||||
|
||||
async function handleInsert() {
|
||||
if (!selectedTable || !selectedView) return;
|
||||
|
||||
if (selectedView.type === "timeline") {
|
||||
// Save the mapping config to bridge Redis before inserting.
|
||||
if (!startCol || !titleCol) return;
|
||||
await saveConfig({
|
||||
titleCol,
|
||||
startCol,
|
||||
endCol,
|
||||
resourceCol,
|
||||
});
|
||||
}
|
||||
|
||||
editor.commands.insertDatabaseView({
|
||||
tableId: selectedTable.id,
|
||||
viewId: selectedView.id,
|
||||
viewType: selectedView.type,
|
||||
bridgeUrl: bridgeUrl ?? null,
|
||||
});
|
||||
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handleAdvanceToTimelineMapping() {
|
||||
if (!selectedView) return;
|
||||
// Pre-populate defaults from date fields.
|
||||
const dateFields = mappableFields.filter(
|
||||
(f) => f.type === "date" || f.type === "created_on",
|
||||
);
|
||||
setTitleCol(mappableFields.find((f) => f.primary)?.name ?? mappableFields[0]?.name ?? "");
|
||||
setStartCol(dateFields[0]?.name ?? "");
|
||||
setEndCol(dateFields[1]?.name ?? null);
|
||||
setResourceCol(null);
|
||||
setStep("timeline-mapping");
|
||||
}
|
||||
|
||||
const filteredTables = (tables ?? []).filter((tbl) =>
|
||||
tbl.name.toLowerCase().includes(tableSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const fieldOptions = mappableFields.map((f) => ({ value: f.name, label: f.name }));
|
||||
const dateFieldOptions = mappableFields
|
||||
.filter((f) => f.type === "date" || f.type === "created_on")
|
||||
.map((f) => ({ value: f.name, label: f.name }));
|
||||
|
||||
const timelineMappingValid = Boolean(titleCol && startCol);
|
||||
|
||||
const isTimeline = selectedView?.type === "timeline";
|
||||
// Insert is disabled for timeline until mapping step is filled.
|
||||
const insertDisabled = !selectedView || (isTimeline && !timelineMappingValid);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<IconTable size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
{t("database_view.modal.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
size="md"
|
||||
centered
|
||||
>
|
||||
{/* Step indicator */}
|
||||
<div className={styles.stepIndicator}>
|
||||
<div className={clsx(styles.stepDot, { [styles.active]: step === "source" })} />
|
||||
<Text size="xs" c={step === "source" ? "blue" : "dimmed"}>
|
||||
{t("database_view.modal.step0", "Database")}
|
||||
</Text>
|
||||
<div className={clsx(styles.stepDot, { [styles.active]: step === "table" })} />
|
||||
<Text size="xs" c={step === "table" ? "blue" : "dimmed"}>
|
||||
{t("database_view.modal.step1")}
|
||||
</Text>
|
||||
<div className={clsx(styles.stepDot, { [styles.active]: step === "view" })} />
|
||||
<Text size="xs" c={step === "view" ? "blue" : "dimmed"}>
|
||||
{t("database_view.modal.step2")}
|
||||
</Text>
|
||||
<div
|
||||
className={clsx(styles.stepDot, {
|
||||
[styles.active]: step === "timeline-mapping",
|
||||
})}
|
||||
/>
|
||||
<Text size="xs" c={step === "timeline-mapping" ? "blue" : "dimmed"}>
|
||||
{t("database_view.modal.step3_timeline")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* ---- STEP 0: source (workspace + database) ---- */}
|
||||
{step === "source" && (
|
||||
<Stack gap="sm">
|
||||
{workspacesLoading && (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{workspacesError && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"database_view.error.workspaces_load",
|
||||
"Failed to load workspaces from the bridge.",
|
||||
)}
|
||||
</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetchWorkspaces()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!workspacesLoading && !workspacesError && (workspaces?.length ?? 0) > 1 && (
|
||||
<Select
|
||||
label={t("database_view.modal.workspace", "Workspace")}
|
||||
data={(workspaces ?? []).map((ws) => ({
|
||||
value: String(ws.id),
|
||||
label: ws.name,
|
||||
}))}
|
||||
value={workspaceId ? String(workspaceId) : null}
|
||||
onChange={(v) => {
|
||||
setWorkspaceId(v ? Number(v) : null);
|
||||
setDatabaseId(null);
|
||||
}}
|
||||
required
|
||||
aria-required="true"
|
||||
data-testid="workspace-select"
|
||||
/>
|
||||
)}
|
||||
|
||||
{workspaceId && databasesLoading && (
|
||||
<Group justify="center" py="xs">
|
||||
<Loader size="xs" />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{workspaceId && databasesError && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"database_view.error.databases_load",
|
||||
"Failed to load databases from the bridge.",
|
||||
)}
|
||||
</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetchDatabases()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{workspaceId && !databasesLoading && !databasesError && (
|
||||
<Select
|
||||
label={t("database_view.modal.database", "Database")}
|
||||
data={(databases ?? []).map((db) => ({
|
||||
value: String(db.id),
|
||||
label: db.name,
|
||||
}))}
|
||||
value={databaseId ? String(databaseId) : null}
|
||||
onChange={(v) => setDatabaseId(v ? Number(v) : null)}
|
||||
required
|
||||
aria-required="true"
|
||||
disabled={(databases?.length ?? 0) === 0}
|
||||
placeholder={
|
||||
(databases?.length ?? 0) === 0
|
||||
? t("database_view.modal.no_databases", "No database in this workspace")
|
||||
: undefined
|
||||
}
|
||||
data-testid="database-select"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!databaseId}
|
||||
onClick={() => setStep("table")}
|
||||
data-testid="source-next-btn"
|
||||
>
|
||||
{t("database_view.modal.next")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ---- STEP 1: table selection ---- */}
|
||||
{step === "table" && (
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconChevronLeft size={14} />}
|
||||
onClick={handleBack}
|
||||
>
|
||||
{t("database_view.modal.back")}
|
||||
</Button>
|
||||
{databases?.find((db) => db.id === databaseId)?.name && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{databases?.find((db) => db.id === databaseId)?.name}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<TextInput
|
||||
placeholder={t("database_view.modal.search_tables")}
|
||||
value={tableSearch}
|
||||
onChange={(e) => setTableSearch(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{tablesLoading && (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{tablesError && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("database_view.error.tables_load")}</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetchTables()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!tablesLoading && !tablesError && (
|
||||
<div className={styles.tableList}>
|
||||
{filteredTables.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{t("database_view.modal.no_tables")}
|
||||
</Text>
|
||||
)}
|
||||
{filteredTables.map((table) => (
|
||||
<div
|
||||
key={table.id}
|
||||
className={clsx(styles.tableItem, {
|
||||
[styles.selected]: selectedTable?.id === table.id,
|
||||
})}
|
||||
onClick={() => handleTableSelect(table)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleTableSelect(table);
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconTable size={14} />
|
||||
<Text size="sm">{table.name}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ---- STEP 2: view selection ---- */}
|
||||
{step === "view" && (
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconChevronLeft size={14} />}
|
||||
onClick={handleBack}
|
||||
>
|
||||
{t("database_view.modal.back")}
|
||||
</Button>
|
||||
<Text size="sm" fw={500}>
|
||||
{selectedTable?.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{viewsLoading && (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{viewsError && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("database_view.error.views_load")}</Text>
|
||||
<Button size="xs" variant="subtle" onClick={() => refetchViews()}>
|
||||
{t("database_view.error.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!viewsLoading && !viewsError && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("database_view.modal.select_view")}
|
||||
</Text>
|
||||
<div className={styles.viewGrid}>
|
||||
{(views ?? []).length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("database_view.modal.no_views")}
|
||||
</Text>
|
||||
)}
|
||||
{(views ?? []).map((view) => (
|
||||
<div
|
||||
key={view.id}
|
||||
className={clsx(styles.viewBadge, {
|
||||
[styles.selected]: selectedView?.id === view.id,
|
||||
})}
|
||||
onClick={() => handleViewSelect(view)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleViewSelect(view);
|
||||
}}
|
||||
>
|
||||
{view.type === "timeline" ? (
|
||||
<IconTimeline size={12} />
|
||||
) : (
|
||||
<IconTable size={12} />
|
||||
)}
|
||||
<span>{view.name}</span>
|
||||
<Text size="xs" c="dimmed">
|
||||
({view.type})
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
{isTimeline ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!selectedView || fieldsLoading}
|
||||
loading={fieldsLoading}
|
||||
onClick={handleAdvanceToTimelineMapping}
|
||||
data-testid="timeline-next-btn"
|
||||
>
|
||||
{t("database_view.modal.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" disabled={!selectedView} onClick={() => void handleInsert()}>
|
||||
{t("database_view.modal.insert")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ---- STEP 3: timeline column mapping ---- */}
|
||||
{step === "timeline-mapping" && (
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconChevronLeft size={14} />}
|
||||
onClick={handleBack}
|
||||
>
|
||||
{t("database_view.modal.back")}
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
<IconTimeline size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{t("database_view.modal.timeline_mapping_title")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{fieldsError && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red">
|
||||
<Text size="sm">{t("database_view.error.generic")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={t("database_view.timeline.title_col")}
|
||||
description={t("database_view.timeline.title_col_desc")}
|
||||
data={fieldOptions}
|
||||
value={titleCol}
|
||||
onChange={(v) => setTitleCol(v ?? "")}
|
||||
required
|
||||
aria-required="true"
|
||||
data-testid="title-col-select"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t("database_view.timeline.start_col")}
|
||||
description={t("database_view.timeline.start_col_desc")}
|
||||
data={dateFieldOptions.length > 0 ? dateFieldOptions : fieldOptions}
|
||||
value={startCol}
|
||||
onChange={(v) => setStartCol(v ?? "")}
|
||||
required
|
||||
aria-required="true"
|
||||
error={!startCol ? t("database_view.timeline.start_col_required") : undefined}
|
||||
data-testid="start-col-select"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t("database_view.timeline.end_col")}
|
||||
description={t("database_view.timeline.end_col_desc")}
|
||||
data={[
|
||||
{ value: "__none__", label: t("database_view.timeline.none") },
|
||||
...(dateFieldOptions.length > 0 ? dateFieldOptions : fieldOptions),
|
||||
]}
|
||||
value={endCol ?? "__none__"}
|
||||
onChange={(v) => setEndCol(v === "__none__" ? null : (v ?? null))}
|
||||
clearable={false}
|
||||
data-testid="end-col-select"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t("database_view.timeline.resource_col")}
|
||||
description={t("database_view.timeline.resource_col_desc")}
|
||||
data={[
|
||||
{ value: "__none__", label: t("database_view.timeline.none") },
|
||||
...fieldOptions,
|
||||
]}
|
||||
value={resourceCol ?? "__none__"}
|
||||
onChange={(v) => setResourceCol(v === "__none__" ? null : (v ?? null))}
|
||||
clearable={false}
|
||||
data-testid="resource-col-select"
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!timelineMappingValid || isSaving}
|
||||
loading={isSaving}
|
||||
onClick={() => void handleInsert()}
|
||||
data-testid="timeline-insert-btn"
|
||||
>
|
||||
{t("database_view.modal.insert")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/**
|
||||
* Shared TypeScript types for the database-view Tiptap extension (R3.1.c).
|
||||
* Aligned on bridge API contracts (R3.1.a).
|
||||
*/
|
||||
|
||||
/** View types the bridge exposes. */
|
||||
export type ViewType = "grid" | "table" | "kanban" | "calendar" | "timeline" | string;
|
||||
|
||||
/** Supported view types. Others render a placeholder. */
|
||||
export const SUPPORTED_VIEW_TYPES: readonly ViewType[] = ["grid", "table", "kanban", "calendar", "timeline"];
|
||||
|
||||
/** Attrs stored on the Tiptap node. */
|
||||
export interface DatabaseViewAttrs {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
viewType: ViewType;
|
||||
/**
|
||||
* Optional per-instance bridge URL override.
|
||||
* Falls back to the env var VITE_BRIDGE_URL when absent.
|
||||
*/
|
||||
bridgeUrl: string | null;
|
||||
}
|
||||
|
||||
/** A Baserow table descriptor returned by GET /api/v1/tables. */
|
||||
export interface BridgeTable {
|
||||
id: string;
|
||||
name: string;
|
||||
databaseId?: string | number;
|
||||
orderIndex?: number;
|
||||
}
|
||||
|
||||
/** A field descriptor returned embedded in GET /api/v1/tables/:id. */
|
||||
export interface BridgeField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
primary?: boolean;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** A view descriptor returned by GET /api/v1/views/table/:tableId. */
|
||||
export interface BridgeView {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ViewType;
|
||||
tableId: string;
|
||||
}
|
||||
|
||||
/** A single row returned by GET /api/v1/views/:viewId/data. */
|
||||
export interface BridgeRow {
|
||||
id: string;
|
||||
tableId: string;
|
||||
fields: Record<string, unknown>;
|
||||
createdOn?: string;
|
||||
updatedOn?: string;
|
||||
order?: string | number;
|
||||
}
|
||||
|
||||
/** Paginated response envelope from GET /api/v1/views/:viewId/data. */
|
||||
export interface BridgeViewDataResponse {
|
||||
data: BridgeRow[];
|
||||
/** Total row count (unpaginated) — may be absent on older bridge versions. */
|
||||
total?: number;
|
||||
meta?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
hasNextPage?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Paginated fetch params for the view-data hook. */
|
||||
export interface ViewDataParams {
|
||||
viewId: string;
|
||||
// Required by the bridge: GET /views/:viewId/data needs tableId to build
|
||||
// Row instances (Baserow does not echo the tableId in listRows responses).
|
||||
tableId: string;
|
||||
bridgeUrl?: string | null;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
/**
|
||||
* Unit tests for custom-node-serializers registry.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CUSTOM_NODE_SERIALIZERS,
|
||||
SERIALIZER_LIST,
|
||||
} from "../services/custom-node-serializers";
|
||||
|
||||
describe("databaseView serializer", () => {
|
||||
const s = CUSTOM_NODE_SERIALIZERS["database-view"];
|
||||
|
||||
it("toMarkdown produces expected token", () => {
|
||||
expect(s.toMarkdown({ tableId: "10", viewId: "5", viewType: "kanban" })).toBe(
|
||||
"[[!db tableId=10 viewId=5 viewType=kanban]]",
|
||||
);
|
||||
});
|
||||
|
||||
it("fromMarkdown extracts attrs", () => {
|
||||
const re = new RegExp(s.pattern.source, "");
|
||||
const match = re.exec("[[!db tableId=42 viewId=7 viewType=grid]]");
|
||||
expect(match).not.toBeNull();
|
||||
const attrs = s.fromMarkdown(match!);
|
||||
expect(attrs?.tableId).toBe("42");
|
||||
expect(attrs?.viewId).toBe("7");
|
||||
expect(attrs?.viewType).toBe("grid");
|
||||
});
|
||||
|
||||
it("fromMarkdown returns null if tableId is missing", () => {
|
||||
const re = new RegExp(s.pattern.source, "");
|
||||
const fakeMatch = ["", "", "7", "grid"] as unknown as RegExpExecArray;
|
||||
expect(s.fromMarkdown(fakeMatch)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wikilink serializer", () => {
|
||||
const s = CUSTOM_NODE_SERIALIZERS["wikilink"];
|
||||
|
||||
it("toMarkdown without alias", () => {
|
||||
expect(s.toMarkdown({ title: "My Page", alias: null })).toBe("[[My Page]]");
|
||||
});
|
||||
|
||||
it("toMarkdown with alias", () => {
|
||||
expect(s.toMarkdown({ title: "Long Title", alias: "short" })).toBe(
|
||||
"[[Long Title|short]]",
|
||||
);
|
||||
});
|
||||
|
||||
it("fromMarkdown extracts title", () => {
|
||||
const re = new RegExp(s.pattern.source, "");
|
||||
const match = re.exec("[[Target Page]]");
|
||||
expect(match).not.toBeNull();
|
||||
const attrs = s.fromMarkdown(match!);
|
||||
expect(attrs?.title).toBe("Target Page");
|
||||
expect(attrs?.alias).toBeNull();
|
||||
});
|
||||
|
||||
it("fromMarkdown extracts alias", () => {
|
||||
const re = new RegExp(s.pattern.source, "");
|
||||
const match = re.exec("[[Target Page|alias]]");
|
||||
const attrs = s.fromMarkdown(match!);
|
||||
expect(attrs?.alias).toBe("alias");
|
||||
});
|
||||
|
||||
it("does NOT match database-view tokens", () => {
|
||||
const re = new RegExp(s.pattern.source, "");
|
||||
const match = re.exec("[[!db tableId=1 viewId=2 viewType=grid]]");
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mention serializer", () => {
|
||||
const s = CUSTOM_NODE_SERIALIZERS["mention"];
|
||||
|
||||
it("toMarkdown produces expected token", () => {
|
||||
expect(s.toMarkdown({ id: "uid-1", label: "Alice" })).toBe("@<uid-1>(Alice)");
|
||||
});
|
||||
|
||||
it("fromMarkdown extracts id and label", () => {
|
||||
const re = new RegExp(s.pattern.source, "");
|
||||
const match = re.exec("@<uid-99>(Carol)");
|
||||
expect(match).not.toBeNull();
|
||||
const attrs = s.fromMarkdown(match!);
|
||||
expect(attrs?.id).toBe("uid-99");
|
||||
expect(attrs?.label).toBe("Carol");
|
||||
});
|
||||
|
||||
it("fromMarkdown returns null if id is missing", () => {
|
||||
const fakeMatch = ["", "", "label"] as unknown as RegExpExecArray;
|
||||
expect(s.fromMarkdown(fakeMatch)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SERIALIZER_LIST ordering", () => {
|
||||
it("databaseView comes before wikilink", () => {
|
||||
const dbIdx = SERIALIZER_LIST.findIndex((s) => s.nodeType === "database-view");
|
||||
const wlIdx = SERIALIZER_LIST.findIndex((s) => s.nodeType === "wikilink");
|
||||
expect(dbIdx).toBeLessThan(wlIdx);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,594 +0,0 @@
|
|||
/**
|
||||
* Round-trip tests for the markdown converter.
|
||||
*
|
||||
* Coverage targets:
|
||||
* - tiptapToMarkdown: Tiptap JSON -> markdown string
|
||||
* - markdownToTiptap: markdown string -> Tiptap JSON
|
||||
* - Round-trip (JSON -> MD -> JSON) structural equivalence
|
||||
* - Round-trip (MD -> JSON -> MD) textual equivalence (to normalisation)
|
||||
* - Custom Acadenice nodes: database-view, wikilink, mention
|
||||
* - Standard markdown features: headings, lists, tables, code, blockquote, hr, links, image
|
||||
* - Edge cases: empty doc, unknown nodes, malformed tokens, nested marks
|
||||
*
|
||||
* Total cases: 38
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { tiptapToMarkdown, markdownToTiptap } from "../services/markdown-converter";
|
||||
import type { TiptapNode } from "../services/markdown-converter";
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function doc(...content: TiptapNode[]): TiptapNode {
|
||||
return { type: "doc", content };
|
||||
}
|
||||
|
||||
function p(...content: TiptapNode[]): TiptapNode {
|
||||
return { type: "paragraph", content };
|
||||
}
|
||||
|
||||
function text(t: string, marks: TiptapNode["marks"] = []): TiptapNode {
|
||||
return marks.length ? { type: "text", text: t, marks } : { type: "text", text: t };
|
||||
}
|
||||
|
||||
function heading(level: number, ...content: TiptapNode[]): TiptapNode {
|
||||
return { type: "heading", attrs: { level }, content };
|
||||
}
|
||||
|
||||
function codeBlock(lang: string, code: string): TiptapNode {
|
||||
return {
|
||||
type: "codeBlock",
|
||||
attrs: { language: lang },
|
||||
content: [{ type: "text", text: code }],
|
||||
};
|
||||
}
|
||||
|
||||
function bulletList(...items: string[]): TiptapNode {
|
||||
return {
|
||||
type: "bulletList",
|
||||
content: items.map((i) => ({
|
||||
type: "listItem",
|
||||
content: [p(text(i))],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function orderedList(...items: string[]): TiptapNode {
|
||||
return {
|
||||
type: "orderedList",
|
||||
attrs: { start: 1 },
|
||||
content: items.map((i) => ({
|
||||
type: "listItem",
|
||||
content: [p(text(i))],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function hr(): TiptapNode {
|
||||
return { type: "horizontalRule" };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 1. tiptapToMarkdown — basic block nodes
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe("tiptapToMarkdown — headings", () => {
|
||||
it("serializes h1", () => {
|
||||
const { markdown } = tiptapToMarkdown(doc(heading(1, text("Title"))));
|
||||
expect(markdown).toBe("# Title");
|
||||
});
|
||||
|
||||
it("serializes h2", () => {
|
||||
const { markdown } = tiptapToMarkdown(doc(heading(2, text("Sub"))));
|
||||
expect(markdown).toBe("## Sub");
|
||||
});
|
||||
|
||||
it("serializes h6", () => {
|
||||
const { markdown } = tiptapToMarkdown(doc(heading(6, text("Deep"))));
|
||||
expect(markdown).toBe("###### Deep");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiptapToMarkdown — paragraphs and marks", () => {
|
||||
it("serializes plain paragraph", () => {
|
||||
const { markdown } = tiptapToMarkdown(doc(p(text("Hello world"))));
|
||||
expect(markdown).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("serializes bold text", () => {
|
||||
const { markdown } = tiptapToMarkdown(
|
||||
doc(p(text("bold", [{ type: "bold" }]))),
|
||||
);
|
||||
expect(markdown).toBe("**bold**");
|
||||
});
|
||||
|
||||
it("serializes italic text", () => {
|
||||
const { markdown } = tiptapToMarkdown(
|
||||
doc(p(text("italic", [{ type: "italic" }]))),
|
||||
);
|
||||
expect(markdown).toBe("_italic_");
|
||||
});
|
||||
|
||||
it("serializes inline code", () => {
|
||||
const { markdown } = tiptapToMarkdown(
|
||||
doc(p(text("x", [{ type: "code" }]))),
|
||||
);
|
||||
expect(markdown).toBe("`x`");
|
||||
});
|
||||
|
||||
it("serializes strikethrough", () => {
|
||||
const { markdown } = tiptapToMarkdown(
|
||||
doc(p(text("del", [{ type: "strike" }]))),
|
||||
);
|
||||
expect(markdown).toBe("~~del~~");
|
||||
});
|
||||
|
||||
it("serializes highlight", () => {
|
||||
const { markdown } = tiptapToMarkdown(
|
||||
doc(p(text("hi", [{ type: "highlight" }]))),
|
||||
);
|
||||
expect(markdown).toBe("==hi==");
|
||||
});
|
||||
|
||||
it("serializes link", () => {
|
||||
const { markdown } = tiptapToMarkdown(
|
||||
doc(p(text("click", [{ type: "link", attrs: { href: "https://example.com" } }]))),
|
||||
);
|
||||
expect(markdown).toBe("[click](https://example.com)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiptapToMarkdown — lists", () => {
|
||||
it("serializes bullet list", () => {
|
||||
const { markdown } = tiptapToMarkdown(doc(bulletList("alpha", "beta")));
|
||||
expect(markdown).toContain("- alpha");
|
||||
expect(markdown).toContain("- beta");
|
||||
});
|
||||
|
||||
it("serializes ordered list", () => {
|
||||
const { markdown } = tiptapToMarkdown(doc(orderedList("first", "second")));
|
||||
expect(markdown).toContain("1. first");
|
||||
expect(markdown).toContain("1. second");
|
||||
});
|
||||
|
||||
it("serializes task list", () => {
|
||||
const taskDoc: TiptapNode = doc({
|
||||
type: "taskList",
|
||||
content: [
|
||||
{ type: "taskItem", attrs: { checked: false }, content: [p(text("todo"))] },
|
||||
{ type: "taskItem", attrs: { checked: true }, content: [p(text("done"))] },
|
||||
],
|
||||
});
|
||||
const { markdown } = tiptapToMarkdown(taskDoc);
|
||||
expect(markdown).toContain("- [ ] todo");
|
||||
expect(markdown).toContain("- [x] done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiptapToMarkdown — code block", () => {
|
||||
it("serializes fenced code block with language", () => {
|
||||
const { markdown } = tiptapToMarkdown(doc(codeBlock("typescript", "const x = 1;")));
|
||||
expect(markdown).toBe("```typescript\nconst x = 1;\n```");
|
||||
});
|
||||
|
||||
it("serializes fenced code block without language", () => {
|
||||
const { markdown } = tiptapToMarkdown(doc(codeBlock("", "raw code")));
|
||||
expect(markdown).toBe("```\nraw code\n```");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiptapToMarkdown — blockquote", () => {
|
||||
it("serializes blockquote", () => {
|
||||
const bq: TiptapNode = {
|
||||
type: "blockquote",
|
||||
content: [p(text("quoted text"))],
|
||||
};
|
||||
const { markdown } = tiptapToMarkdown(doc(bq));
|
||||
expect(markdown).toContain("> quoted text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiptapToMarkdown — horizontal rule", () => {
|
||||
it("serializes hr", () => {
|
||||
const { markdown } = tiptapToMarkdown(doc(hr()));
|
||||
expect(markdown).toBe("---");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiptapToMarkdown — table", () => {
|
||||
it("serializes table with header + data row", () => {
|
||||
const table: TiptapNode = {
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableHeader", attrs: {}, content: [p(text("Name"))] },
|
||||
{ type: "tableHeader", attrs: {}, content: [p(text("Age"))] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableCell", attrs: {}, content: [p(text("Alice"))] },
|
||||
{ type: "tableCell", attrs: {}, content: [p(text("30"))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { markdown } = tiptapToMarkdown(doc(table));
|
||||
expect(markdown).toContain("| Name |");
|
||||
expect(markdown).toContain("| Age |");
|
||||
expect(markdown).toContain("| Alice |");
|
||||
expect(markdown).toContain("| 30 |");
|
||||
expect(markdown).toContain("---");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiptapToMarkdown — image", () => {
|
||||
it("serializes image node", () => {
|
||||
const img: TiptapNode = {
|
||||
type: "image",
|
||||
attrs: { src: "https://example.com/img.png", alt: "logo" },
|
||||
};
|
||||
const { markdown } = tiptapToMarkdown(doc(img));
|
||||
expect(markdown).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 2. tiptapToMarkdown — custom Acadenice nodes
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe("tiptapToMarkdown — custom nodes", () => {
|
||||
it("serializes database-view node", () => {
|
||||
const node: TiptapNode = {
|
||||
type: "database-view",
|
||||
attrs: { tableId: "42", viewId: "7", viewType: "grid", bridgeUrl: null },
|
||||
};
|
||||
const { markdown } = tiptapToMarkdown(doc(node));
|
||||
expect(markdown).toBe("[[!db tableId=42 viewId=7 viewType=grid]]");
|
||||
});
|
||||
|
||||
it("serializes wikilink without alias", () => {
|
||||
const node: TiptapNode = {
|
||||
type: "wikilink",
|
||||
attrs: { pageId: "uuid-1", title: "My Page", alias: null },
|
||||
};
|
||||
const { markdown } = tiptapToMarkdown(doc(p(node)));
|
||||
expect(markdown).toBe("[[My Page]]");
|
||||
});
|
||||
|
||||
it("serializes wikilink with alias", () => {
|
||||
const node: TiptapNode = {
|
||||
type: "wikilink",
|
||||
attrs: { pageId: "uuid-2", title: "Long Page Title", alias: "short" },
|
||||
};
|
||||
const { markdown } = tiptapToMarkdown(doc(p(node)));
|
||||
expect(markdown).toBe("[[Long Page Title|short]]");
|
||||
});
|
||||
|
||||
it("serializes mention node", () => {
|
||||
const node: TiptapNode = {
|
||||
type: "mention",
|
||||
attrs: { id: "user-uuid-42", label: "Alice" },
|
||||
};
|
||||
const { markdown } = tiptapToMarkdown(doc(p(node)));
|
||||
expect(markdown).toBe("@<user-uuid-42>(Alice)");
|
||||
});
|
||||
|
||||
it("produces no warnings for known custom nodes", () => {
|
||||
const node: TiptapNode = {
|
||||
type: "database-view",
|
||||
attrs: { tableId: "1", viewId: "2", viewType: "kanban", bridgeUrl: null },
|
||||
};
|
||||
const { warnings } = tiptapToMarkdown(doc(node));
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 3. markdownToTiptap — parsing
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe("markdownToTiptap — headings", () => {
|
||||
it("parses h1", () => {
|
||||
const { doc: d } = markdownToTiptap("# Hello");
|
||||
expect(d.content![0].type).toBe("heading");
|
||||
expect(d.content![0].attrs!.level).toBe(1);
|
||||
});
|
||||
|
||||
it("parses h3", () => {
|
||||
const { doc: d } = markdownToTiptap("### Section");
|
||||
expect(d.content![0].attrs!.level).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownToTiptap — paragraphs and marks", () => {
|
||||
it("parses plain paragraph", () => {
|
||||
const { doc: d } = markdownToTiptap("Hello");
|
||||
expect(d.content![0].type).toBe("paragraph");
|
||||
expect(d.content![0].content![0].text).toBe("Hello");
|
||||
});
|
||||
|
||||
it("parses bold", () => {
|
||||
const { doc: d } = markdownToTiptap("**bold**");
|
||||
const marks = d.content![0].content![0].marks;
|
||||
expect(marks?.some((m) => m.type === "bold")).toBe(true);
|
||||
});
|
||||
|
||||
it("parses inline code", () => {
|
||||
const { doc: d } = markdownToTiptap("`code`");
|
||||
const marks = d.content![0].content![0].marks;
|
||||
expect(marks?.some((m) => m.type === "code")).toBe(true);
|
||||
});
|
||||
|
||||
it("parses link", () => {
|
||||
const { doc: d } = markdownToTiptap("[text](https://example.com)");
|
||||
const marks = d.content![0].content![0].marks;
|
||||
expect(marks?.some((m) => m.type === "link")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownToTiptap — lists", () => {
|
||||
it("parses bullet list", () => {
|
||||
const { doc: d } = markdownToTiptap("- item one\n- item two");
|
||||
expect(d.content![0].type).toBe("bulletList");
|
||||
expect(d.content![0].content).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("parses ordered list", () => {
|
||||
const { doc: d } = markdownToTiptap("1. first\n2. second");
|
||||
expect(d.content![0].type).toBe("orderedList");
|
||||
});
|
||||
|
||||
it("parses task list", () => {
|
||||
const { doc: d } = markdownToTiptap("- [ ] todo\n- [x] done");
|
||||
expect(d.content![0].type).toBe("taskList");
|
||||
expect(d.content![0].content![0].attrs!.checked).toBe(false);
|
||||
expect(d.content![0].content![1].attrs!.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownToTiptap — custom nodes", () => {
|
||||
it("parses database-view token", () => {
|
||||
const { doc: d } = markdownToTiptap("[[!db tableId=42 viewId=7 viewType=grid]]");
|
||||
expect(d.content![0].type).toBe("database-view");
|
||||
expect(d.content![0].attrs!.tableId).toBe("42");
|
||||
expect(d.content![0].attrs!.viewType).toBe("grid");
|
||||
});
|
||||
|
||||
it("parses wikilink without alias", () => {
|
||||
const { doc: d } = markdownToTiptap("[[My Page]]");
|
||||
// wikilink is inline; it lives inside a paragraph
|
||||
const para = d.content![0];
|
||||
expect(para.content?.some((n) => n.type === "wikilink")).toBe(true);
|
||||
});
|
||||
|
||||
it("parses wikilink with alias", () => {
|
||||
const { doc: d } = markdownToTiptap("[[Long Title|short]]");
|
||||
const wl = d.content![0].content?.find((n) => n.type === "wikilink");
|
||||
expect(wl?.attrs?.title).toBe("Long Title");
|
||||
expect(wl?.attrs?.alias).toBe("short");
|
||||
});
|
||||
|
||||
it("parses mention token", () => {
|
||||
const { doc: d } = markdownToTiptap("@<user-uuid-42>(Alice)");
|
||||
const mention = d.content![0].content?.find((n) => n.type === "mention");
|
||||
expect(mention?.attrs?.id).toBe("user-uuid-42");
|
||||
expect(mention?.attrs?.label).toBe("Alice");
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 4. Round-trip: JSON -> MD -> JSON
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe("round-trip JSON -> MD -> JSON", () => {
|
||||
function roundTripDocToDoc(input: TiptapNode): TiptapNode {
|
||||
const { markdown } = tiptapToMarkdown(input);
|
||||
const { doc: output } = markdownToTiptap(markdown);
|
||||
return output;
|
||||
}
|
||||
|
||||
it("preserves heading level", () => {
|
||||
const input = doc(heading(2, text("Section")));
|
||||
const output = roundTripDocToDoc(input);
|
||||
const h = output.content?.find((n) => n.type === "heading");
|
||||
expect(h?.attrs?.level).toBe(2);
|
||||
});
|
||||
|
||||
it("preserves database-view attrs", () => {
|
||||
const input = doc({
|
||||
type: "database-view",
|
||||
attrs: { tableId: "99", viewId: "3", viewType: "kanban", bridgeUrl: null },
|
||||
});
|
||||
const output = roundTripDocToDoc(input);
|
||||
const node = output.content?.find((n) => n.type === "database-view");
|
||||
expect(node?.attrs?.tableId).toBe("99");
|
||||
expect(node?.attrs?.viewType).toBe("kanban");
|
||||
});
|
||||
|
||||
it("preserves wikilink title and alias", () => {
|
||||
const input = doc(p({ type: "wikilink", attrs: { pageId: "x", title: "Page A", alias: "A" } }));
|
||||
const output = roundTripDocToDoc(input);
|
||||
const wl = output.content?.[0].content?.find((n) => n.type === "wikilink");
|
||||
expect(wl?.attrs?.title).toBe("Page A");
|
||||
expect(wl?.attrs?.alias).toBe("A");
|
||||
});
|
||||
|
||||
it("preserves mention id", () => {
|
||||
const input = doc(p({ type: "mention", attrs: { id: "uid-1", label: "Bob" } }));
|
||||
const output = roundTripDocToDoc(input);
|
||||
const m = output.content?.[0].content?.find((n) => n.type === "mention");
|
||||
expect(m?.attrs?.id).toBe("uid-1");
|
||||
});
|
||||
|
||||
it("preserves code block language", () => {
|
||||
const input = doc(codeBlock("python", "print('hi')"));
|
||||
const output = roundTripDocToDoc(input);
|
||||
const cb = output.content?.find((n) => n.type === "codeBlock");
|
||||
expect(cb?.attrs?.language).toBe("python");
|
||||
});
|
||||
|
||||
it("preserves bullet list items count", () => {
|
||||
const input = doc(bulletList("a", "b", "c"));
|
||||
const output = roundTripDocToDoc(input);
|
||||
const bl = output.content?.find((n) => n.type === "bulletList");
|
||||
expect(bl?.content?.length).toBe(3);
|
||||
});
|
||||
|
||||
it("preserves task list checked state", () => {
|
||||
const input: TiptapNode = doc({
|
||||
type: "taskList",
|
||||
content: [
|
||||
{ type: "taskItem", attrs: { checked: true }, content: [p(text("done"))] },
|
||||
{ type: "taskItem", attrs: { checked: false }, content: [p(text("todo"))] },
|
||||
],
|
||||
});
|
||||
const output = roundTripDocToDoc(input);
|
||||
const tl = output.content?.find((n) => n.type === "taskList");
|
||||
expect(tl?.content?.[0].attrs?.checked).toBe(true);
|
||||
expect(tl?.content?.[1].attrs?.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves horizontal rule", () => {
|
||||
const input = doc(p(text("before")), hr(), p(text("after")));
|
||||
const output = roundTripDocToDoc(input);
|
||||
expect(output.content?.some((n) => n.type === "horizontalRule")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 5. Round-trip: MD -> JSON -> MD
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe("round-trip MD -> JSON -> MD", () => {
|
||||
function roundTripMdToMd(input: string): string {
|
||||
const { doc: d } = markdownToTiptap(input);
|
||||
const { markdown } = tiptapToMarkdown(d);
|
||||
return markdown;
|
||||
}
|
||||
|
||||
it("preserves heading", () => {
|
||||
expect(roundTripMdToMd("# Hello")).toBe("# Hello");
|
||||
});
|
||||
|
||||
it("preserves database-view token", () => {
|
||||
const token = "[[!db tableId=10 viewId=5 viewType=grid]]";
|
||||
expect(roundTripMdToMd(token)).toBe(token);
|
||||
});
|
||||
|
||||
it("preserves wikilink without alias", () => {
|
||||
expect(roundTripMdToMd("[[My Page]]")).toContain("[[My Page]]");
|
||||
});
|
||||
|
||||
it("preserves wikilink with alias", () => {
|
||||
expect(roundTripMdToMd("[[Long Title|short]]")).toContain("[[Long Title|short]]");
|
||||
});
|
||||
|
||||
it("preserves mention", () => {
|
||||
expect(roundTripMdToMd("@<uid-99>(Carol)")).toContain("@<uid-99>(Carol)");
|
||||
});
|
||||
|
||||
it("preserves fenced code block", () => {
|
||||
const input = "```javascript\nconsole.log('hi');\n```";
|
||||
expect(roundTripMdToMd(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("preserves bullet list", () => {
|
||||
const output = roundTripMdToMd("- alpha\n- beta");
|
||||
expect(output).toContain("- alpha");
|
||||
expect(output).toContain("- beta");
|
||||
});
|
||||
|
||||
it("preserves bold", () => {
|
||||
expect(roundTripMdToMd("**bold**")).toContain("**bold**");
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 6. Edge cases
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("returns empty doc with single paragraph for empty string", () => {
|
||||
const { doc: d } = markdownToTiptap("");
|
||||
expect(d.type).toBe("doc");
|
||||
expect(d.content?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles doc with no content gracefully", () => {
|
||||
const empty: TiptapNode = { type: "doc", content: [] };
|
||||
const { markdown } = tiptapToMarkdown(empty);
|
||||
expect(markdown).toBe("");
|
||||
});
|
||||
|
||||
it("emits warning for unknown node type (to-markdown)", () => {
|
||||
const unknown: TiptapNode = { type: "unknown-node", attrs: {} };
|
||||
const { warnings } = tiptapToMarkdown(doc(unknown));
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not crash on deeply nested unknown nodes", () => {
|
||||
const nested: TiptapNode = {
|
||||
type: "unknown-wrapper",
|
||||
content: [p(text("inner text"))],
|
||||
};
|
||||
const { markdown, warnings } = tiptapToMarkdown(doc(nested));
|
||||
// Text content is preserved even though the wrapper is unknown
|
||||
expect(markdown).toContain("inner text");
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles wikilink with no title gracefully", () => {
|
||||
// Malformed wikilink token — should not crash
|
||||
const { doc: d, warnings } = markdownToTiptap("[[]]");
|
||||
expect(d.type).toBe("doc");
|
||||
// The malformed token is parsed as a text paragraph (the regex does not match empty titles)
|
||||
// No hard crash expected
|
||||
});
|
||||
|
||||
it("wikilink pageId is null after round-trip (cannot be re-resolved from markdown)", () => {
|
||||
const { doc: d } = markdownToTiptap("[[Some Page]]");
|
||||
const wl = d.content?.[0].content?.find((n) => n.type === "wikilink");
|
||||
expect(wl?.attrs?.pageId).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves multiline code block content exactly", () => {
|
||||
const code = "line1\nline2\nline3";
|
||||
const { markdown } = tiptapToMarkdown(doc(codeBlock("", code)));
|
||||
const { doc: back } = markdownToTiptap(markdown);
|
||||
const cb = back.content?.find((n) => n.type === "codeBlock");
|
||||
expect(cb?.content?.[0].text).toBe(code);
|
||||
});
|
||||
|
||||
it("table round-trip preserves cell count", () => {
|
||||
const table: TiptapNode = {
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableHeader", attrs: {}, content: [p(text("A"))] },
|
||||
{ type: "tableHeader", attrs: {}, content: [p(text("B"))] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableCell", attrs: {}, content: [p(text("1"))] },
|
||||
{ type: "tableCell", attrs: {}, content: [p(text("2"))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const { markdown } = tiptapToMarkdown(doc(table));
|
||||
const { doc: back } = markdownToTiptap(markdown);
|
||||
const bt = back.content?.find((n) => n.type === "table");
|
||||
// Header + data row
|
||||
expect(bt?.content?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/**
|
||||
* Unit tests for useEditorMode hook + localStorage persistence.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { initEditorMode } from "../hooks/use-editor-mode";
|
||||
|
||||
// Minimal localStorage mock for jsdom environments that may not persist correctly.
|
||||
const storeMock: Record<string, string> = {};
|
||||
const localStorageMock = {
|
||||
getItem: (k: string) => storeMock[k] ?? null,
|
||||
setItem: (k: string, v: string) => { storeMock[k] = v; },
|
||||
removeItem: (k: string) => { delete storeMock[k]; },
|
||||
clear: () => { for (const k of Object.keys(storeMock)) delete storeMock[k]; },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
describe("initEditorMode", () => {
|
||||
it("defaults to wysiwyg when no value stored", () => {
|
||||
const setter = vi.fn();
|
||||
initEditorMode("page-1", setter);
|
||||
expect(setter).toHaveBeenCalledWith("wysiwyg");
|
||||
});
|
||||
|
||||
it("reads wysiwyg from localStorage", () => {
|
||||
localStorage.setItem("acadenice:editor-mode:page-2", "wysiwyg");
|
||||
const setter = vi.fn();
|
||||
initEditorMode("page-2", setter);
|
||||
expect(setter).toHaveBeenCalledWith("wysiwyg");
|
||||
});
|
||||
|
||||
it("reads markdown from localStorage", () => {
|
||||
localStorage.setItem("acadenice:editor-mode:page-3", "markdown");
|
||||
const setter = vi.fn();
|
||||
initEditorMode("page-3", setter);
|
||||
expect(setter).toHaveBeenCalledWith("markdown");
|
||||
});
|
||||
|
||||
it("ignores invalid stored value and defaults to wysiwyg", () => {
|
||||
localStorage.setItem("acadenice:editor-mode:page-4", "invalid-mode");
|
||||
const setter = vi.fn();
|
||||
initEditorMode("page-4", setter);
|
||||
expect(setter).toHaveBeenCalledWith("wysiwyg");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
/**
|
||||
* DualEditor — wrapper that hosts either the Tiptap WYSIWYG editor or
|
||||
* the raw markdown source editor (MarkdownEditor).
|
||||
*
|
||||
* Switch logic:
|
||||
* WYSIWYG -> Markdown: serialize the live Tiptap doc to markdown via
|
||||
* tiptapToMarkdown(). If warnings are present, a confirmation modal is
|
||||
* shown listing the potential data loss.
|
||||
* Markdown -> WYSIWYG: parse the current markdown string to a Tiptap JSON
|
||||
* doc via markdownToTiptap(), then call editor.commands.setContent(doc).
|
||||
*
|
||||
* Persistence:
|
||||
* The mode choice is persisted per-page in localStorage.
|
||||
* The source of truth for the document content is always the Tiptap JSON
|
||||
* (stored in the DB). The markdown view is ephemeral.
|
||||
*
|
||||
* Usage:
|
||||
* Replace <PageEditor> with <DualEditor> in full-editor.tsx.
|
||||
* All PageEditor props are forwarded; the DualEditor adds the mode toggle
|
||||
* button in the toolbar area.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { ModeToggleButton } from "./mode-toggle-button";
|
||||
import { MarkdownEditor } from "./markdown-editor";
|
||||
import {
|
||||
editorModeAtom,
|
||||
initEditorMode,
|
||||
type EditorMode,
|
||||
useEditorMode,
|
||||
} from "../hooks/use-editor-mode";
|
||||
import { tiptapToMarkdown, markdownToTiptap } from "../services/markdown-converter";
|
||||
import type { ConversionWarning } from "../services/markdown-converter";
|
||||
|
||||
interface DualEditorProps {
|
||||
/** The Tiptap WYSIWYG editor rendered as children. */
|
||||
children: React.ReactNode;
|
||||
pageId: string;
|
||||
/** Whether the page is editable (controls toggle visibility). */
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
interface SwitchWarningModalProps {
|
||||
warnings: ConversionWarning[];
|
||||
direction: "to-markdown" | "to-wysiwyg";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function SwitchWarningModal({
|
||||
warnings,
|
||||
direction,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: SwitchWarningModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={onCancel}
|
||||
title={t("dual_editor.switch_warning_title")}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert icon={<IconAlertTriangle size={16} />} color="yellow">
|
||||
{direction === "to-markdown"
|
||||
? t("dual_editor.switch_warning_to_md")
|
||||
: t("dual_editor.switch_warning_to_wysiwyg")}
|
||||
</Alert>
|
||||
<Stack gap={4}>
|
||||
{warnings.map((w, i) => (
|
||||
<Text key={i} size="sm" c="dimmed">
|
||||
{`[${w.nodeType}] ${w.message}`}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onCancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button color="yellow" onClick={onConfirm}>
|
||||
{t("dual_editor.switch_anyway")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function DualEditor({ children, pageId, editable }: DualEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editor] = useAtom(pageEditorAtom);
|
||||
const [mode, setMode] = useEditorMode(pageId);
|
||||
|
||||
// Raw markdown content while in markdown mode
|
||||
const [markdownValue, setMarkdownValue] = useState("");
|
||||
|
||||
// Pending switch confirmation state
|
||||
const [pendingSwitch, setPendingSwitch] = useState<{
|
||||
direction: "to-markdown" | "to-wysiwyg";
|
||||
warnings: ConversionWarning[];
|
||||
markdownSnapshot?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Track pageId changes to reset mode
|
||||
const prevPageId = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (prevPageId.current !== pageId) {
|
||||
prevPageId.current = pageId;
|
||||
initEditorMode(pageId, setMode);
|
||||
// Reset to empty markdown; it will be repopulated on next WYSIWYG->MD switch.
|
||||
setMarkdownValue("");
|
||||
}
|
||||
}, [pageId, setMode]);
|
||||
|
||||
const switchToMarkdown = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const doc = editor.getJSON();
|
||||
const { markdown, warnings } = tiptapToMarkdown(doc as any);
|
||||
|
||||
if (warnings.length > 0) {
|
||||
// Ask for confirmation before switching
|
||||
setPendingSwitch({ direction: "to-markdown", warnings, markdownSnapshot: markdown });
|
||||
return;
|
||||
}
|
||||
|
||||
setMarkdownValue(markdown);
|
||||
setMode("markdown");
|
||||
}, [editor, setMode]);
|
||||
|
||||
const switchToWysiwyg = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { doc, warnings } = markdownToTiptap(markdownValue);
|
||||
|
||||
if (warnings.length > 0) {
|
||||
setPendingSwitch({ direction: "to-wysiwyg", warnings });
|
||||
return;
|
||||
}
|
||||
|
||||
editor.commands.setContent(doc as any, { emitUpdate: false });
|
||||
setMode("wysiwyg");
|
||||
}, [editor, markdownValue, setMode]);
|
||||
|
||||
function handleToggle() {
|
||||
if (!editable) return;
|
||||
if (mode === "wysiwyg") {
|
||||
switchToMarkdown();
|
||||
} else {
|
||||
switchToWysiwyg();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmSwitch() {
|
||||
if (!pendingSwitch) return;
|
||||
|
||||
if (pendingSwitch.direction === "to-markdown" && pendingSwitch.markdownSnapshot !== undefined) {
|
||||
setMarkdownValue(pendingSwitch.markdownSnapshot);
|
||||
setMode("markdown");
|
||||
} else if (pendingSwitch.direction === "to-wysiwyg" && editor) {
|
||||
const { doc } = markdownToTiptap(markdownValue);
|
||||
editor.commands.setContent(doc as any, { emitUpdate: false });
|
||||
setMode("wysiwyg");
|
||||
}
|
||||
|
||||
setPendingSwitch(null);
|
||||
}
|
||||
|
||||
function cancelSwitch() {
|
||||
setPendingSwitch(null);
|
||||
}
|
||||
|
||||
// When in markdown mode, sync back to Tiptap JSON on every change so that
|
||||
// the page save mechanism (which reads from the Tiptap doc) stays in sync.
|
||||
useEffect(() => {
|
||||
if (mode !== "markdown" || !editor) return;
|
||||
const { doc } = markdownToTiptap(markdownValue);
|
||||
editor.commands.setContent(doc as any, { emitUpdate: false });
|
||||
}, [markdownValue, mode, editor]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* Toggle button — positioned at the top-right of the editor area */}
|
||||
{editable && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-2.5rem",
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
className="print-hide"
|
||||
>
|
||||
<ModeToggleButton
|
||||
mode={mode}
|
||||
onToggle={handleToggle}
|
||||
disabled={!editor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning modal for lossy switches */}
|
||||
{pendingSwitch && (
|
||||
<SwitchWarningModal
|
||||
warnings={pendingSwitch.warnings}
|
||||
direction={pendingSwitch.direction}
|
||||
onConfirm={confirmSwitch}
|
||||
onCancel={cancelSwitch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Editor content */}
|
||||
{mode === "wysiwyg" ? (
|
||||
children
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
value={markdownValue}
|
||||
onChange={setMarkdownValue}
|
||||
pageId={pageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* Raw markdown source editor.
|
||||
*
|
||||
* Uses a plain <textarea> as the code editor backend.
|
||||
*
|
||||
* Rationale for textarea over CodeMirror/Monaco:
|
||||
* - Neither @codemirror/* nor monaco-editor is in the current dependency tree.
|
||||
* - Adding CodeMirror 6 requires pnpm install (explicitly forbidden in scope).
|
||||
* - A styled <textarea> with monospace font is sufficient for prod-like raw
|
||||
* markdown editing and has zero bundle cost.
|
||||
* - If CodeMirror 6 is added later, it is a drop-in replacement: same
|
||||
* value/onChange contract.
|
||||
*
|
||||
* The textarea is intentionally unstyled beyond the monospace font so it
|
||||
* inherits the Mantine theme correctly.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Textarea } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Aria-described page id for accessibility. */
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlled markdown source textarea.
|
||||
*
|
||||
* The component auto-sizes its height to the content (up to 100vh).
|
||||
* Tab key inserts two spaces instead of moving focus.
|
||||
*/
|
||||
export function MarkdownEditor({ value, onChange, pageId }: MarkdownEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize height to content
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value]);
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const next = value.substring(0, start) + " " + value.substring(end);
|
||||
onChange(next);
|
||||
// Restore cursor after React re-render
|
||||
requestAnimationFrame(() => {
|
||||
el.selectionStart = start + 2;
|
||||
el.selectionEnd = start + 2;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autosize
|
||||
minRows={8}
|
||||
styles={{
|
||||
input: {
|
||||
fontFamily: "var(--mantine-font-family-monospace, monospace)",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
resize: "none",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
padding: "1rem 0",
|
||||
background: "transparent",
|
||||
},
|
||||
}}
|
||||
aria-label={t("dual_editor.markdown_editor_label")}
|
||||
aria-describedby={pageId ? `page-${pageId}` : undefined}
|
||||
data-testid="markdown-source-editor"
|
||||
spellCheck={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import React from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconCode, IconEye } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { EditorMode } from "../hooks/use-editor-mode";
|
||||
|
||||
interface ModeToggleButtonProps {
|
||||
mode: EditorMode;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle button shown in the editor toolbar.
|
||||
*
|
||||
* WYSIWYG mode: shows <IconCode> (click -> switch to markdown)
|
||||
* Markdown mode: shows <IconEye> (click -> switch to WYSIWYG)
|
||||
*
|
||||
* Disabled when the editor is read-only (editable=false).
|
||||
*/
|
||||
export function ModeToggleButton({
|
||||
mode,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
}: ModeToggleButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const label =
|
||||
mode === "wysiwyg"
|
||||
? t("dual_editor.switch_to_markdown")
|
||||
: t("dual_editor.switch_to_wysiwyg");
|
||||
|
||||
return (
|
||||
<Tooltip label={label} withArrow position="bottom">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={mode === "markdown" ? "blue" : "gray"}
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
data-testid="dual-editor-mode-toggle"
|
||||
>
|
||||
{mode === "wysiwyg" ? (
|
||||
<IconCode size={18} stroke={1.5} />
|
||||
) : (
|
||||
<IconEye size={18} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* Editor mode atom + persistence hook.
|
||||
*
|
||||
* The chosen mode ('wysiwyg' | 'markdown') is persisted per page in
|
||||
* localStorage under the key `acadenice:editor-mode:<pageId>`.
|
||||
*
|
||||
* On first visit to a page the mode defaults to 'wysiwyg'.
|
||||
*/
|
||||
|
||||
import { atom, useAtom } from "jotai";
|
||||
|
||||
export type EditorMode = "wysiwyg" | "markdown";
|
||||
|
||||
const STORAGE_PREFIX = "acadenice:editor-mode:";
|
||||
|
||||
function storageKey(pageId: string): string {
|
||||
return `${STORAGE_PREFIX}${pageId}`;
|
||||
}
|
||||
|
||||
function readFromStorage(pageId: string): EditorMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(pageId));
|
||||
if (raw === "markdown" || raw === "wysiwyg") return raw;
|
||||
} catch {
|
||||
// localStorage may be unavailable in some environments (tests, SSR).
|
||||
}
|
||||
return "wysiwyg";
|
||||
}
|
||||
|
||||
function writeToStorage(pageId: string, mode: EditorMode): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey(pageId), mode);
|
||||
} catch {
|
||||
// Ignore write errors (private browsing / quota exceeded).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global atom that holds the current editor mode.
|
||||
* Initialised to 'wysiwyg'; the hook below reads/writes localStorage.
|
||||
*/
|
||||
export const editorModeAtom = atom<EditorMode>("wysiwyg");
|
||||
|
||||
/**
|
||||
* useEditorMode — returns [mode, setMode] for the given page.
|
||||
*
|
||||
* On first call for a pageId the mode is loaded from localStorage.
|
||||
* Subsequent calls within the same render tree share the same atom value.
|
||||
*
|
||||
* Persistence: every set() call writes to localStorage.
|
||||
*/
|
||||
export function useEditorMode(pageId: string): [EditorMode, (mode: EditorMode) => void] {
|
||||
const [mode, setModeAtom] = useAtom(editorModeAtom);
|
||||
|
||||
// Initialise from localStorage on first mount — this runs during render,
|
||||
// which is acceptable because it's synchronous and side-effect-free from
|
||||
// React's perspective (localStorage is not the React tree).
|
||||
// We rely on the atom default and let the first render of DualEditor
|
||||
// call initMode() to sync.
|
||||
|
||||
function setMode(next: EditorMode): void {
|
||||
writeToStorage(pageId, next);
|
||||
setModeAtom(next);
|
||||
}
|
||||
|
||||
return [mode, setMode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and apply the persisted mode for a given page.
|
||||
* Should be called once per page mount (inside a useEffect).
|
||||
*/
|
||||
export function initEditorMode(
|
||||
pageId: string,
|
||||
setMode: (mode: EditorMode) => void,
|
||||
): void {
|
||||
const persisted = readFromStorage(pageId);
|
||||
setMode(persisted);
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
/**
|
||||
* Registry of Acadenice custom node serializers for markdown round-trip.
|
||||
*
|
||||
* Each entry maps a Tiptap node type name to a pair of functions:
|
||||
* toMarkdown(node): string — Tiptap JSON node -> markdown string
|
||||
* fromMarkdown(match): attrs | null — regex match -> Tiptap node attrs
|
||||
*
|
||||
* The registry is consumed by markdown-converter.ts.
|
||||
*
|
||||
* Syntax choices (reversible, no HTML inline):
|
||||
* databaseView -> [[!db tableId=<X> viewId=<Y> viewType=<Z>]]
|
||||
* wikilink -> [[Page Title]] or [[Page Title|alias]]
|
||||
* mention -> @<userId>(<name>)
|
||||
*/
|
||||
|
||||
export interface CustomNodeSerializer {
|
||||
/** Regex that matches this node's markdown syntax. Must have named groups. */
|
||||
pattern: RegExp;
|
||||
/** Convert Tiptap JSON node attrs to a markdown token string. */
|
||||
toMarkdown: (attrs: Record<string, unknown>) => string;
|
||||
/**
|
||||
* Convert a regex match (from `pattern`) to Tiptap node attrs.
|
||||
* Returns null if the match is invalid / incomplete.
|
||||
*/
|
||||
fromMarkdown: (match: RegExpExecArray) => Record<string, unknown> | null;
|
||||
/** The Tiptap node type name to create when parsing. */
|
||||
nodeType: string;
|
||||
/**
|
||||
* Whether this node occupies a full block line on its own (e.g. database-view).
|
||||
* Inline-only nodes (wikilink, mention) must set this to false so they are
|
||||
* never consumed by the block-level parser (they are parsed inline instead).
|
||||
*/
|
||||
isBlock: boolean;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// databaseView
|
||||
// --------------------------------------------------------------------------
|
||||
// Syntax: [[!db tableId=42 viewId=7 viewType=grid]]
|
||||
// The "!" prefix distinguishes database embeds from regular wikilinks.
|
||||
|
||||
const DATABASE_VIEW_PATTERN =
|
||||
/\[\[!db tableId=([^\s\]]+) viewId=([^\s\]]+) viewType=([^\s\]]+)\]\]/g;
|
||||
|
||||
const databaseViewSerializer: CustomNodeSerializer = {
|
||||
nodeType: "database-view",
|
||||
isBlock: true,
|
||||
pattern: DATABASE_VIEW_PATTERN,
|
||||
toMarkdown(attrs) {
|
||||
const tableId = String(attrs.tableId ?? "");
|
||||
const viewId = String(attrs.viewId ?? "");
|
||||
const viewType = String(attrs.viewType ?? "grid");
|
||||
return `[[!db tableId=${tableId} viewId=${viewId} viewType=${viewType}]]`;
|
||||
},
|
||||
fromMarkdown(match) {
|
||||
const [, tableId, viewId, viewType] = match;
|
||||
if (!tableId || !viewId) return null;
|
||||
return { tableId, viewId, viewType: viewType ?? "grid", bridgeUrl: null };
|
||||
},
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// wikilink
|
||||
// --------------------------------------------------------------------------
|
||||
// Syntax: [[Page Title]] or [[Page Title|alias]]
|
||||
// Matches only after we've excluded the !db prefix.
|
||||
|
||||
const WIKILINK_PATTERN = /\[\[(?!!db )([^\]|]+?)(?:\|([^\]]*))?\]\]/g;
|
||||
|
||||
const wikilinkSerializer: CustomNodeSerializer = {
|
||||
nodeType: "wikilink",
|
||||
isBlock: false,
|
||||
pattern: WIKILINK_PATTERN,
|
||||
toMarkdown(attrs) {
|
||||
const title = String(attrs.title ?? "");
|
||||
const alias = attrs.alias ? String(attrs.alias) : null;
|
||||
return alias ? `[[${title}|${alias}]]` : `[[${title}]]`;
|
||||
},
|
||||
fromMarkdown(match) {
|
||||
const [, title, alias] = match;
|
||||
if (!title) return null;
|
||||
return {
|
||||
pageId: null,
|
||||
title: title.trim(),
|
||||
alias: alias ? alias.trim() : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// mention
|
||||
// --------------------------------------------------------------------------
|
||||
// Syntax: @<userId>(<name>)
|
||||
// The userId is preserved to avoid lossy round-trips (display name alone
|
||||
// is not sufficient to re-resolve the user without a server lookup).
|
||||
|
||||
const MENTION_PATTERN = /@<([^>]+)>\(([^)]*)\)/g;
|
||||
|
||||
const mentionSerializer: CustomNodeSerializer = {
|
||||
nodeType: "mention",
|
||||
isBlock: false,
|
||||
pattern: MENTION_PATTERN,
|
||||
toMarkdown(attrs) {
|
||||
const id = String(attrs.id ?? "");
|
||||
const label = String(attrs.label ?? attrs.id ?? "");
|
||||
return `@<${id}>(${label})`;
|
||||
},
|
||||
fromMarkdown(match) {
|
||||
const [, id, label] = match;
|
||||
if (!id) return null;
|
||||
return { id, label };
|
||||
},
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Public registry
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export const CUSTOM_NODE_SERIALIZERS: Record<string, CustomNodeSerializer> = {
|
||||
"database-view": databaseViewSerializer,
|
||||
wikilink: wikilinkSerializer,
|
||||
mention: mentionSerializer,
|
||||
};
|
||||
|
||||
/**
|
||||
* List of serializers in parse order.
|
||||
* databaseView must be before wikilink (its pattern is more specific).
|
||||
* Consumers that only want block-level serializers should filter by `isBlock`.
|
||||
*/
|
||||
export const SERIALIZER_LIST: CustomNodeSerializer[] = [
|
||||
databaseViewSerializer,
|
||||
wikilinkSerializer,
|
||||
mentionSerializer,
|
||||
];
|
||||
|
|
@ -1,734 +0,0 @@
|
|||
/**
|
||||
* Bidirectional converter: Tiptap JSON <-> Markdown.
|
||||
*
|
||||
* Source of truth is always the Tiptap JSON (persisted to DB).
|
||||
* Markdown is a human-readable projection; it is never stored directly.
|
||||
*
|
||||
* Design decisions:
|
||||
* - Pure TypeScript, no external markdown library.
|
||||
* Docmost does not bundle tiptap-markdown/prosemirror-markdown; adding a
|
||||
* heavy parser would bloat the bundle. A custom serializer keeps the dependency
|
||||
* count flat and gives full control over custom-node syntax.
|
||||
* - Block nodes are serialized top-down, marks are applied inline.
|
||||
* - Unknown nodes fall back to their text content (no silent data loss).
|
||||
* - Round-trip fidelity: JSON -> MD -> JSON produces a structurally equivalent
|
||||
* document (IDs and transient attrs may differ).
|
||||
*
|
||||
* Supported node types:
|
||||
* doc, paragraph, text, hardBreak,
|
||||
* heading (1-6), blockquote, codeBlock,
|
||||
* bulletList, orderedList, listItem,
|
||||
* taskList, taskItem,
|
||||
* table, tableRow, tableCell, tableHeader,
|
||||
* horizontalRule, image,
|
||||
* -- Acadenice custom --
|
||||
* database-view, wikilink, mention
|
||||
*
|
||||
* Supported marks:
|
||||
* bold, italic, code, strike, underline, link, highlight
|
||||
*/
|
||||
|
||||
import {
|
||||
CUSTOM_NODE_SERIALIZERS,
|
||||
SERIALIZER_LIST,
|
||||
} from "./custom-node-serializers";
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Types
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export interface TiptapMark {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TiptapNode {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TiptapNode[];
|
||||
marks?: TiptapMark[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface ConversionWarning {
|
||||
nodeType: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TiptapToMarkdownResult {
|
||||
markdown: string;
|
||||
warnings: ConversionWarning[];
|
||||
}
|
||||
|
||||
export interface MarkdownToTiptapResult {
|
||||
doc: TiptapNode;
|
||||
warnings: ConversionWarning[];
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Tiptap -> Markdown
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function applyMarks(text: string, marks: TiptapMark[]): string {
|
||||
let result = text;
|
||||
for (const mark of marks) {
|
||||
switch (mark.type) {
|
||||
case "bold":
|
||||
result = `**${result}**`;
|
||||
break;
|
||||
case "italic":
|
||||
result = `_${result}_`;
|
||||
break;
|
||||
case "code":
|
||||
result = `\`${result}\``;
|
||||
break;
|
||||
case "strike":
|
||||
result = `~~${result}~~`;
|
||||
break;
|
||||
case "underline":
|
||||
// No standard markdown for underline; use HTML span to preserve intent.
|
||||
result = `<u>${result}</u>`;
|
||||
break;
|
||||
case "link": {
|
||||
const href = String(mark.attrs?.href ?? "");
|
||||
result = `[${result}](${href})`;
|
||||
break;
|
||||
}
|
||||
case "highlight": {
|
||||
result = `==${result}==`;
|
||||
break;
|
||||
}
|
||||
// Unknown marks: pass-through (text is preserved)
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeInlineNode(
|
||||
node: TiptapNode,
|
||||
warnings: ConversionWarning[],
|
||||
): string {
|
||||
if (node.type === "text") {
|
||||
const raw = node.text ?? "";
|
||||
const marks = node.marks ?? [];
|
||||
return applyMarks(raw, marks);
|
||||
}
|
||||
|
||||
if (node.type === "hardBreak") {
|
||||
return " \n";
|
||||
}
|
||||
|
||||
// Custom Acadenice nodes (inline: wikilink, mention)
|
||||
const customSerializer = CUSTOM_NODE_SERIALIZERS[node.type];
|
||||
if (customSerializer && node.attrs) {
|
||||
return customSerializer.toMarkdown(node.attrs);
|
||||
}
|
||||
|
||||
// Unknown inline node: emit text content if any
|
||||
warnings.push({
|
||||
nodeType: node.type,
|
||||
message: `Unknown inline node type "${node.type}" — text content preserved`,
|
||||
});
|
||||
return serializeContent(node.content ?? [], warnings);
|
||||
}
|
||||
|
||||
function serializeContent(
|
||||
nodes: TiptapNode[],
|
||||
warnings: ConversionWarning[],
|
||||
): string {
|
||||
return nodes.map((n) => serializeInlineNode(n, warnings)).join("");
|
||||
}
|
||||
|
||||
function serializeTableRow(
|
||||
row: TiptapNode,
|
||||
isHeader: boolean,
|
||||
warnings: ConversionWarning[],
|
||||
): string {
|
||||
const cells = (row.content ?? [])
|
||||
.filter((c) => c.type === "tableCell" || c.type === "tableHeader")
|
||||
.map((cell) => {
|
||||
const inner = serializeContent(
|
||||
flattenCellContent(cell.content ?? []),
|
||||
warnings,
|
||||
);
|
||||
return ` ${inner.replace(/\|/g, "\\|").trim()} `;
|
||||
});
|
||||
const rowStr = `|${cells.join("|")}|`;
|
||||
if (!isHeader) return rowStr;
|
||||
// Separator row
|
||||
const sep = cells.map(() => " --- ").join("|");
|
||||
return `${rowStr}\n|${sep}|`;
|
||||
}
|
||||
|
||||
/** Flatten nested paragraph nodes inside a table cell to their inline content. */
|
||||
function flattenCellContent(nodes: TiptapNode[]): TiptapNode[] {
|
||||
const result: TiptapNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.type === "paragraph") {
|
||||
result.push(...(node.content ?? []));
|
||||
} else {
|
||||
result.push(node);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeNode(
|
||||
node: TiptapNode,
|
||||
ctx: { listDepth: number; ordered: boolean; warnings: ConversionWarning[] },
|
||||
): string {
|
||||
const { warnings } = ctx;
|
||||
|
||||
switch (node.type) {
|
||||
case "doc":
|
||||
return (node.content ?? [])
|
||||
.map((n) => serializeNode(n, ctx))
|
||||
.join("\n\n");
|
||||
|
||||
case "paragraph": {
|
||||
if (!node.content || node.content.length === 0) return "";
|
||||
return serializeContent(node.content, warnings);
|
||||
}
|
||||
|
||||
case "heading": {
|
||||
const level = Number(node.attrs?.level ?? 1);
|
||||
const prefix = "#".repeat(Math.min(6, Math.max(1, level)));
|
||||
return `${prefix} ${serializeContent(node.content ?? [], warnings)}`;
|
||||
}
|
||||
|
||||
case "blockquote": {
|
||||
const inner = (node.content ?? [])
|
||||
.map((n) => serializeNode(n, ctx))
|
||||
.join("\n");
|
||||
return inner
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
case "codeBlock": {
|
||||
const lang = String(node.attrs?.language ?? "");
|
||||
const code = serializeContent(node.content ?? [], warnings);
|
||||
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
||||
}
|
||||
|
||||
case "bulletList": {
|
||||
return (node.content ?? [])
|
||||
.map((item) =>
|
||||
serializeNode(item, { ...ctx, listDepth: ctx.listDepth + 1, ordered: false }),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
case "orderedList": {
|
||||
let idx = Number(node.attrs?.start ?? 1);
|
||||
return (node.content ?? [])
|
||||
.map((item) => {
|
||||
const str = serializeNode(item, { ...ctx, listDepth: ctx.listDepth + 1, ordered: true });
|
||||
// Prepend order prefix (already done inside listItem)
|
||||
return str;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
case "listItem": {
|
||||
const indent = " ".repeat(Math.max(0, ctx.listDepth - 1));
|
||||
const bullet = ctx.ordered ? "1." : "-";
|
||||
const inner = (node.content ?? [])
|
||||
.map((n) => serializeNode(n, ctx))
|
||||
.join("\n");
|
||||
return inner
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? `${indent}${bullet} ${line}` : `${indent} ${line}`))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
case "taskList": {
|
||||
return (node.content ?? [])
|
||||
.map((item) => serializeNode(item, { ...ctx, listDepth: ctx.listDepth + 1, ordered: false }))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
case "taskItem": {
|
||||
const checked = Boolean(node.attrs?.checked);
|
||||
const indent = " ".repeat(Math.max(0, ctx.listDepth - 1));
|
||||
const checkbox = checked ? "[x]" : "[ ]";
|
||||
const inner = (node.content ?? [])
|
||||
.map((n) => serializeNode(n, ctx))
|
||||
.join("\n");
|
||||
return inner
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? `${indent}- ${checkbox} ${line}` : `${indent} ${line}`))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
case "table": {
|
||||
const rows = node.content ?? [];
|
||||
if (rows.length === 0) return "";
|
||||
const lines: string[] = [];
|
||||
rows.forEach((row, i) => {
|
||||
lines.push(serializeTableRow(row, i === 0, warnings));
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
case "tableRow":
|
||||
return serializeTableRow(node, false, warnings);
|
||||
|
||||
case "horizontalRule":
|
||||
return "---";
|
||||
|
||||
case "image": {
|
||||
const src = String(node.attrs?.src ?? "");
|
||||
const alt = String(node.attrs?.alt ?? "");
|
||||
return ``;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Custom block nodes (database-view) and unknown nodes.
|
||||
const customSerializer = CUSTOM_NODE_SERIALIZERS[node.type];
|
||||
if (customSerializer && node.attrs) {
|
||||
return customSerializer.toMarkdown(node.attrs);
|
||||
}
|
||||
|
||||
warnings.push({
|
||||
nodeType: node.type,
|
||||
message: `Unknown block node type "${node.type}" — content preserved as text`,
|
||||
});
|
||||
|
||||
// Fall back to serializing children
|
||||
if (node.content && node.content.length > 0) {
|
||||
return (node.content ?? [])
|
||||
.map((n) => serializeNode(n, ctx))
|
||||
.join("\n\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Tiptap JSON document to markdown.
|
||||
*
|
||||
* @param doc - The Tiptap doc JSON object (type: "doc")
|
||||
* @returns Markdown string + any conversion warnings
|
||||
*/
|
||||
export function tiptapToMarkdown(doc: TiptapNode): TiptapToMarkdownResult {
|
||||
const warnings: ConversionWarning[] = [];
|
||||
const markdown = serializeNode(doc, {
|
||||
listDepth: 0,
|
||||
ordered: false,
|
||||
warnings,
|
||||
});
|
||||
// Collapse more than two consecutive blank lines
|
||||
const normalized = markdown.replace(/\n{3,}/g, "\n\n").trim();
|
||||
return { markdown: normalized, warnings };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Markdown -> Tiptap
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** Internal parser state. */
|
||||
interface ParseContext {
|
||||
lines: string[];
|
||||
pos: number;
|
||||
warnings: ConversionWarning[];
|
||||
}
|
||||
|
||||
function peek(ctx: ParseContext): string | undefined {
|
||||
return ctx.lines[ctx.pos];
|
||||
}
|
||||
|
||||
function advance(ctx: ParseContext): string {
|
||||
return ctx.lines[ctx.pos++] ?? "";
|
||||
}
|
||||
|
||||
function parseInlineTokens(
|
||||
text: string,
|
||||
warnings: ConversionWarning[],
|
||||
): TiptapNode[] {
|
||||
// We process inline tokens left-to-right using a simple state machine.
|
||||
const nodes: TiptapNode[] = [];
|
||||
|
||||
// All custom inline patterns (wikilink + mention) merged with standard marks.
|
||||
// Order matters: longer / more specific patterns first.
|
||||
type InlineToken =
|
||||
| { kind: "db"; tableId: string; viewId: string; viewType: string }
|
||||
| { kind: "wikilink"; title: string; alias: string | null }
|
||||
| { kind: "mention"; id: string; label: string }
|
||||
| { kind: "bold"; text: string }
|
||||
| { kind: "italic"; text: string }
|
||||
| { kind: "code"; text: string }
|
||||
| { kind: "strike"; text: string }
|
||||
| { kind: "underline"; text: string }
|
||||
| { kind: "highlight"; text: string }
|
||||
| { kind: "link"; href: string; text: string }
|
||||
| { kind: "hardbreak" }
|
||||
| { kind: "text"; text: string };
|
||||
|
||||
// Build the combined regex. Non-capturing groups around each alternative
|
||||
// so we can identify which alternative matched by inspecting capture groups.
|
||||
const TOKEN_RE =
|
||||
/\[\[!db tableId=([^\s\]]+) viewId=([^\s\]]+) viewType=([^\s\]]+)\]\]|\[\[(?!!db )([^\]|]+?)(?:\|([^\]]*))?\]\]|@<([^>]+)>\(([^)]*)\)|\*\*(.+?)\*\*|(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|`([^`]+)`|~~(.+?)~~|<u>(.+?)<\/u>|==(.+?)==|\[([^\]]+)\]\(([^)]+)\)| \n|((?! \n).+?)/gs;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
TOKEN_RE.lastIndex = 0;
|
||||
|
||||
const pendingText: string[] = [];
|
||||
|
||||
function flushText() {
|
||||
if (pendingText.length === 0) return;
|
||||
const combined = pendingText.join("");
|
||||
pendingText.length = 0;
|
||||
if (combined) {
|
||||
nodes.push({ type: "text", text: combined });
|
||||
}
|
||||
}
|
||||
|
||||
while ((match = TOKEN_RE.exec(text)) !== null) {
|
||||
// Groups 1-15: named capture groups. The hardbreak alternative ( \n) has
|
||||
// no capture group, so rawText is at group 16 — no slot to skip.
|
||||
const [
|
||||
full,
|
||||
dbTableId,
|
||||
dbViewId,
|
||||
dbViewType,
|
||||
wlTitle,
|
||||
wlAlias,
|
||||
mentionId,
|
||||
mentionLabel,
|
||||
boldText,
|
||||
italicText,
|
||||
codeText,
|
||||
strikeText,
|
||||
underlineText,
|
||||
highlightText,
|
||||
linkText,
|
||||
linkHref,
|
||||
rawText,
|
||||
] = match;
|
||||
|
||||
if (dbTableId !== undefined) {
|
||||
flushText();
|
||||
nodes.push({
|
||||
type: "database-view",
|
||||
attrs: { tableId: dbTableId, viewId: dbViewId, viewType: dbViewType, bridgeUrl: null },
|
||||
});
|
||||
} else if (wlTitle !== undefined) {
|
||||
flushText();
|
||||
nodes.push({
|
||||
type: "wikilink",
|
||||
attrs: { pageId: null, title: wlTitle.trim(), alias: wlAlias?.trim() ?? null },
|
||||
});
|
||||
} else if (mentionId !== undefined) {
|
||||
flushText();
|
||||
nodes.push({
|
||||
type: "mention",
|
||||
attrs: { id: mentionId, label: mentionLabel ?? mentionId },
|
||||
});
|
||||
} else if (boldText !== undefined) {
|
||||
flushText();
|
||||
nodes.push({ type: "text", text: boldText, marks: [{ type: "bold" }] });
|
||||
} else if (italicText !== undefined) {
|
||||
flushText();
|
||||
nodes.push({ type: "text", text: italicText, marks: [{ type: "italic" }] });
|
||||
} else if (codeText !== undefined) {
|
||||
flushText();
|
||||
nodes.push({ type: "text", text: codeText, marks: [{ type: "code" }] });
|
||||
} else if (strikeText !== undefined) {
|
||||
flushText();
|
||||
nodes.push({ type: "text", text: strikeText, marks: [{ type: "strike" }] });
|
||||
} else if (underlineText !== undefined) {
|
||||
flushText();
|
||||
nodes.push({ type: "text", text: underlineText, marks: [{ type: "underline" }] });
|
||||
} else if (highlightText !== undefined) {
|
||||
flushText();
|
||||
nodes.push({ type: "text", text: highlightText, marks: [{ type: "highlight" }] });
|
||||
} else if (linkText !== undefined) {
|
||||
flushText();
|
||||
nodes.push({
|
||||
type: "text",
|
||||
text: linkText,
|
||||
marks: [{ type: "link", attrs: { href: linkHref } }],
|
||||
});
|
||||
} else if (full === " \n") {
|
||||
flushText();
|
||||
nodes.push({ type: "hardBreak" });
|
||||
} else if (rawText !== undefined) {
|
||||
pendingText.push(rawText);
|
||||
}
|
||||
}
|
||||
|
||||
flushText();
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function parseParagraph(
|
||||
line: string,
|
||||
warnings: ConversionWarning[],
|
||||
): TiptapNode {
|
||||
const inlineNodes = parseInlineTokens(line, warnings);
|
||||
return { type: "paragraph", content: inlineNodes };
|
||||
}
|
||||
|
||||
function parseHeading(
|
||||
line: string,
|
||||
warnings: ConversionWarning[],
|
||||
): TiptapNode {
|
||||
const match = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (!match) return parseParagraph(line, warnings);
|
||||
const level = match[1].length;
|
||||
const text = match[2];
|
||||
return {
|
||||
type: "heading",
|
||||
attrs: { level },
|
||||
content: parseInlineTokens(text, warnings),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCodeBlock(ctx: ParseContext): TiptapNode {
|
||||
const firstLine = advance(ctx);
|
||||
const langMatch = /^```(.*)$/.exec(firstLine);
|
||||
const lang = langMatch ? langMatch[1].trim() : "";
|
||||
const codeLines: string[] = [];
|
||||
while (peek(ctx) !== undefined && !peek(ctx)!.startsWith("```")) {
|
||||
codeLines.push(advance(ctx));
|
||||
}
|
||||
// Consume closing ```
|
||||
if (peek(ctx) !== undefined) advance(ctx);
|
||||
return {
|
||||
type: "codeBlock",
|
||||
attrs: { language: lang },
|
||||
content: [{ type: "text", text: codeLines.join("\n") }],
|
||||
};
|
||||
}
|
||||
|
||||
function parseBlockquote(
|
||||
ctx: ParseContext,
|
||||
warnings: ConversionWarning[],
|
||||
): TiptapNode {
|
||||
const lines: string[] = [];
|
||||
while (peek(ctx) !== undefined && peek(ctx)!.startsWith("> ")) {
|
||||
lines.push(advance(ctx).slice(2));
|
||||
}
|
||||
const innerDoc = parseDocument({ lines, pos: 0, warnings });
|
||||
return { type: "blockquote", content: innerDoc };
|
||||
}
|
||||
|
||||
/** Parse a bullet list starting at current position. */
|
||||
function parseBulletList(
|
||||
ctx: ParseContext,
|
||||
warnings: ConversionWarning[],
|
||||
): TiptapNode {
|
||||
const items: TiptapNode[] = [];
|
||||
while (peek(ctx) !== undefined) {
|
||||
const line = peek(ctx)!;
|
||||
const taskMatch = /^(\s*)- \[([ xX])\] (.*)$/.exec(line);
|
||||
if (taskMatch) break; // task list item — stop bullet list
|
||||
const bulletMatch = /^(\s*)([-*+]) (.*)$/.exec(line);
|
||||
if (!bulletMatch) break;
|
||||
advance(ctx);
|
||||
const content = bulletMatch[3];
|
||||
items.push({
|
||||
type: "listItem",
|
||||
content: [parseParagraph(content, warnings)],
|
||||
});
|
||||
}
|
||||
return { type: "bulletList", content: items };
|
||||
}
|
||||
|
||||
/** Parse an ordered list starting at current position. */
|
||||
function parseOrderedList(
|
||||
ctx: ParseContext,
|
||||
warnings: ConversionWarning[],
|
||||
): TiptapNode {
|
||||
const items: TiptapNode[] = [];
|
||||
while (peek(ctx) !== undefined) {
|
||||
const line = peek(ctx)!;
|
||||
const match = /^\d+\. (.*)$/.exec(line);
|
||||
if (!match) break;
|
||||
advance(ctx);
|
||||
items.push({
|
||||
type: "listItem",
|
||||
content: [parseParagraph(match[1], warnings)],
|
||||
});
|
||||
}
|
||||
return { type: "orderedList", attrs: { start: 1 }, content: items };
|
||||
}
|
||||
|
||||
/** Parse a task list (- [ ] / - [x]) starting at current position. */
|
||||
function parseTaskList(
|
||||
ctx: ParseContext,
|
||||
warnings: ConversionWarning[],
|
||||
): TiptapNode {
|
||||
const items: TiptapNode[] = [];
|
||||
while (peek(ctx) !== undefined) {
|
||||
const line = peek(ctx)!;
|
||||
const match = /^(\s*)- \[([ xX])\] (.*)$/.exec(line);
|
||||
if (!match) break;
|
||||
advance(ctx);
|
||||
const checked = match[2].toLowerCase() === "x";
|
||||
const text = match[3];
|
||||
items.push({
|
||||
type: "taskItem",
|
||||
attrs: { checked },
|
||||
content: [parseParagraph(text, warnings)],
|
||||
});
|
||||
}
|
||||
return { type: "taskList", content: items };
|
||||
}
|
||||
|
||||
/** Parse a GFM table. */
|
||||
function parseTable(
|
||||
ctx: ParseContext,
|
||||
warnings: ConversionWarning[],
|
||||
): TiptapNode {
|
||||
const rows: TiptapNode[] = [];
|
||||
let isFirst = true;
|
||||
while (peek(ctx) !== undefined && peek(ctx)!.startsWith("|")) {
|
||||
const line = advance(ctx);
|
||||
// Skip separator row (| --- | --- |)
|
||||
if (/^\|[\s\-|]+\|$/.test(line.replace(/ /g, ""))) {
|
||||
continue;
|
||||
}
|
||||
const cells = line
|
||||
.slice(1, -1)
|
||||
.split("|")
|
||||
.map((c) => c.trim());
|
||||
const cellNodes: TiptapNode[] = cells.map((cell) => ({
|
||||
type: isFirst ? "tableHeader" : "tableCell",
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [{ type: "paragraph", content: parseInlineTokens(cell, warnings) }],
|
||||
}));
|
||||
rows.push({ type: "tableRow", content: cellNodes });
|
||||
isFirst = false;
|
||||
}
|
||||
return { type: "table", content: rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is a standalone custom-node token (block-level).
|
||||
* Returns the Tiptap node or null.
|
||||
*/
|
||||
function tryParseCustomBlockNode(
|
||||
line: string,
|
||||
warnings: ConversionWarning[],
|
||||
): TiptapNode | null {
|
||||
for (const serializer of SERIALIZER_LIST) {
|
||||
// Skip inline-only nodes — they are parsed by parseInlineTokens.
|
||||
if (!serializer.isBlock) continue;
|
||||
const re = new RegExp(serializer.pattern.source, "");
|
||||
const match = re.exec(line);
|
||||
if (match && match[0] === line.trim()) {
|
||||
const attrs = serializer.fromMarkdown(match as RegExpExecArray);
|
||||
if (!attrs) {
|
||||
warnings.push({
|
||||
nodeType: serializer.nodeType,
|
||||
message: `Could not parse ${serializer.nodeType} token: "${line}"`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { type: serializer.nodeType, attrs };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDocument(ctx: ParseContext): TiptapNode[] {
|
||||
const nodes: TiptapNode[] = [];
|
||||
const { warnings } = ctx;
|
||||
|
||||
while (ctx.pos < ctx.lines.length) {
|
||||
const line = peek(ctx);
|
||||
if (line === undefined) break;
|
||||
|
||||
// Blank line
|
||||
if (line.trim() === "") {
|
||||
advance(ctx);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fenced code block
|
||||
if (line.startsWith("```")) {
|
||||
nodes.push(parseCodeBlock(ctx));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith("> ")) {
|
||||
nodes.push(parseBlockquote(ctx, warnings));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading
|
||||
if (/^#{1,6} /.test(line)) {
|
||||
advance(ctx);
|
||||
nodes.push(parseHeading(line, warnings));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^---+$/.test(line.trim()) || /^\*\*\*+$/.test(line.trim())) {
|
||||
advance(ctx);
|
||||
nodes.push({ type: "horizontalRule" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Task list (must be before bullet list)
|
||||
if (/^(\s*)- \[([ xX])\] /.test(line)) {
|
||||
nodes.push(parseTaskList(ctx, warnings));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bullet list
|
||||
if (/^(\s*)([-*+]) /.test(line)) {
|
||||
nodes.push(parseBulletList(ctx, warnings));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (/^\d+\. /.test(line)) {
|
||||
nodes.push(parseOrderedList(ctx, warnings));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table
|
||||
if (line.startsWith("|")) {
|
||||
nodes.push(parseTable(ctx, warnings));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Custom block nodes (database-view on its own line)
|
||||
const customNode = tryParseCustomBlockNode(line, warnings);
|
||||
if (customNode) {
|
||||
advance(ctx);
|
||||
nodes.push(customNode);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default: paragraph
|
||||
advance(ctx);
|
||||
nodes.push(parseParagraph(line, warnings));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a markdown string to a Tiptap JSON document.
|
||||
*
|
||||
* @param markdown - Raw markdown string
|
||||
* @returns Tiptap doc + any conversion warnings
|
||||
*/
|
||||
export function markdownToTiptap(markdown: string): MarkdownToTiptapResult {
|
||||
const warnings: ConversionWarning[] = [];
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const ctx: ParseContext = { lines, pos: 0, warnings };
|
||||
const content = parseDocument(ctx);
|
||||
const doc: TiptapNode = {
|
||||
type: "doc",
|
||||
content: content.length > 0 ? content : [{ type: "paragraph", content: [] }],
|
||||
};
|
||||
return { doc, warnings };
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
/**
|
||||
* Smoke tests for GraphCanvas.
|
||||
*
|
||||
* react-force-graph-2d is Canvas-based and not installed yet — we mock it to
|
||||
* verify the component mounts without errors, renders the fallback when the
|
||||
* lib is absent, and wires interactions correctly.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { GraphCanvas } from "../components/graph-canvas";
|
||||
import type { GraphNode, GraphEdge } from "../services/graph-client";
|
||||
|
||||
// The lib is not installed — the component falls back to a placeholder.
|
||||
// Verify the fallback renders without throwing.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
const NODES: GraphNode[] = [
|
||||
{
|
||||
id: "p1",
|
||||
label: "Page 1",
|
||||
slug: "page-1",
|
||||
type: "page",
|
||||
spaceId: "s1",
|
||||
spaceName: "Engineering",
|
||||
icon: null,
|
||||
isOrphan: false,
|
||||
metrics: { inDegree: 1, outDegree: 2 },
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
label: "Page 2",
|
||||
slug: null,
|
||||
type: "page",
|
||||
spaceId: "s1",
|
||||
spaceName: "Engineering",
|
||||
icon: null,
|
||||
isOrphan: false,
|
||||
metrics: { inDegree: 0, outDegree: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const EDGES: GraphEdge[] = [
|
||||
{
|
||||
id: "p1:p2:wikilink",
|
||||
source: "p1",
|
||||
target: "p2",
|
||||
type: "wikilink",
|
||||
weight: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const PARENT_CHILD_EDGES: GraphEdge[] = [
|
||||
{
|
||||
id: "p1:p2:parent_child",
|
||||
source: "p1",
|
||||
target: "p2",
|
||||
type: "parent_child",
|
||||
weight: 1,
|
||||
},
|
||||
];
|
||||
|
||||
function renderCanvas(nodes = NODES, edges = EDGES) {
|
||||
const store = createStore();
|
||||
return render(
|
||||
createElement(
|
||||
Provider,
|
||||
{ store },
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
null,
|
||||
createElement(GraphCanvas, {
|
||||
nodes,
|
||||
edges,
|
||||
searchTerm: "",
|
||||
width: 800,
|
||||
height: 600,
|
||||
slugMap: {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("GraphCanvas smoke", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders without throwing when lib is absent (fallback placeholder)", () => {
|
||||
// The dynamic import of react-force-graph-2d will fail in test env.
|
||||
// Component should render the placeholder instead.
|
||||
expect(() => renderCanvas()).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders placeholder text when lib is unavailable", () => {
|
||||
renderCanvas();
|
||||
// Either the real graph or the placeholder — both must not crash.
|
||||
// The placeholder shows install instructions when the lib is absent.
|
||||
const el = document.body;
|
||||
expect(el).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders with empty nodes and edges without error", () => {
|
||||
expect(() => renderCanvas([], [])).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders with single orphan node", () => {
|
||||
const orphan: GraphNode = {
|
||||
id: "orphan-1",
|
||||
label: "Orphan",
|
||||
slug: "orphan-1",
|
||||
type: "page",
|
||||
spaceId: "s2",
|
||||
spaceName: null,
|
||||
icon: null,
|
||||
isOrphan: true,
|
||||
metrics: { inDegree: 0, outDegree: 0 },
|
||||
};
|
||||
expect(() => renderCanvas([orphan], [])).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders with null labels on nodes", () => {
|
||||
const noLabel: GraphNode = {
|
||||
id: "p3",
|
||||
label: null,
|
||||
slug: null,
|
||||
type: "page",
|
||||
spaceId: "s1",
|
||||
spaceName: "Test",
|
||||
icon: null,
|
||||
isOrphan: false,
|
||||
metrics: { inDegree: 1, outDegree: 0 },
|
||||
};
|
||||
expect(() => renderCanvas([noLabel], [])).not.toThrow();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// R4.6 — parent_child edge type
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("renders parent_child edges without error", () => {
|
||||
expect(() => renderCanvas(NODES, PARENT_CHILD_EDGES)).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders mixed wikilink and parent_child edges without error", () => {
|
||||
expect(() =>
|
||||
renderCanvas(NODES, [...EDGES, ...PARENT_CHILD_EDGES])
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders nodes with slug fallback when label is null", () => {
|
||||
const slugOnlyNode: GraphNode = {
|
||||
id: "p-slug",
|
||||
label: null,
|
||||
slug: "my-page-slug",
|
||||
type: "page",
|
||||
spaceId: "s1",
|
||||
spaceName: "Test",
|
||||
icon: null,
|
||||
isOrphan: false,
|
||||
metrics: { inDegree: 0, outDegree: 1 },
|
||||
};
|
||||
// Should mount without throw — slug used as display label in canvas
|
||||
expect(() => renderCanvas([slugOnlyNode], [])).not.toThrow();
|
||||
});
|
||||
|
||||
it("renders legend elements in DOM (the outer wrapper div is present)", () => {
|
||||
const { container } = renderCanvas(NODES, EDGES);
|
||||
// The wrapper div is always rendered regardless of lib availability.
|
||||
// We verify the component mounts a root element (canvas or placeholder).
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue