feat(database-view): admin UI and create-database slash command

Adds two new entry points around the bridge-backed database view:
- /database admin modal to manage fields (field-admin-modal)
- slash command to create a database from the editor
  (create-database-modal + create-database-slash)
Wires the new components into the editor slash menu and the
database-view module index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-11 09:54:06 +00:00
parent 41ce6308fa
commit 843986d5c2
8 changed files with 965 additions and 55 deletions

View file

@ -0,0 +1,174 @@
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

@ -6,5 +6,6 @@
*/
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,12 +1,34 @@
import { useState } from "react";
import { Text, Button, Group, Skeleton, Stack, Alert } from "@mantine/core";
import { IconAlertCircle, IconChevronLeft, IconChevronRight } from "@tabler/icons-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 { 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";
@ -157,8 +179,58 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
});
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 });
// 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);
@ -213,9 +285,54 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
<tr>
{fields.map((field) => (
<th key={field.id} className={styles.th}>
{field.name}
<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>

View file

@ -0,0 +1,166 @@
/**
* 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 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 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,53 +1,30 @@
/**
* Thin HTTP wrapper for the bridge API (R3.1.a).
* Thin HTTP wrapper for the bridge API.
*
* Why a separate client and not the shared `api` axios instance:
* The bridge lives at a different origin (VITE_BRIDGE_URL) and uses the
* DocAdenice JWT forwarded via the Authorization header, whereas `api` targets
* the Docmost backend at "/api" with cookie-based auth.
* 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.
*
* The JWT is read from the cookie `authToken`. In production the cookie is
* HttpOnly so JS cannot read it SSE auth uses the cookie automatically via
* credentials. For REST calls we rely on the cookie being sent automatically
* by withCredentials when the bridge is same-site, OR on the server proxying
* the calls through Docmost (future R3.2). For now we send credentials and
* leave the Authorization header empty when the token is not readable the
* bridge falls back to cookie auth (R2.3b).
* `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.
*
* In dev, the Vite server proxies `/bridge/*` to `http://localhost:4000/*` (see vite.config.ts).
* Same-origin = the auth cookie is sent automatically without CORS gymnastics.
* In prod, set VITE_BRIDGE_URL at build time to an absolute URL behind your reverse proxy
* (or keep `/bridge` and proxy server-side).
*/
/** 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";
}
/** Attempt to read the auth token from JS-accessible cookie or jotai storage. */
function readTokenFromCookie(): string | null {
try {
// authToken is HttpOnly in production; authTokens may be readable.
const raw = document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("authTokens="));
if (raw) {
const val = decodeURIComponent(raw.slice("authTokens=".length));
const parsed = JSON.parse(val);
return typeof parsed?.token === "string" ? parsed.token : null;
}
} catch {
// cookie not accessible or malformed — silently fall through
}
return null;
}
/** Build a one-shot axios instance targeting the resolved bridge URL. */
export function createBridgeClient(bridgeUrl: string): AxiosInstance {
const instance = axios.create({
@ -56,23 +33,16 @@ export function createBridgeClient(bridgeUrl: string): AxiosInstance {
timeout: 15_000,
});
// Vite auto-exposes VITE_* vars from apps/client/.env(.local) via
// import.meta.env. The monorepo-root .env is not picked up automatically
// for client-side consumption, so the dev token lives in apps/client/.env.local
// (gitignored).
const envToken: string | undefined = (
import.meta as unknown as { env?: { VITE_BRIDGE_TOKEN?: string } }
).env?.VITE_BRIDGE_TOKEN;
instance.interceptors.request.use((config) => {
// Priority: cookie token (prod) > VITE_BRIDGE_TOKEN env (dev fallback)
const cookieToken = readTokenFromCookie();
const token = cookieToken || envToken;
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
if (envToken) {
instance.interceptors.request.use((config) => {
config.headers["Authorization"] = `Bearer ${envToken}`;
return config;
});
}
instance.interceptors.response.use(
(res) => res.data,

View file

@ -0,0 +1,382 @@
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,
} 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: insert the new table as a Tiptap node into the editor.
// We don't have a viewId here yet — Baserow auto-creates a default Grid
// view at table creation. We let the rendererfetch the first view.
editor
.chain()
.focus()
.insertDatabaseView({
tableId: String(table.id),
viewId: "",
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

@ -0,0 +1,99 @@
/**
* 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

@ -54,7 +54,7 @@ import {
YoutubeIcon,
} from "@/components/icons";
// Acadenice R3.1.c — database-view slash command
import { buildDatabaseSlashItem } from "@/features/acadenice/database-view";
import { buildDatabaseSlashItem, buildCreateDatabaseSlashItem } from "@/features/acadenice/database-view";
// Acadenice R3.6 — /template slash command opens the picker modal via a custom event
import { IconTemplate } from "@tabler/icons-react";
// Acadenice R4.2 — /sync-block slash command
@ -65,6 +65,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
// Acadenice R3.1.c — database embed group (separate from basic to keep ordering clean)
acadenice: [
buildDatabaseSlashItem() as unknown as import("./types").SlashMenuItemType,
buildCreateDatabaseSlashItem() as unknown as import("./types").SlashMenuItemType,
// Acadenice R3.6 — /template opens the workspace template picker via custom DOM event
{
title: "Template",