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:
parent
41ce6308fa
commit
843986d5c2
8 changed files with 965 additions and 55 deletions
|
|
@ -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:
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>,
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue