fix(database-view): pick a database before listing tables
The insert-database modal called the public bridge /api/v1/tables route, which requires a databaseId and a Baserow user JWT — the modal supplied neither, so the request returned 400 then 501. Add a step 0 to pick a workspace and database (auto-resolved when each lists only one), then list tables via the admin endpoint GET /api/v1/admin/tables?databaseId=X. The client extension is also rewired to .extend() the shared DatabaseView node.
This commit is contained in:
parent
a87e61e382
commit
43a70929ec
7 changed files with 290 additions and 129 deletions
|
|
@ -31,11 +31,30 @@ vi.mock("../hooks/use-views", () => ({
|
|||
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" },
|
||||
|
|
@ -79,9 +98,32 @@ describe("InsertDatabaseModal", () => {
|
|||
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(),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders step 1 with table list when opened", () => {
|
||||
// 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>
|
||||
|
|
@ -93,11 +135,12 @@ describe("InsertDatabaseModal", () => {
|
|||
</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", () => {
|
||||
it("shows loading state in step 1", async () => {
|
||||
mockUseTables.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
|
|
@ -114,11 +157,12 @@ describe("InsertDatabaseModal", () => {
|
|||
/>
|
||||
</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", () => {
|
||||
it("shows error alert and retry button on tables load failure", async () => {
|
||||
const refetch = vi.fn();
|
||||
mockUseTables.mockReturnValue({
|
||||
data: undefined,
|
||||
|
|
@ -136,6 +180,7 @@ describe("InsertDatabaseModal", () => {
|
|||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
expect(
|
||||
screen.getByText("database_view.error.tables_load"),
|
||||
).toBeInTheDocument();
|
||||
|
|
@ -143,7 +188,7 @@ describe("InsertDatabaseModal", () => {
|
|||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows empty state when no tables", () => {
|
||||
it("shows empty state when no tables", async () => {
|
||||
mockUseTables.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
|
|
@ -160,6 +205,7 @@ describe("InsertDatabaseModal", () => {
|
|||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
expect(
|
||||
screen.getByText("database_view.modal.no_tables"),
|
||||
).toBeInTheDocument();
|
||||
|
|
@ -176,9 +222,9 @@ describe("InsertDatabaseModal", () => {
|
|||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
fireEvent.click(screen.getByText("Contacts"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("database_view.modal.step2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Grid view")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -194,9 +240,13 @@ describe("InsertDatabaseModal", () => {
|
|||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
fireEvent.click(screen.getByText("Contacts"));
|
||||
await waitFor(() => screen.getByText("database_view.modal.back"));
|
||||
fireEvent.click(screen.getByText("database_view.modal.back"));
|
||||
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();
|
||||
|
|
@ -215,6 +265,7 @@ describe("InsertDatabaseModal", () => {
|
|||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await advanceToTableStep();
|
||||
fireEvent.click(screen.getByText("Contacts"));
|
||||
await waitFor(() => screen.getByText("Grid view"));
|
||||
fireEvent.click(screen.getByText("Grid view"));
|
||||
|
|
|
|||
|
|
@ -1,112 +1,18 @@
|
|||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { DatabaseView } from "@docmost/editor-ext";
|
||||
import { DatabaseViewComponent } from "./database-view-component";
|
||||
|
||||
/**
|
||||
* Tiptap Node extension: `database-view`.
|
||||
* Client-side database-view extension.
|
||||
*
|
||||
* Renders an embedded Baserow view (grid/table in R3.1.c) inside a page.
|
||||
*
|
||||
* Attrs:
|
||||
* - tableId : string, required — Baserow table ID
|
||||
* - viewId : string, required — Baserow view ID
|
||||
* - viewType : string, default "grid" — determines which renderer to use
|
||||
* - bridgeUrl : string|null, default null — per-instance bridge URL override
|
||||
* (falls back to VITE_BRIDGE_URL env var at runtime)
|
||||
*
|
||||
* The node is atomic (isLeaf, selectable) so it behaves like an image block.
|
||||
* It is not editable in-place; mutations come via the bridge API in R3.1.d.
|
||||
* 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 = Node.create({
|
||||
name: "database-view",
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
tableId: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-table-id") ?? "",
|
||||
renderHTML: (attributes) => ({ "data-table-id": attributes.tableId }),
|
||||
},
|
||||
viewId: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-view-id") ?? "",
|
||||
renderHTML: (attributes) => ({ "data-view-id": attributes.viewId }),
|
||||
},
|
||||
viewType: {
|
||||
default: "grid",
|
||||
parseHTML: (element) =>
|
||||
element.getAttribute("data-view-type") ?? "grid",
|
||||
renderHTML: (attributes) => ({
|
||||
"data-view-type": attributes.viewType,
|
||||
}),
|
||||
},
|
||||
bridgeUrl: {
|
||||
default: null,
|
||||
parseHTML: (element) =>
|
||||
element.getAttribute("data-bridge-url") ?? null,
|
||||
renderHTML: (attributes) =>
|
||||
attributes.bridgeUrl
|
||||
? { "data-bridge-url": attributes.bridgeUrl }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "div[data-node-type=database-view]" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, { "data-node-type": "database-view" }),
|
||||
];
|
||||
},
|
||||
|
||||
const DatabaseViewExtension = DatabaseView.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(DatabaseViewComponent);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertDatabaseView:
|
||||
(attrs: {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
viewType: string;
|
||||
bridgeUrl?: string | null;
|
||||
}) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
tableId: attrs.tableId,
|
||||
viewId: attrs.viewId,
|
||||
viewType: attrs.viewType,
|
||||
bridgeUrl: attrs.bridgeUrl ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default DatabaseViewExtension;
|
||||
|
||||
/** Extend the Tiptap Commands interface for TypeScript consumers. */
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
databaseView: {
|
||||
insertDatabaseView: (attrs: {
|
||||
tableId: string;
|
||||
viewId: string;
|
||||
viewType: string;
|
||||
bridgeUrl?: string | null;
|
||||
}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { listDatabases, type BaserowDatabase } from "../services/admin-client";
|
||||
|
||||
export const DATABASES_QUERY_KEY = ["bridge-admin-databases"] as const;
|
||||
|
||||
export function useDatabases(
|
||||
workspaceId?: number | null,
|
||||
bridgeUrl?: string | null,
|
||||
) {
|
||||
return useQuery<BaserowDatabase[]>({
|
||||
queryKey: [...DATABASES_QUERY_KEY, workspaceId ?? null, bridgeUrl ?? null],
|
||||
queryFn: () => {
|
||||
if (!workspaceId) return Promise.resolve([] as BaserowDatabase[]);
|
||||
return listDatabases(workspaceId, bridgeUrl);
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,29 +1,36 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getBridgeClient, resolveBridgeUrl } from "../services/bridge-client";
|
||||
import { listTables, type BaserowTableSummary } from "../services/admin-client";
|
||||
import type { BridgeTable } from "../types/database-view.types";
|
||||
|
||||
export const TABLES_QUERY_KEY = ["bridge-tables"] as const;
|
||||
export const TABLES_QUERY_KEY = ["bridge-admin-tables"] as const;
|
||||
|
||||
/**
|
||||
* Fetches the list of tables exposed by the bridge.
|
||||
* Used in the insert-database-modal step 1.
|
||||
* Fetch tables of a Baserow database via the bridge admin endpoint.
|
||||
*
|
||||
* The response is a flat array; pagination is not needed — Baserow tables per
|
||||
* database are in the dozens, not thousands.
|
||||
* 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(bridgeUrl?: string | null) {
|
||||
const url = resolveBridgeUrl(bridgeUrl);
|
||||
|
||||
export function useTables(
|
||||
databaseId?: number | null,
|
||||
bridgeUrl?: string | null,
|
||||
) {
|
||||
return useQuery<BridgeTable[]>({
|
||||
queryKey: [...TABLES_QUERY_KEY, url],
|
||||
queryKey: [...TABLES_QUERY_KEY, databaseId ?? null, bridgeUrl ?? null],
|
||||
queryFn: async () => {
|
||||
const client = getBridgeClient(url);
|
||||
const res = await (client.get("/api/v1/tables") as unknown as Promise<{
|
||||
data: BridgeTable[];
|
||||
}>);
|
||||
// Bridge wraps in { data: [...] } envelope.
|
||||
return Array.isArray(res) ? res : (res as { data: BridgeTable[] }).data ?? [];
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -94,6 +94,16 @@ export async function createDatabase(
|
|||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
|
|
@ -23,11 +23,13 @@ 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 = "table" | "view" | "timeline-mapping";
|
||||
type Step = "source" | "table" | "view" | "timeline-mapping";
|
||||
|
||||
interface InsertDatabaseModalProps {
|
||||
opened: boolean;
|
||||
|
|
@ -54,11 +56,39 @@ export function InsertDatabaseModal({
|
|||
}: InsertDatabaseModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [step, setStep] = useState<Step>("table");
|
||||
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("");
|
||||
|
|
@ -70,7 +100,7 @@ export function InsertDatabaseModal({
|
|||
isLoading: tablesLoading,
|
||||
isError: tablesError,
|
||||
refetch: refetchTables,
|
||||
} = useTables(bridgeUrl);
|
||||
} = useTables(databaseId, bridgeUrl);
|
||||
|
||||
const {
|
||||
data: views,
|
||||
|
|
@ -100,7 +130,9 @@ export function InsertDatabaseModal({
|
|||
});
|
||||
|
||||
function handleReset() {
|
||||
setStep("table");
|
||||
setStep("source");
|
||||
setWorkspaceId(null);
|
||||
setDatabaseId(null);
|
||||
setSelectedTable(null);
|
||||
setSelectedView(null);
|
||||
setTableSearch("");
|
||||
|
|
@ -124,9 +156,12 @@ export function InsertDatabaseModal({
|
|||
function handleBack() {
|
||||
if (step === "timeline-mapping") {
|
||||
setStep("view");
|
||||
} else {
|
||||
} else if (step === "view") {
|
||||
setStep("table");
|
||||
setSelectedView(null);
|
||||
} else if (step === "table") {
|
||||
setStep("source");
|
||||
setSelectedTable(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +238,10 @@ export function InsertDatabaseModal({
|
|||
>
|
||||
{/* 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")}
|
||||
|
|
@ -221,9 +260,126 @@ export function InsertDatabaseModal({
|
|||
</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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue