@marktiderman/genesis-studio 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -1
- package/dist/index.d.ts +100 -0
- package/dist/index.js +463 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +12 -0
- package/package.json +65 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Tiderman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1 +1,63 @@
|
|
|
1
|
-
|
|
1
|
+
# @marktiderman/genesis-studio
|
|
2
|
+
|
|
3
|
+
**Genesis Data Studio**, the workspace UI: a sidebar of the tables the signed-in user may see, and a
|
|
4
|
+
sheet of the selected one where a cell is edited in place. Every save is checked against the
|
|
5
|
+
version of the row it was read at and committed with its audit entry, and the outcome is on screen:
|
|
6
|
+
"Saved", with the audit entry, or "Not saved" and why.
|
|
7
|
+
|
|
8
|
+
Built on genesis-ui's `ResourcePage` and `DataTable` (DATA-STUDIO.md decision 21). Routing and URL
|
|
9
|
+
state come from your app through an adapter (decision 23), and sign-in fills a slot.
|
|
10
|
+
|
|
11
|
+
**Status: 0.1, Experimental.** P0 scope: browse, search, sort, page, and single-cell edits.
|
|
12
|
+
|
|
13
|
+
## Use it
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { DataStudio, createSearchParamsRouter } from "@marktiderman/genesis-studio";
|
|
17
|
+
import { createStudioProvider } from "@marktiderman/genesis-studio-server/client";
|
|
18
|
+
import { useSearchParams } from "react-router";
|
|
19
|
+
|
|
20
|
+
export default function AdminData() {
|
|
21
|
+
const { session } = useSupabaseSession(); // your app's own sign-in
|
|
22
|
+
const [params, setParams] = useSearchParams();
|
|
23
|
+
const router = createSearchParamsRouter(params, (next, { replace }) => setParams(next, { replace }));
|
|
24
|
+
const provider = useMemo(
|
|
25
|
+
() => createStudioProvider({ url: "/api/studio", getAccessToken: () => session?.access_token ?? null }),
|
|
26
|
+
[session],
|
|
27
|
+
);
|
|
28
|
+
return <DataStudio provider={provider} router={router} sessionKey={session?.user.id} signIn={<SignInForm />} />;
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The server half — the handler at `/api/studio` — is
|
|
33
|
+
[`@marktiderman/genesis-studio-server`](../studio-server/README.md).
|
|
34
|
+
|
|
35
|
+
Tailwind v4 needs to see the studio's classes:
|
|
36
|
+
|
|
37
|
+
```css
|
|
38
|
+
@import "tailwindcss";
|
|
39
|
+
@import "@marktiderman/genesis-ui/styles.css";
|
|
40
|
+
@import "@marktiderman/genesis-studio/styles.css";
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Props
|
|
44
|
+
|
|
45
|
+
| Prop | |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `provider` | Any Genesis `DataProvider` with `describe` and `listResources` — usually `createStudioProvider`. |
|
|
48
|
+
| `router` | `{ location, navigate }`. `createSearchParamsRouter` adapts React Router or Next.js; `useHistoryRouter` works without a router; `useMemoryRouter` for tests. URL keys: `table`, `page`, `sort`, `q`. |
|
|
49
|
+
| `signIn` | Rendered instead of the studio while the provider reports nobody signed in. |
|
|
50
|
+
| `sessionKey` | The signed-in user's id. When it changes, everything loaded for the previous user is dropped. |
|
|
51
|
+
| `title`, `perPage`, `className` | Presentation. |
|
|
52
|
+
|
|
53
|
+
## What it shows
|
|
54
|
+
|
|
55
|
+
- **Read-only tables** carry a lock in the sidebar and a banner with the server's reason (no version
|
|
56
|
+
trigger, editing not enabled, RLS off…).
|
|
57
|
+
- **Columns** are exactly the fields `describe()` returned for this user; a column they can't read
|
|
58
|
+
never renders. Only fields with `permissions.update` are editable; enum and boolean fields edit
|
|
59
|
+
as a select.
|
|
60
|
+
- **A conflict** keeps the editor open with the message, and the row then shows the other person's
|
|
61
|
+
value, so the next edit starts from it.
|
|
62
|
+
- **This session's edits** lists every save and refusal, with the audit id, actor, time and the
|
|
63
|
+
before → after of each changed field.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { DataProvider } from '@marktiderman/genesis-core/provider';
|
|
4
|
+
import { AuditEntry } from '@marktiderman/genesis-core/schema';
|
|
5
|
+
|
|
6
|
+
/** Everything the studio keeps in the URL. */
|
|
7
|
+
interface StudioLocation {
|
|
8
|
+
/** `schema.table`, e.g. `public.clients`. */
|
|
9
|
+
resource?: string;
|
|
10
|
+
/** 1-based. */
|
|
11
|
+
page?: number;
|
|
12
|
+
/** `field:asc` or `field:desc`. */
|
|
13
|
+
sort?: string;
|
|
14
|
+
search?: string;
|
|
15
|
+
}
|
|
16
|
+
interface StudioNavigateOptions {
|
|
17
|
+
/** Replace the current history entry instead of pushing one (e.g. while typing a search). */
|
|
18
|
+
replace?: boolean;
|
|
19
|
+
}
|
|
20
|
+
interface StudioRouter {
|
|
21
|
+
location: StudioLocation;
|
|
22
|
+
navigate(next: StudioLocation, options?: StudioNavigateOptions): void;
|
|
23
|
+
}
|
|
24
|
+
declare function locationFromSearchParams(params: URLSearchParams): StudioLocation;
|
|
25
|
+
/**
|
|
26
|
+
* The studio's location as query parameters, keeping every parameter in
|
|
27
|
+
* `base` that isn't the studio's own.
|
|
28
|
+
*/
|
|
29
|
+
declare function searchParamsFromLocation(location: StudioLocation, base?: URLSearchParams): URLSearchParams;
|
|
30
|
+
/**
|
|
31
|
+
* An adapter over any router that exposes search params with a setter —
|
|
32
|
+
* React Router's `useSearchParams`, or Next.js's `useSearchParams` plus
|
|
33
|
+
* `router.replace`/`push`.
|
|
34
|
+
*
|
|
35
|
+
* @example React Router
|
|
36
|
+
* const [params, setParams] = useSearchParams();
|
|
37
|
+
* const router = createSearchParamsRouter(params, (next, { replace }) => setParams(next, { replace }));
|
|
38
|
+
*/
|
|
39
|
+
declare function createSearchParamsRouter(params: URLSearchParams, setParams: (next: URLSearchParams, options: {
|
|
40
|
+
replace: boolean;
|
|
41
|
+
}) => void): StudioRouter;
|
|
42
|
+
/**
|
|
43
|
+
* For an app with no router: keeps the studio's state in the current page's
|
|
44
|
+
* query string through the History API.
|
|
45
|
+
*/
|
|
46
|
+
declare function useHistoryRouter(): StudioRouter;
|
|
47
|
+
/** A router held in memory, for tests and embedded previews. */
|
|
48
|
+
declare function useMemoryRouter(initial?: StudioLocation): StudioRouter;
|
|
49
|
+
|
|
50
|
+
/** One entry in the session's activity panel. */
|
|
51
|
+
interface StudioActivity {
|
|
52
|
+
id: string;
|
|
53
|
+
at: Date;
|
|
54
|
+
resource: string;
|
|
55
|
+
field: string;
|
|
56
|
+
/** The record's key, as shown to the user. */
|
|
57
|
+
record: string;
|
|
58
|
+
outcome: "saved" | "not_saved";
|
|
59
|
+
message: string;
|
|
60
|
+
/** Present when the edit committed with an audit entry from the `table` sink. */
|
|
61
|
+
audit?: AuditEntry;
|
|
62
|
+
}
|
|
63
|
+
interface DataStudioProps {
|
|
64
|
+
/**
|
|
65
|
+
* Where the data comes from: the studio handler's client
|
|
66
|
+
* (`createStudioProvider` from `@marktiderman/genesis-studio-server/client`)
|
|
67
|
+
* or any Genesis `DataProvider` that implements `describe` and
|
|
68
|
+
* `listResources`.
|
|
69
|
+
*/
|
|
70
|
+
provider: DataProvider;
|
|
71
|
+
/** URL state, from the app's router. See `createSearchParamsRouter` and `useHistoryRouter`. */
|
|
72
|
+
router: StudioRouter;
|
|
73
|
+
/** Shown in place of the studio while nobody is signed in. */
|
|
74
|
+
signIn?: ReactNode;
|
|
75
|
+
/**
|
|
76
|
+
* Changes when the signed-in user does (their id, say). The studio drops
|
|
77
|
+
* everything it loaded for the previous user and asks again.
|
|
78
|
+
*/
|
|
79
|
+
sessionKey?: string | number | null;
|
|
80
|
+
/** Heading above the table list. Default "Data". */
|
|
81
|
+
title?: string;
|
|
82
|
+
/** Rows per page. Default 25. */
|
|
83
|
+
perPage?: number;
|
|
84
|
+
className?: string;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The Data Studio: a sidebar of the tables this user may see, and a sheet of
|
|
88
|
+
* the selected one where a cell is edited in place. Every edit carries the
|
|
89
|
+
* version it was read at, so a stale edit is refused rather than saved over
|
|
90
|
+
* someone else's, and the outcome — saved with its audit entry, or not saved
|
|
91
|
+
* and why — is shown on screen.
|
|
92
|
+
*
|
|
93
|
+
* It renders inside its own `GenesisProvider`, so it works whether or not
|
|
94
|
+
* the app already has one.
|
|
95
|
+
*
|
|
96
|
+
* @stability Experimental
|
|
97
|
+
*/
|
|
98
|
+
declare function DataStudio(props: DataStudioProps): react.JSX.Element;
|
|
99
|
+
|
|
100
|
+
export { DataStudio, type DataStudioProps, type StudioActivity, type StudioLocation, type StudioNavigateOptions, type StudioRouter, createSearchParamsRouter, locationFromSearchParams, searchParamsFromLocation, useHistoryRouter, useMemoryRouter };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/DataStudio.tsx
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
import { Database, History, Lock, Table2 } from "lucide-react";
|
|
7
|
+
import { GenesisProvider } from "@marktiderman/genesis-core/provider";
|
|
8
|
+
import {
|
|
9
|
+
encodeRecordKey,
|
|
10
|
+
isDataWriteError,
|
|
11
|
+
keyFields
|
|
12
|
+
} from "@marktiderman/genesis-core/schema";
|
|
13
|
+
import { ResourcePage } from "@marktiderman/genesis-ui/data";
|
|
14
|
+
import { Alert, AlertDescription, AlertTitle } from "@marktiderman/genesis-ui/alert";
|
|
15
|
+
import { cn } from "@marktiderman/genesis-ui/utils";
|
|
16
|
+
|
|
17
|
+
// src/values.ts
|
|
18
|
+
function editText(value) {
|
|
19
|
+
if (value === null || value === void 0) return "";
|
|
20
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
21
|
+
return String(value);
|
|
22
|
+
}
|
|
23
|
+
function displayText(value) {
|
|
24
|
+
if (value === null || value === void 0) return null;
|
|
25
|
+
return editText(value);
|
|
26
|
+
}
|
|
27
|
+
function parseDraft(field, draft) {
|
|
28
|
+
if (draft === "" && field.nullable) return null;
|
|
29
|
+
return draft;
|
|
30
|
+
}
|
|
31
|
+
function choicesFor(field) {
|
|
32
|
+
if (field.enumValues) return [...field.enumValues];
|
|
33
|
+
if (field.type === "boolean") return ["true", "false"];
|
|
34
|
+
return void 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/DataStudio.tsx
|
|
38
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
39
|
+
var PER_PAGE = 25;
|
|
40
|
+
var SEARCH_DEBOUNCE_MS = 300;
|
|
41
|
+
function isUnauthenticated(error) {
|
|
42
|
+
return isDataWriteError(error) && error.outcome === "unauthenticated";
|
|
43
|
+
}
|
|
44
|
+
function errorText(error) {
|
|
45
|
+
return error instanceof Error ? error.message : String(error);
|
|
46
|
+
}
|
|
47
|
+
function DataStudio(props) {
|
|
48
|
+
return /* @__PURE__ */ jsx(GenesisProvider, { provider: props.provider, children: /* @__PURE__ */ jsx(StudioWorkspace, { ...props }) });
|
|
49
|
+
}
|
|
50
|
+
function StudioWorkspace({
|
|
51
|
+
provider,
|
|
52
|
+
router,
|
|
53
|
+
signIn,
|
|
54
|
+
sessionKey = null,
|
|
55
|
+
title = "Data",
|
|
56
|
+
perPage = PER_PAGE,
|
|
57
|
+
className
|
|
58
|
+
}) {
|
|
59
|
+
const queryClient = useQueryClient();
|
|
60
|
+
const [activity, setActivity] = useState([]);
|
|
61
|
+
const [signedOut, setSignedOut] = useState(false);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setSignedOut(false);
|
|
64
|
+
setActivity([]);
|
|
65
|
+
void queryClient.resetQueries({ queryKey: ["genesis-studio"] });
|
|
66
|
+
}, [sessionKey, queryClient]);
|
|
67
|
+
const resources = useQuery({
|
|
68
|
+
queryKey: ["genesis-studio", sessionKey, "resources"],
|
|
69
|
+
queryFn: () => {
|
|
70
|
+
if (!provider.listResources) throw new Error("This data provider can't list its resources.");
|
|
71
|
+
return provider.listResources();
|
|
72
|
+
},
|
|
73
|
+
retry: (count, error) => !isUnauthenticated(error) && count < 2
|
|
74
|
+
});
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (isUnauthenticated(resources.error)) setSignedOut(true);
|
|
77
|
+
}, [resources.error]);
|
|
78
|
+
const record = useCallback((entry) => {
|
|
79
|
+
setActivity(
|
|
80
|
+
(prev) => [{ ...entry, id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, at: /* @__PURE__ */ new Date() }, ...prev].slice(0, 50)
|
|
81
|
+
);
|
|
82
|
+
}, []);
|
|
83
|
+
if (signedOut) {
|
|
84
|
+
return /* @__PURE__ */ jsx("div", { className: cn("flex min-h-[320px] items-center justify-center p-6", className), "data-testid": "studio-sign-in", children: signIn ?? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Sign in to use the studio." }) });
|
|
85
|
+
}
|
|
86
|
+
const selected = router.location.resource;
|
|
87
|
+
const list = resources.data ?? [];
|
|
88
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("flex min-h-[480px] w-full flex-col gap-4 lg:flex-row", className), "data-testid": "studio", children: [
|
|
89
|
+
/* @__PURE__ */ jsx(
|
|
90
|
+
TableSidebar,
|
|
91
|
+
{
|
|
92
|
+
title,
|
|
93
|
+
resources: list,
|
|
94
|
+
selected,
|
|
95
|
+
loading: resources.isLoading,
|
|
96
|
+
error: resources.isError ? errorText(resources.error) : null,
|
|
97
|
+
onSelect: (resource) => router.navigate({ resource })
|
|
98
|
+
}
|
|
99
|
+
),
|
|
100
|
+
/* @__PURE__ */ jsxs("main", { className: "min-w-0 flex-1 space-y-4", "data-testid": "studio-main", children: [
|
|
101
|
+
selected ? /* @__PURE__ */ jsx(
|
|
102
|
+
Sheet,
|
|
103
|
+
{
|
|
104
|
+
provider,
|
|
105
|
+
resource: selected,
|
|
106
|
+
sessionKey,
|
|
107
|
+
router,
|
|
108
|
+
perPage,
|
|
109
|
+
onActivity: record,
|
|
110
|
+
onSignedOut: () => setSignedOut(true)
|
|
111
|
+
},
|
|
112
|
+
selected
|
|
113
|
+
) : /* @__PURE__ */ jsxs(
|
|
114
|
+
"div",
|
|
115
|
+
{
|
|
116
|
+
className: "flex h-full min-h-[320px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-8 text-center text-muted-foreground",
|
|
117
|
+
"data-testid": "studio-empty",
|
|
118
|
+
children: [
|
|
119
|
+
/* @__PURE__ */ jsx(Table2, { className: "h-6 w-6", "aria-hidden": true }),
|
|
120
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm", children: list.length ? "Pick a table to open it." : "No tables to show yet." })
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
),
|
|
124
|
+
/* @__PURE__ */ jsx(ActivityPanel, { entries: activity })
|
|
125
|
+
] })
|
|
126
|
+
] });
|
|
127
|
+
}
|
|
128
|
+
function TableSidebar({
|
|
129
|
+
title,
|
|
130
|
+
resources,
|
|
131
|
+
selected,
|
|
132
|
+
loading,
|
|
133
|
+
error,
|
|
134
|
+
onSelect
|
|
135
|
+
}) {
|
|
136
|
+
return /* @__PURE__ */ jsxs("nav", { "aria-label": "Tables", className: "w-full shrink-0 self-start rounded-lg border bg-card lg:w-56", "data-testid": "studio-sidebar", children: [
|
|
137
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold", children: [
|
|
138
|
+
/* @__PURE__ */ jsx(Database, { className: "h-4 w-4 text-muted-foreground", "aria-hidden": true }),
|
|
139
|
+
title
|
|
140
|
+
] }),
|
|
141
|
+
loading && /* @__PURE__ */ jsx("p", { className: "px-3 py-2 text-xs text-muted-foreground", children: "Loading tables\u2026" }),
|
|
142
|
+
error && /* @__PURE__ */ jsx("p", { role: "alert", className: "px-3 py-2 text-xs text-destructive", children: error }),
|
|
143
|
+
/* @__PURE__ */ jsx("ul", { className: "p-1", children: resources.map((r) => {
|
|
144
|
+
const active = r.name === selected;
|
|
145
|
+
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
|
|
146
|
+
"button",
|
|
147
|
+
{
|
|
148
|
+
type: "button",
|
|
149
|
+
onClick: () => onSelect(r.name),
|
|
150
|
+
"aria-current": active ? "page" : void 0,
|
|
151
|
+
title: r.access.update ? r.name : `${r.name} \u2014 ${r.access.reason ?? "Read-only"}`,
|
|
152
|
+
"data-testid": `studio-table-${r.name}`,
|
|
153
|
+
className: cn(
|
|
154
|
+
"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm",
|
|
155
|
+
active ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
|
156
|
+
),
|
|
157
|
+
children: [
|
|
158
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: r.label ?? r.name }),
|
|
159
|
+
!r.access.update && /* @__PURE__ */ jsx(Lock, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground", "aria-label": "Read-only" })
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
) }, r.name);
|
|
163
|
+
}) })
|
|
164
|
+
] });
|
|
165
|
+
}
|
|
166
|
+
function parseSort(sort) {
|
|
167
|
+
if (!sort) return void 0;
|
|
168
|
+
const at = sort.lastIndexOf(":");
|
|
169
|
+
const field = at > 0 ? sort.slice(0, at) : sort;
|
|
170
|
+
const order = sort.slice(at + 1) === "desc" ? "desc" : "asc";
|
|
171
|
+
return field ? { field, order } : void 0;
|
|
172
|
+
}
|
|
173
|
+
function Sheet({
|
|
174
|
+
provider,
|
|
175
|
+
resource,
|
|
176
|
+
sessionKey,
|
|
177
|
+
router,
|
|
178
|
+
perPage,
|
|
179
|
+
onActivity,
|
|
180
|
+
onSignedOut
|
|
181
|
+
}) {
|
|
182
|
+
const queryClient = useQueryClient();
|
|
183
|
+
const { location } = router;
|
|
184
|
+
const page = location.page ?? 1;
|
|
185
|
+
const sort = parseSort(location.sort);
|
|
186
|
+
const search = location.search ?? "";
|
|
187
|
+
const schemaQuery = useQuery({
|
|
188
|
+
queryKey: ["genesis-studio", sessionKey, "describe", resource],
|
|
189
|
+
queryFn: () => {
|
|
190
|
+
if (!provider.describe) throw new Error("This data provider can't describe its resources.");
|
|
191
|
+
return provider.describe(resource);
|
|
192
|
+
},
|
|
193
|
+
retry: false
|
|
194
|
+
});
|
|
195
|
+
const schema = schemaQuery.data;
|
|
196
|
+
const listKey = useMemo(
|
|
197
|
+
() => ["genesis-studio", sessionKey, "list", resource, page, perPage, location.sort ?? "", search],
|
|
198
|
+
[sessionKey, resource, page, perPage, location.sort, search]
|
|
199
|
+
);
|
|
200
|
+
const listQuery = useQuery({
|
|
201
|
+
queryKey: listKey,
|
|
202
|
+
enabled: Boolean(schema),
|
|
203
|
+
placeholderData: keepPreviousData,
|
|
204
|
+
retry: false,
|
|
205
|
+
queryFn: () => provider.getList(resource, {
|
|
206
|
+
pagination: { page, perPage },
|
|
207
|
+
sort,
|
|
208
|
+
search: search ? { query: search, fields: [] } : void 0
|
|
209
|
+
})
|
|
210
|
+
});
|
|
211
|
+
const failure = schemaQuery.error ?? listQuery.error;
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (isUnauthenticated(failure)) onSignedOut();
|
|
214
|
+
}, [failure, onSignedOut]);
|
|
215
|
+
const searchTimer = useRef(void 0);
|
|
216
|
+
useEffect(() => () => clearTimeout(searchTimer.current), []);
|
|
217
|
+
const navigate = useCallback(
|
|
218
|
+
(patch, replace = false) => router.navigate({ ...location, resource, ...patch }, { replace }),
|
|
219
|
+
[router, location, resource]
|
|
220
|
+
);
|
|
221
|
+
const rows = listQuery.data?.data ?? [];
|
|
222
|
+
const keyed = Boolean(schema && keyFields(schema).length > 0);
|
|
223
|
+
const keys = useMemo(() => {
|
|
224
|
+
const map = /* @__PURE__ */ new Map();
|
|
225
|
+
rows.forEach((row, index) => map.set(row, schema && keyed ? String(encodeRecordKey(schema, row)) : `row-${index}`));
|
|
226
|
+
return map;
|
|
227
|
+
}, [rows, schema, keyed]);
|
|
228
|
+
const replaceRow = useCallback(
|
|
229
|
+
(oldRow, next) => {
|
|
230
|
+
queryClient.setQueryData(
|
|
231
|
+
listKey,
|
|
232
|
+
(prev) => prev ? { ...prev, data: prev.data.map((r) => r === oldRow ? next : r) } : prev
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
[queryClient, listKey]
|
|
236
|
+
);
|
|
237
|
+
const save = useCallback(
|
|
238
|
+
async (field, row, draft) => {
|
|
239
|
+
if (!schema) return;
|
|
240
|
+
if (draft === editText(row[field.name])) return;
|
|
241
|
+
const recordLabel = keys.get(row) ?? "";
|
|
242
|
+
const versionField = schema.version?.field;
|
|
243
|
+
const expectedVersion = versionField ? row[versionField] == null ? null : String(row[versionField]) : null;
|
|
244
|
+
try {
|
|
245
|
+
const result = await provider.update(resource, {
|
|
246
|
+
id: encodeRecordKey(schema, row),
|
|
247
|
+
data: { [field.name]: parseDraft(field, draft) },
|
|
248
|
+
expectedVersion
|
|
249
|
+
});
|
|
250
|
+
replaceRow(row, result.data);
|
|
251
|
+
onActivity({
|
|
252
|
+
resource,
|
|
253
|
+
field: field.name,
|
|
254
|
+
record: recordLabel,
|
|
255
|
+
outcome: "saved",
|
|
256
|
+
message: result.audit ? "Saved, with its audit entry." : "Saved.",
|
|
257
|
+
...result.audit ? { audit: result.audit } : {}
|
|
258
|
+
});
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (isUnauthenticated(error)) onSignedOut();
|
|
261
|
+
if (isDataWriteError(error) && error.outcome === "conflict" && error.current) {
|
|
262
|
+
replaceRow(row, {
|
|
263
|
+
...row,
|
|
264
|
+
...error.current,
|
|
265
|
+
...versionField ? { [versionField]: error.currentVersion ?? null } : {}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (isDataWriteError(error) && error.outcome === "gone") {
|
|
269
|
+
void queryClient.invalidateQueries({ queryKey: listKey });
|
|
270
|
+
}
|
|
271
|
+
onActivity({ resource, field: field.name, record: recordLabel, outcome: "not_saved", message: errorText(error) });
|
|
272
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
[schema, keys, provider, resource, replaceRow, onActivity, onSignedOut, queryClient, listKey]
|
|
276
|
+
);
|
|
277
|
+
const columns = useMemo(() => {
|
|
278
|
+
if (!schema) return [];
|
|
279
|
+
return schema.fields.map((field) => {
|
|
280
|
+
const options = choicesFor(field);
|
|
281
|
+
return {
|
|
282
|
+
key: field.name,
|
|
283
|
+
header: field.name,
|
|
284
|
+
sortable: true,
|
|
285
|
+
accessor: (row) => editText(row[field.name]),
|
|
286
|
+
render: (row) => {
|
|
287
|
+
const text = displayText(row[field.name]);
|
|
288
|
+
return text === null ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }) : /* @__PURE__ */ jsx("span", { className: "block max-w-[320px] truncate", title: text, children: text });
|
|
289
|
+
},
|
|
290
|
+
...field.permissions?.update ? {
|
|
291
|
+
editable: {
|
|
292
|
+
type: options ? "select" : "text",
|
|
293
|
+
...options ? { options } : {},
|
|
294
|
+
onSave: (row, draft) => save(field, row, draft)
|
|
295
|
+
}
|
|
296
|
+
} : {}
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
}, [schema, save]);
|
|
300
|
+
if (schemaQuery.isError && !isUnauthenticated(schemaQuery.error)) {
|
|
301
|
+
return /* @__PURE__ */ jsxs(Alert, { variant: "destructive", "data-testid": "studio-describe-error", children: [
|
|
302
|
+
/* @__PURE__ */ jsxs(AlertTitle, { children: [
|
|
303
|
+
"Couldn't open ",
|
|
304
|
+
resource
|
|
305
|
+
] }),
|
|
306
|
+
/* @__PURE__ */ jsx(AlertDescription, { children: errorText(schemaQuery.error) })
|
|
307
|
+
] });
|
|
308
|
+
}
|
|
309
|
+
const editable = schema?.fields.filter((f) => f.permissions?.update).length ?? 0;
|
|
310
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-3", "data-testid": "studio-sheet", children: [
|
|
311
|
+
schema && !schema.access.update && /* @__PURE__ */ jsxs(Alert, { "data-testid": "studio-read-only", children: [
|
|
312
|
+
/* @__PURE__ */ jsx(Lock, { className: "h-4 w-4", "aria-hidden": true }),
|
|
313
|
+
/* @__PURE__ */ jsx(AlertTitle, { children: "Read-only" }),
|
|
314
|
+
/* @__PURE__ */ jsx(AlertDescription, { children: schema.access.reason ?? "You can view this table but not edit it." })
|
|
315
|
+
] }),
|
|
316
|
+
/* @__PURE__ */ jsx(
|
|
317
|
+
ResourcePage,
|
|
318
|
+
{
|
|
319
|
+
title: schema?.label ?? resource,
|
|
320
|
+
subtitle: schemaSubtitle(schema, editable),
|
|
321
|
+
data: rows,
|
|
322
|
+
total: listQuery.data?.total ?? 0,
|
|
323
|
+
columns,
|
|
324
|
+
getKey: (row) => keys.get(row) ?? "",
|
|
325
|
+
detail: "none",
|
|
326
|
+
page,
|
|
327
|
+
perPage,
|
|
328
|
+
onPageChange: (next) => navigate({ page: next }),
|
|
329
|
+
onSortChange: (next) => navigate({ sort: next ? `${next.field}:${next.order}` : void 0, page: 1 }),
|
|
330
|
+
onSearchChange: (text) => {
|
|
331
|
+
clearTimeout(searchTimer.current);
|
|
332
|
+
searchTimer.current = setTimeout(() => navigate({ search: text || void 0, page: 1 }, true), SEARCH_DEBOUNCE_MS);
|
|
333
|
+
},
|
|
334
|
+
initialFilters: [
|
|
335
|
+
...search ? [{ field: "__search__", operator: "contains", value: search }] : [],
|
|
336
|
+
...location.sort ? [{ field: "__sort__", operator: "eq", value: location.sort }] : []
|
|
337
|
+
],
|
|
338
|
+
loading: schemaQuery.isLoading || listQuery.isLoading,
|
|
339
|
+
error: listQuery.isError && !isUnauthenticated(listQuery.error),
|
|
340
|
+
errorMessage: listQuery.error ? errorText(listQuery.error) : void 0,
|
|
341
|
+
onRetry: () => void listQuery.refetch(),
|
|
342
|
+
columnVisibilityKey: `genesis-studio:${resource}`,
|
|
343
|
+
resizeKey: `genesis-studio:${resource}`,
|
|
344
|
+
viewSettingsKey: `genesis-studio:${resource}`,
|
|
345
|
+
stickyFirstColumn: true
|
|
346
|
+
}
|
|
347
|
+
)
|
|
348
|
+
] });
|
|
349
|
+
}
|
|
350
|
+
function schemaSubtitle(schema, editable) {
|
|
351
|
+
if (!schema) return void 0;
|
|
352
|
+
const parts = [schema.name];
|
|
353
|
+
if (schema.access.update) parts.push(`${editable} of ${schema.fields.length} fields editable`);
|
|
354
|
+
if (schema.version) parts.push(`edits checked against ${schema.version.field}`);
|
|
355
|
+
return parts.join(" \xB7 ");
|
|
356
|
+
}
|
|
357
|
+
function ActivityPanel({ entries }) {
|
|
358
|
+
return /* @__PURE__ */ jsxs("section", { "aria-label": "This session's edits", className: "rounded-lg border bg-card", "data-testid": "studio-activity", children: [
|
|
359
|
+
/* @__PURE__ */ jsxs("h2", { className: "flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold", children: [
|
|
360
|
+
/* @__PURE__ */ jsx(History, { className: "h-4 w-4 text-muted-foreground", "aria-hidden": true }),
|
|
361
|
+
"This session's edits"
|
|
362
|
+
] }),
|
|
363
|
+
entries.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-3 py-3 text-xs text-muted-foreground", children: "Click a cell to edit it. Each save is checked against the latest version of the row, and audited." }) : /* @__PURE__ */ jsx("ol", { className: "divide-y", "aria-live": "polite", children: entries.map((entry) => /* @__PURE__ */ jsxs("li", { className: "px-3 py-2 text-xs", "data-testid": `studio-activity-${entry.outcome}`, children: [
|
|
364
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-x-2", children: [
|
|
365
|
+
/* @__PURE__ */ jsx("span", { className: entry.outcome === "saved" ? "font-medium text-foreground" : "font-medium text-destructive", children: entry.outcome === "saved" ? "Saved" : "Not saved" }),
|
|
366
|
+
/* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
|
|
367
|
+
entry.resource,
|
|
368
|
+
" \xB7 ",
|
|
369
|
+
entry.field,
|
|
370
|
+
" \xB7 ",
|
|
371
|
+
entry.record
|
|
372
|
+
] }),
|
|
373
|
+
/* @__PURE__ */ jsx("time", { className: "ml-auto text-muted-foreground", dateTime: entry.at.toISOString(), children: entry.at.toLocaleTimeString() })
|
|
374
|
+
] }),
|
|
375
|
+
/* @__PURE__ */ jsx("p", { className: "mt-0.5 text-muted-foreground", children: entry.message }),
|
|
376
|
+
entry.audit && /* @__PURE__ */ jsx(AuditLine, { audit: entry.audit })
|
|
377
|
+
] }, entry.id)) })
|
|
378
|
+
] });
|
|
379
|
+
}
|
|
380
|
+
function AuditLine({ audit }) {
|
|
381
|
+
const changes = Object.keys(audit.after ?? {}).map((field) => ({
|
|
382
|
+
field,
|
|
383
|
+
before: displayText(audit.before?.[field]) ?? "\u2014",
|
|
384
|
+
after: displayText(audit.after?.[field]) ?? "\u2014"
|
|
385
|
+
}));
|
|
386
|
+
return /* @__PURE__ */ jsxs("div", { className: "mt-1 rounded bg-muted/50 px-2 py-1 font-mono text-[11px]", "data-testid": "studio-audit-entry", children: [
|
|
387
|
+
/* @__PURE__ */ jsxs("div", { className: "text-muted-foreground", children: [
|
|
388
|
+
"audit ",
|
|
389
|
+
audit.id.slice(0, 8),
|
|
390
|
+
" \xB7 ",
|
|
391
|
+
audit.occurredAt,
|
|
392
|
+
" \xB7 by ",
|
|
393
|
+
audit.actor ?? "unknown"
|
|
394
|
+
] }),
|
|
395
|
+
changes.map((c) => /* @__PURE__ */ jsxs("div", { children: [
|
|
396
|
+
c.field,
|
|
397
|
+
": ",
|
|
398
|
+
c.before,
|
|
399
|
+
" \u2192 ",
|
|
400
|
+
c.after
|
|
401
|
+
] }, c.field))
|
|
402
|
+
] });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/router.ts
|
|
406
|
+
import { useCallback as useCallback2, useEffect as useEffect2, useMemo as useMemo2, useState as useState2 } from "react";
|
|
407
|
+
var PARAM = { resource: "table", page: "page", sort: "sort", search: "q" };
|
|
408
|
+
function locationFromSearchParams(params) {
|
|
409
|
+
const page = Number(params.get(PARAM.page));
|
|
410
|
+
return {
|
|
411
|
+
...params.get(PARAM.resource) ? { resource: params.get(PARAM.resource) } : {},
|
|
412
|
+
...Number.isInteger(page) && page > 1 ? { page } : {},
|
|
413
|
+
...params.get(PARAM.sort) ? { sort: params.get(PARAM.sort) } : {},
|
|
414
|
+
...params.get(PARAM.search) ? { search: params.get(PARAM.search) } : {}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function searchParamsFromLocation(location, base) {
|
|
418
|
+
const params = new URLSearchParams(base);
|
|
419
|
+
for (const name of Object.values(PARAM)) params.delete(name);
|
|
420
|
+
if (location.resource) params.set(PARAM.resource, location.resource);
|
|
421
|
+
if (location.page && location.page > 1) params.set(PARAM.page, String(location.page));
|
|
422
|
+
if (location.sort) params.set(PARAM.sort, location.sort);
|
|
423
|
+
if (location.search) params.set(PARAM.search, location.search);
|
|
424
|
+
return params;
|
|
425
|
+
}
|
|
426
|
+
function createSearchParamsRouter(params, setParams) {
|
|
427
|
+
return {
|
|
428
|
+
location: locationFromSearchParams(params),
|
|
429
|
+
navigate: (next, options) => setParams(searchParamsFromLocation(next, params), { replace: options?.replace ?? false })
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function useHistoryRouter() {
|
|
433
|
+
const read = () => typeof window === "undefined" ? {} : locationFromSearchParams(new URLSearchParams(window.location.search));
|
|
434
|
+
const [location, setLocation] = useState2(read);
|
|
435
|
+
useEffect2(() => {
|
|
436
|
+
const onPop = () => setLocation(read());
|
|
437
|
+
window.addEventListener("popstate", onPop);
|
|
438
|
+
return () => window.removeEventListener("popstate", onPop);
|
|
439
|
+
}, []);
|
|
440
|
+
const navigate = useCallback2((next, options) => {
|
|
441
|
+
const params = searchParamsFromLocation(next, new URLSearchParams(window.location.search));
|
|
442
|
+
const query = params.toString();
|
|
443
|
+
const url = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`;
|
|
444
|
+
if (options?.replace) window.history.replaceState(window.history.state, "", url);
|
|
445
|
+
else window.history.pushState(window.history.state, "", url);
|
|
446
|
+
setLocation(next);
|
|
447
|
+
}, []);
|
|
448
|
+
return useMemo2(() => ({ location, navigate }), [location, navigate]);
|
|
449
|
+
}
|
|
450
|
+
function useMemoryRouter(initial = {}) {
|
|
451
|
+
const [location, setLocation] = useState2(initial);
|
|
452
|
+
const navigate = useCallback2((next) => setLocation(next), []);
|
|
453
|
+
return useMemo2(() => ({ location, navigate }), [location, navigate]);
|
|
454
|
+
}
|
|
455
|
+
export {
|
|
456
|
+
DataStudio,
|
|
457
|
+
createSearchParamsRouter,
|
|
458
|
+
locationFromSearchParams,
|
|
459
|
+
searchParamsFromLocation,
|
|
460
|
+
useHistoryRouter,
|
|
461
|
+
useMemoryRouter
|
|
462
|
+
};
|
|
463
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/DataStudio.tsx","../src/values.ts","../src/router.ts"],"sourcesContent":["import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport { keepPreviousData, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { Database, History, Lock, Table2 } from \"lucide-react\";\nimport { GenesisProvider, type BaseRecord, type DataProvider } from \"@marktiderman/genesis-core/provider\";\nimport {\n encodeRecordKey,\n isDataWriteError,\n keyFields,\n type AuditEntry,\n type FieldSchema,\n type ResourceSchema,\n type ResourceSummary,\n} from \"@marktiderman/genesis-core/schema\";\nimport { ResourcePage, type ResourceColumnDef } from \"@marktiderman/genesis-ui/data\";\nimport { Alert, AlertDescription, AlertTitle } from \"@marktiderman/genesis-ui/alert\";\nimport { cn } from \"@marktiderman/genesis-ui/utils\";\nimport type { StudioLocation, StudioRouter } from \"./router\";\nimport { choicesFor, displayText, editText, parseDraft } from \"./values\";\n\ntype Row = Record<string, unknown>;\n\n/** One entry in the session's activity panel. */\nexport interface StudioActivity {\n id: string;\n at: Date;\n resource: string;\n field: string;\n /** The record's key, as shown to the user. */\n record: string;\n outcome: \"saved\" | \"not_saved\";\n message: string;\n /** Present when the edit committed with an audit entry from the `table` sink. */\n audit?: AuditEntry;\n}\n\nexport interface DataStudioProps {\n /**\n * Where the data comes from: the studio handler's client\n * (`createStudioProvider` from `@marktiderman/genesis-studio-server/client`)\n * or any Genesis `DataProvider` that implements `describe` and\n * `listResources`.\n */\n provider: DataProvider;\n /** URL state, from the app's router. See `createSearchParamsRouter` and `useHistoryRouter`. */\n router: StudioRouter;\n /** Shown in place of the studio while nobody is signed in. */\n signIn?: ReactNode;\n /**\n * Changes when the signed-in user does (their id, say). The studio drops\n * everything it loaded for the previous user and asks again.\n */\n sessionKey?: string | number | null;\n /** Heading above the table list. Default \"Data\". */\n title?: string;\n /** Rows per page. Default 25. */\n perPage?: number;\n className?: string;\n}\n\nconst PER_PAGE = 25;\nconst SEARCH_DEBOUNCE_MS = 300;\n\nfunction isUnauthenticated(error: unknown): boolean {\n return isDataWriteError(error) && error.outcome === \"unauthenticated\";\n}\n\nfunction errorText(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/**\n * The Data Studio: a sidebar of the tables this user may see, and a sheet of\n * the selected one where a cell is edited in place. Every edit carries the\n * version it was read at, so a stale edit is refused rather than saved over\n * someone else's, and the outcome — saved with its audit entry, or not saved\n * and why — is shown on screen.\n *\n * It renders inside its own `GenesisProvider`, so it works whether or not\n * the app already has one.\n *\n * @stability Experimental\n */\nexport function DataStudio(props: DataStudioProps) {\n return (\n <GenesisProvider provider={props.provider}>\n <StudioWorkspace {...props} />\n </GenesisProvider>\n );\n}\n\nfunction StudioWorkspace({\n provider,\n router,\n signIn,\n sessionKey = null,\n title = \"Data\",\n perPage = PER_PAGE,\n className,\n}: DataStudioProps) {\n const queryClient = useQueryClient();\n const [activity, setActivity] = useState<StudioActivity[]>([]);\n const [signedOut, setSignedOut] = useState(false);\n\n // A new user sees nothing the previous one loaded.\n useEffect(() => {\n setSignedOut(false);\n setActivity([]);\n void queryClient.resetQueries({ queryKey: [\"genesis-studio\"] });\n }, [sessionKey, queryClient]);\n\n const resources = useQuery({\n queryKey: [\"genesis-studio\", sessionKey, \"resources\"],\n queryFn: () => {\n if (!provider.listResources) throw new Error(\"This data provider can't list its resources.\");\n return provider.listResources();\n },\n retry: (count, error) => !isUnauthenticated(error) && count < 2,\n });\n\n useEffect(() => {\n if (isUnauthenticated(resources.error)) setSignedOut(true);\n }, [resources.error]);\n\n const record = useCallback((entry: Omit<StudioActivity, \"id\" | \"at\">) => {\n setActivity((prev) =>\n [{ ...entry, id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, at: new Date() }, ...prev].slice(0, 50),\n );\n }, []);\n\n if (signedOut) {\n return (\n <div className={cn(\"flex min-h-[320px] items-center justify-center p-6\", className)} data-testid=\"studio-sign-in\">\n {signIn ?? <p className=\"text-sm text-muted-foreground\">Sign in to use the studio.</p>}\n </div>\n );\n }\n\n const selected = router.location.resource;\n const list = resources.data ?? [];\n\n return (\n <div className={cn(\"flex min-h-[480px] w-full flex-col gap-4 lg:flex-row\", className)} data-testid=\"studio\">\n <TableSidebar\n title={title}\n resources={list}\n selected={selected}\n loading={resources.isLoading}\n error={resources.isError ? errorText(resources.error) : null}\n onSelect={(resource) => router.navigate({ resource })}\n />\n <main className=\"min-w-0 flex-1 space-y-4\" data-testid=\"studio-main\">\n {selected ? (\n <Sheet\n key={selected}\n provider={provider}\n resource={selected}\n sessionKey={sessionKey}\n router={router}\n perPage={perPage}\n onActivity={record}\n onSignedOut={() => setSignedOut(true)}\n />\n ) : (\n <div\n className=\"flex h-full min-h-[320px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-8 text-center text-muted-foreground\"\n data-testid=\"studio-empty\"\n >\n <Table2 className=\"h-6 w-6\" aria-hidden />\n <p className=\"text-sm\">{list.length ? \"Pick a table to open it.\" : \"No tables to show yet.\"}</p>\n </div>\n )}\n <ActivityPanel entries={activity} />\n </main>\n </div>\n );\n}\n\nfunction TableSidebar({\n title,\n resources,\n selected,\n loading,\n error,\n onSelect,\n}: {\n title: string;\n resources: ResourceSummary[];\n selected?: string;\n loading: boolean;\n error: string | null;\n onSelect: (resource: string) => void;\n}) {\n return (\n <nav aria-label=\"Tables\" className=\"w-full shrink-0 self-start rounded-lg border bg-card lg:w-56\" data-testid=\"studio-sidebar\">\n <div className=\"flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold\">\n <Database className=\"h-4 w-4 text-muted-foreground\" aria-hidden />\n {title}\n </div>\n {loading && <p className=\"px-3 py-2 text-xs text-muted-foreground\">Loading tables…</p>}\n {error && (\n <p role=\"alert\" className=\"px-3 py-2 text-xs text-destructive\">\n {error}\n </p>\n )}\n <ul className=\"p-1\">\n {resources.map((r) => {\n const active = r.name === selected;\n return (\n <li key={r.name}>\n <button\n type=\"button\"\n onClick={() => onSelect(r.name)}\n aria-current={active ? \"page\" : undefined}\n title={r.access.update ? r.name : `${r.name} — ${r.access.reason ?? \"Read-only\"}`}\n data-testid={`studio-table-${r.name}`}\n className={cn(\n \"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm\",\n active ? \"bg-accent text-accent-foreground\" : \"hover:bg-muted\",\n )}\n >\n <span className=\"truncate\">{r.label ?? r.name}</span>\n {!r.access.update && (\n <Lock className=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" aria-label=\"Read-only\" />\n )}\n </button>\n </li>\n );\n })}\n </ul>\n </nav>\n );\n}\n\nfunction parseSort(sort: string | undefined): { field: string; order: \"asc\" | \"desc\" } | undefined {\n if (!sort) return undefined;\n const at = sort.lastIndexOf(\":\");\n const field = at > 0 ? sort.slice(0, at) : sort;\n const order = sort.slice(at + 1) === \"desc\" ? \"desc\" : \"asc\";\n return field ? { field, order } : undefined;\n}\n\nfunction Sheet({\n provider,\n resource,\n sessionKey,\n router,\n perPage,\n onActivity,\n onSignedOut,\n}: {\n provider: DataProvider;\n resource: string;\n sessionKey: string | number | null;\n router: StudioRouter;\n perPage: number;\n onActivity: (entry: Omit<StudioActivity, \"id\" | \"at\">) => void;\n onSignedOut: () => void;\n}) {\n const queryClient = useQueryClient();\n const { location } = router;\n const page = location.page ?? 1;\n const sort = parseSort(location.sort);\n const search = location.search ?? \"\";\n\n const schemaQuery = useQuery({\n queryKey: [\"genesis-studio\", sessionKey, \"describe\", resource],\n queryFn: () => {\n if (!provider.describe) throw new Error(\"This data provider can't describe its resources.\");\n return provider.describe(resource);\n },\n retry: false,\n });\n const schema = schemaQuery.data;\n\n const listKey = useMemo(\n () => [\"genesis-studio\", sessionKey, \"list\", resource, page, perPage, location.sort ?? \"\", search],\n [sessionKey, resource, page, perPage, location.sort, search],\n );\n const listQuery = useQuery({\n queryKey: listKey,\n enabled: Boolean(schema),\n placeholderData: keepPreviousData,\n retry: false,\n queryFn: () =>\n provider.getList<BaseRecord>(resource, {\n pagination: { page, perPage },\n sort,\n search: search ? { query: search, fields: [] } : undefined,\n }),\n });\n\n const failure = schemaQuery.error ?? listQuery.error;\n useEffect(() => {\n if (isUnauthenticated(failure)) onSignedOut();\n }, [failure, onSignedOut]);\n\n // Debounce search into the URL; typing replaces rather than pushes history.\n const searchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(searchTimer.current), []);\n const navigate = useCallback(\n (patch: Partial<StudioLocation>, replace = false) => router.navigate({ ...location, resource, ...patch }, { replace }),\n [router, location, resource],\n );\n\n const rows = (listQuery.data?.data ?? []) as Row[];\n const keyed = Boolean(schema && keyFields(schema).length > 0);\n const keys = useMemo(() => {\n const map = new Map<Row, string>();\n rows.forEach((row, index) => map.set(row, schema && keyed ? String(encodeRecordKey(schema, row)) : `row-${index}`));\n return map;\n }, [rows, schema, keyed]);\n\n const replaceRow = useCallback(\n (oldRow: Row, next: Row) => {\n queryClient.setQueryData(listKey, (prev: { data: Row[]; total: number } | undefined) =>\n prev ? { ...prev, data: prev.data.map((r) => (r === oldRow ? next : r)) } : prev,\n );\n },\n [queryClient, listKey],\n );\n\n const save = useCallback(\n async (field: FieldSchema, row: Row, draft: string) => {\n if (!schema) return;\n if (draft === editText(row[field.name])) return; // nothing changed\n const recordLabel = keys.get(row) ?? \"\";\n const versionField = schema.version?.field;\n const expectedVersion = versionField ? (row[versionField] == null ? null : String(row[versionField])) : null;\n try {\n const result = await provider.update<BaseRecord>(resource, {\n id: encodeRecordKey(schema, row),\n data: { [field.name]: parseDraft(field, draft) },\n expectedVersion,\n });\n replaceRow(row, result.data as Row);\n onActivity({\n resource,\n field: field.name,\n record: recordLabel,\n outcome: \"saved\",\n message: result.audit ? \"Saved, with its audit entry.\" : \"Saved.\",\n ...(result.audit ? { audit: result.audit } : {}),\n });\n } catch (error) {\n if (isUnauthenticated(error)) onSignedOut();\n if (isDataWriteError(error) && error.outcome === \"conflict\" && error.current) {\n // Show the row as it is now, so the next edit starts from their change.\n replaceRow(row, {\n ...row,\n ...error.current,\n ...(versionField ? { [versionField]: error.currentVersion ?? null } : {}),\n });\n }\n if (isDataWriteError(error) && error.outcome === \"gone\") {\n void queryClient.invalidateQueries({ queryKey: listKey });\n }\n onActivity({ resource, field: field.name, record: recordLabel, outcome: \"not_saved\", message: errorText(error) });\n throw error instanceof Error ? error : new Error(String(error));\n }\n },\n [schema, keys, provider, resource, replaceRow, onActivity, onSignedOut, queryClient, listKey],\n );\n\n const columns = useMemo<ResourceColumnDef<Row>[]>(() => {\n if (!schema) return [];\n return schema.fields.map((field) => {\n const options = choicesFor(field);\n return {\n key: field.name,\n header: field.name,\n sortable: true,\n accessor: (row: Row) => editText(row[field.name]),\n render: (row: Row) => {\n const text = displayText(row[field.name]);\n return text === null ? (\n <span className=\"text-muted-foreground\">—</span>\n ) : (\n <span className=\"block max-w-[320px] truncate\" title={text}>\n {text}\n </span>\n );\n },\n ...(field.permissions?.update\n ? {\n editable: {\n type: options ? (\"select\" as const) : (\"text\" as const),\n ...(options ? { options } : {}),\n onSave: (row: Row, draft: string) => save(field, row, draft),\n },\n }\n : {}),\n };\n });\n }, [schema, save]);\n\n if (schemaQuery.isError && !isUnauthenticated(schemaQuery.error)) {\n return (\n <Alert variant=\"destructive\" data-testid=\"studio-describe-error\">\n <AlertTitle>Couldn't open {resource}</AlertTitle>\n <AlertDescription>{errorText(schemaQuery.error)}</AlertDescription>\n </Alert>\n );\n }\n\n const editable = schema?.fields.filter((f) => f.permissions?.update).length ?? 0;\n\n return (\n <div className=\"space-y-3\" data-testid=\"studio-sheet\">\n {schema && !schema.access.update && (\n <Alert data-testid=\"studio-read-only\">\n <Lock className=\"h-4 w-4\" aria-hidden />\n <AlertTitle>Read-only</AlertTitle>\n <AlertDescription>{schema.access.reason ?? \"You can view this table but not edit it.\"}</AlertDescription>\n </Alert>\n )}\n <ResourcePage<Row>\n title={schema?.label ?? resource}\n subtitle={schemaSubtitle(schema, editable)}\n data={rows}\n total={listQuery.data?.total ?? 0}\n columns={columns}\n getKey={(row) => keys.get(row) ?? \"\"}\n detail=\"none\"\n page={page}\n perPage={perPage}\n onPageChange={(next) => navigate({ page: next })}\n onSortChange={(next) => navigate({ sort: next ? `${next.field}:${next.order}` : undefined, page: 1 })}\n onSearchChange={(text) => {\n clearTimeout(searchTimer.current);\n searchTimer.current = setTimeout(() => navigate({ search: text || undefined, page: 1 }, true), SEARCH_DEBOUNCE_MS);\n }}\n // Seed the search box and sort control from the URL, so a deep link\n // or a reload shows why the rows are filtered. Sheet remounts per\n // table, so mount-time seeding is enough.\n initialFilters={[\n ...(search ? [{ field: \"__search__\", operator: \"contains\" as const, value: search }] : []),\n ...(location.sort ? [{ field: \"__sort__\", operator: \"eq\" as const, value: location.sort }] : []),\n ]}\n loading={schemaQuery.isLoading || listQuery.isLoading}\n error={listQuery.isError && !isUnauthenticated(listQuery.error)}\n errorMessage={listQuery.error ? errorText(listQuery.error) : undefined}\n onRetry={() => void listQuery.refetch()}\n columnVisibilityKey={`genesis-studio:${resource}`}\n resizeKey={`genesis-studio:${resource}`}\n viewSettingsKey={`genesis-studio:${resource}`}\n stickyFirstColumn\n />\n </div>\n );\n}\n\nfunction schemaSubtitle(schema: ResourceSchema | undefined, editable: number): string | undefined {\n if (!schema) return undefined;\n const parts = [schema.name];\n if (schema.access.update) parts.push(`${editable} of ${schema.fields.length} fields editable`);\n if (schema.version) parts.push(`edits checked against ${schema.version.field}`);\n return parts.join(\" · \");\n}\n\nfunction ActivityPanel({ entries }: { entries: StudioActivity[] }) {\n return (\n <section aria-label=\"This session's edits\" className=\"rounded-lg border bg-card\" data-testid=\"studio-activity\">\n <h2 className=\"flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold\">\n <History className=\"h-4 w-4 text-muted-foreground\" aria-hidden />\n This session's edits\n </h2>\n {entries.length === 0 ? (\n <p className=\"px-3 py-3 text-xs text-muted-foreground\">\n Click a cell to edit it. Each save is checked against the latest version of the row, and audited.\n </p>\n ) : (\n <ol className=\"divide-y\" aria-live=\"polite\">\n {entries.map((entry) => (\n <li key={entry.id} className=\"px-3 py-2 text-xs\" data-testid={`studio-activity-${entry.outcome}`}>\n <div className=\"flex flex-wrap items-baseline gap-x-2\">\n <span className={entry.outcome === \"saved\" ? \"font-medium text-foreground\" : \"font-medium text-destructive\"}>\n {entry.outcome === \"saved\" ? \"Saved\" : \"Not saved\"}\n </span>\n <span className=\"text-muted-foreground\">\n {entry.resource} · {entry.field} · {entry.record}\n </span>\n <time className=\"ml-auto text-muted-foreground\" dateTime={entry.at.toISOString()}>\n {entry.at.toLocaleTimeString()}\n </time>\n </div>\n <p className=\"mt-0.5 text-muted-foreground\">{entry.message}</p>\n {entry.audit && <AuditLine audit={entry.audit} />}\n </li>\n ))}\n </ol>\n )}\n </section>\n );\n}\n\nfunction AuditLine({ audit }: { audit: AuditEntry }) {\n const changes = Object.keys(audit.after ?? {}).map((field) => ({\n field,\n before: displayText(audit.before?.[field]) ?? \"—\",\n after: displayText(audit.after?.[field]) ?? \"—\",\n }));\n return (\n <div className=\"mt-1 rounded bg-muted/50 px-2 py-1 font-mono text-[11px]\" data-testid=\"studio-audit-entry\">\n <div className=\"text-muted-foreground\">\n audit {audit.id.slice(0, 8)} · {audit.occurredAt} · by {audit.actor ?? \"unknown\"}\n </div>\n {changes.map((c) => (\n <div key={c.field}>\n {c.field}: {c.before} → {c.after}\n </div>\n ))}\n </div>\n );\n}\n","// How a stored value is shown, edited, and sent back. Postgres parses what\n// comes back (the handler casts every value to the column's type), so the\n// studio only decides what text the user sees and what \"empty\" means.\n\nimport type { FieldSchema } from \"@marktiderman/genesis-core/schema\";\n\n/** The text a cell starts editing from. JSON is edited as JSON text. */\nexport function editText(value: unknown): string {\n if (value === null || value === undefined) return \"\";\n if (typeof value === \"object\") return JSON.stringify(value);\n return String(value);\n}\n\n/** The text a cell shows, or `null` for an empty cell. */\nexport function displayText(value: unknown): string | null {\n if (value === null || value === undefined) return null;\n return editText(value);\n}\n\n/**\n * The value to save for what the user typed. An emptied cell becomes `null`\n * when the field accepts null, and the empty string otherwise.\n */\nexport function parseDraft(field: FieldSchema, draft: string): unknown {\n if (draft === \"\" && field.nullable) return null;\n return draft;\n}\n\n/** The choices for a select-style cell, or `undefined` for free text. */\nexport function choicesFor(field: FieldSchema): string[] | undefined {\n if (field.enumValues) return [...field.enumValues];\n if (field.type === \"boolean\") return [\"true\", \"false\"];\n return undefined;\n}\n","// ---------------------------------------------------------------------------\n// Routing and URL state come from the host app (DATA-STUDIO.md decision 23).\n//\n// The studio never touches `window.location` itself. It reads a\n// `StudioLocation` and asks for a new one; the adapter decides how that maps\n// onto the app's router, so a link to \"clients, page 3, sorted by name\" is a\n// plain URL the app already knows how to serve.\n// ---------------------------------------------------------------------------\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\n\n/** Everything the studio keeps in the URL. */\nexport interface StudioLocation {\n /** `schema.table`, e.g. `public.clients`. */\n resource?: string;\n /** 1-based. */\n page?: number;\n /** `field:asc` or `field:desc`. */\n sort?: string;\n search?: string;\n}\n\nexport interface StudioNavigateOptions {\n /** Replace the current history entry instead of pushing one (e.g. while typing a search). */\n replace?: boolean;\n}\n\nexport interface StudioRouter {\n location: StudioLocation;\n navigate(next: StudioLocation, options?: StudioNavigateOptions): void;\n}\n\n/** Query parameter names. Short, and namespaced enough not to collide with an app's own. */\nconst PARAM = { resource: \"table\", page: \"page\", sort: \"sort\", search: \"q\" } as const;\n\nexport function locationFromSearchParams(params: URLSearchParams): StudioLocation {\n const page = Number(params.get(PARAM.page));\n return {\n ...(params.get(PARAM.resource) ? { resource: params.get(PARAM.resource)! } : {}),\n ...(Number.isInteger(page) && page > 1 ? { page } : {}),\n ...(params.get(PARAM.sort) ? { sort: params.get(PARAM.sort)! } : {}),\n ...(params.get(PARAM.search) ? { search: params.get(PARAM.search)! } : {}),\n };\n}\n\n/**\n * The studio's location as query parameters, keeping every parameter in\n * `base` that isn't the studio's own.\n */\nexport function searchParamsFromLocation(location: StudioLocation, base?: URLSearchParams): URLSearchParams {\n const params = new URLSearchParams(base);\n for (const name of Object.values(PARAM)) params.delete(name);\n if (location.resource) params.set(PARAM.resource, location.resource);\n if (location.page && location.page > 1) params.set(PARAM.page, String(location.page));\n if (location.sort) params.set(PARAM.sort, location.sort);\n if (location.search) params.set(PARAM.search, location.search);\n return params;\n}\n\n/**\n * An adapter over any router that exposes search params with a setter —\n * React Router's `useSearchParams`, or Next.js's `useSearchParams` plus\n * `router.replace`/`push`.\n *\n * @example React Router\n * const [params, setParams] = useSearchParams();\n * const router = createSearchParamsRouter(params, (next, { replace }) => setParams(next, { replace }));\n */\nexport function createSearchParamsRouter(\n params: URLSearchParams,\n setParams: (next: URLSearchParams, options: { replace: boolean }) => void,\n): StudioRouter {\n return {\n location: locationFromSearchParams(params),\n navigate: (next, options) => setParams(searchParamsFromLocation(next, params), { replace: options?.replace ?? false }),\n };\n}\n\n/**\n * For an app with no router: keeps the studio's state in the current page's\n * query string through the History API.\n */\nexport function useHistoryRouter(): StudioRouter {\n const read = () =>\n typeof window === \"undefined\" ? {} : locationFromSearchParams(new URLSearchParams(window.location.search));\n const [location, setLocation] = useState<StudioLocation>(read);\n\n useEffect(() => {\n const onPop = () => setLocation(read());\n window.addEventListener(\"popstate\", onPop);\n return () => window.removeEventListener(\"popstate\", onPop);\n }, []);\n\n const navigate = useCallback((next: StudioLocation, options?: StudioNavigateOptions) => {\n const params = searchParamsFromLocation(next, new URLSearchParams(window.location.search));\n const query = params.toString();\n const url = `${window.location.pathname}${query ? `?${query}` : \"\"}${window.location.hash}`;\n if (options?.replace) window.history.replaceState(window.history.state, \"\", url);\n else window.history.pushState(window.history.state, \"\", url);\n setLocation(next);\n }, []);\n\n return useMemo(() => ({ location, navigate }), [location, navigate]);\n}\n\n/** A router held in memory, for tests and embedded previews. */\nexport function useMemoryRouter(initial: StudioLocation = {}): StudioRouter {\n const [location, setLocation] = useState<StudioLocation>(initial);\n const navigate = useCallback((next: StudioLocation) => setLocation(next), []);\n return useMemo(() => ({ location, navigate }), [location, navigate]);\n}\n"],"mappings":"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","names":["useCallback","useEffect","useMemo","useState"]}
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tailwind v4 source entry for @marktiderman/genesis-studio.
|
|
3
|
+
*
|
|
4
|
+
* Copied to dist/styles.css at build time, so "./" is the published dist/
|
|
5
|
+
* folder: importing this file makes Tailwind scan the studio's compiled
|
|
6
|
+
* components for the utility classes they use. Import genesis-ui's too.
|
|
7
|
+
*
|
|
8
|
+
* @import "tailwindcss";
|
|
9
|
+
* @import "@marktiderman/genesis-ui/styles.css";
|
|
10
|
+
* @import "@marktiderman/genesis-studio/styles.css";
|
|
11
|
+
*/
|
|
12
|
+
@source "./";
|
package/package.json
CHANGED
|
@@ -1 +1,65 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@marktiderman/genesis-studio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"*.css"
|
|
8
|
+
],
|
|
9
|
+
"description": "Genesis Data Studio — an Airtable-style workspace for a Postgres or Supabase app's own data: a table sidebar and a sheet grid with version-checked, audited single-cell edits, as the signed-in user. Built on genesis-ui's ResourcePage; routing and sign-in come from the host app. Part of the Genesis framework.",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/marktiderman/genesis.git",
|
|
13
|
+
"directory": "packages/studio"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"provenance": false
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
"./package.json": "./package.json",
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./styles.css": "./dist/styles.css"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@marktiderman/genesis-core": "^1.2.0",
|
|
37
|
+
"@marktiderman/genesis-ui": "^2.5.0",
|
|
38
|
+
"@tanstack/react-query": "^5.0.0",
|
|
39
|
+
"lucide-react": "^1.0.0",
|
|
40
|
+
"react": "^19.0.0",
|
|
41
|
+
"react-dom": "^19.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@marktiderman/genesis-core": "^1.2.0",
|
|
45
|
+
"@marktiderman/genesis-design-system": "^1.3.1",
|
|
46
|
+
"@marktiderman/genesis-ui": "^2.5.0",
|
|
47
|
+
"@tanstack/react-query": "^5.75.0",
|
|
48
|
+
"@testing-library/react": "^16.3.2",
|
|
49
|
+
"@types/react": "^19.2.14",
|
|
50
|
+
"@types/react-dom": "^19.2.3",
|
|
51
|
+
"happy-dom": "^20.9.0",
|
|
52
|
+
"lucide-react": "^1.0.0",
|
|
53
|
+
"react": "^19.0.0",
|
|
54
|
+
"react-dom": "^19.0.0",
|
|
55
|
+
"tsup": "^8.3.0",
|
|
56
|
+
"typescript": "^5.9.3",
|
|
57
|
+
"vitest": "^2.1.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsup && cp src/styles.css dist/styles.css && node ../../scripts/check-exports-resolve.mjs",
|
|
61
|
+
"clean": "rm -rf dist",
|
|
62
|
+
"typecheck": "tsc --noEmit",
|
|
63
|
+
"test": "vitest run"
|
|
64
|
+
}
|
|
65
|
+
}
|