Compare commits

..

No commits in common. "acadenice/main" and "main" have entirely different histories.

374 changed files with 151 additions and 45157 deletions

View file

@ -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
View file

@ -5,7 +5,6 @@ data
# compiled output
/dist
/node_modules
.pnpm-store
# Logs
logs

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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>

View file

@ -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 |

View file

@ -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"]

View file

@ -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-->

View file

@ -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"
}
}

View file

@ -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"
}

View file

@ -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 ?"
}

View file

@ -1,6 +1,6 @@
{
"name": "AcadeDoc",
"short_name": "AcadeDoc",
"name": "Docmost",
"short_name": "Docmost",
"start_url": "/",
"display": "standalone",
"background_color": "#222",

View file

@ -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>

View file

@ -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>

View file

@ -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() {

View file

@ -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(),
});
};

View file

@ -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;

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
</>
);
}

View file

@ -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",
});
},
});
}

View file

@ -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}`);
}

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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>
)}
</>
);
}

View file

@ -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,
});
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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();
});
});

View file

@ -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;
}

View file

@ -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;

View file

@ -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,
});
}

View file

@ -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');
});
});
});

View file

@ -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>
</>
);
}

View file

@ -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 });
},
});
}

View file

@ -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}`);
},
};

View file

@ -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 } },
);
});
});

View file

@ -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();
});
});

View file

@ -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")}
>
&#10003;
</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")}
>
&#8635;
</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")}
>
&#10005;
</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>
);
}

View file

@ -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),
});
},
});
}

View file

@ -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;
}

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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");
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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] }),
);
});
});

View file

@ -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:&nbsp;
<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>
);
}

View file

@ -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);
}

View file

@ -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,
}));
}

View file

@ -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);
}

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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,
});
},
});
}

View file

@ -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]);
}

View file

@ -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,
});
}

View file

@ -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;
}
}

View file

@ -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,
});
}

View file

@ -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,
};
}

View file

@ -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,
});
},
});
}

View file

@ -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,
}));
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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";

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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),
);
}

View file

@ -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;
}

View file

@ -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:&nbsp;
<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>
);
}

View file

@ -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>,
);
});
},
};
}

View file

@ -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>,
);
});
},
};
}

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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("![logo](https://example.com/img.png)");
});
});
// --------------------------------------------------------------------------
// 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);
});
});

View file

@ -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");
});
});

View file

@ -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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View file

@ -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);
}

View file

@ -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,
];

View file

@ -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 `![${alt}](${src})`;
}
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 };
}

View file

@ -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