@marktiderman/genesis-studio 0.0.0 → 0.2.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 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,135 @@
1
- Placeholder. Install a version >= 0.1.0.
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.3, Experimental.** P2 adds:
12
+ - the **sheet view**, with keyboard editing and copy and paste;
13
+ - multi-row pastes saved in one transaction;
14
+ - **undo** from the audit;
15
+ - **bulk** set and delete;
16
+ - **CSV import** with a dry run;
17
+ - code-configured **form sections and conditions** (`forms`).
18
+
19
+ From P1:
20
+ - the sheet grid, with in-place edits;
21
+ - a record pop-up with a schema-built form, related records, history and delete;
22
+ - adding rows, and relation pickers;
23
+ - a filter builder, multi-field sort and group-by;
24
+ - saved views and CSV export.
25
+
26
+ ## Embed it in any app
27
+
28
+ ```tsx
29
+ import { DataStudio } from "@marktiderman/genesis-studio";
30
+ import { createSupabaseStudioProvider } from "@marktiderman/genesis-studio-server/client";
31
+ import "@marktiderman/genesis-studio/standalone.css";
32
+ import { supabase } from "@/integrations/supabase/client";
33
+
34
+ const provider = createSupabaseStudioProvider(supabase, { url: "/api/studio" });
35
+
36
+ export default function DataPage() {
37
+ return <DataStudio provider={provider} />;
38
+ }
39
+ ```
40
+
41
+ `npx @marktiderman/genesis-cli studio init` writes this page and its server for you.
42
+
43
+ - **No router or session wiring.** The studio keeps its state in the page's query string. The
44
+ Supabase provider tells it when the signed-in user changes.
45
+ - **`standalone.css`** is prebuilt: every class the studio uses, for apps without Tailwind v4
46
+ (Tailwind 3, CSS modules, or none). It adds no global reset. A small reset applies only inside
47
+ the studio and its pop-ups. The app's own shadcn-style theme variables (`--background`,
48
+ `--primary`, …) win over Genesis's defaults, so the studio matches the app.
49
+ - An app already on Tailwind v4 imports `styles.css` instead (see below).
50
+
51
+ ## Use it
52
+
53
+ ```tsx
54
+ import { DataStudio, createSearchParamsRouter } from "@marktiderman/genesis-studio";
55
+ import { createStudioProvider } from "@marktiderman/genesis-studio-server/client";
56
+ import { useSearchParams } from "react-router";
57
+
58
+ export default function AdminData() {
59
+ const { session } = useSupabaseSession(); // your app's own sign-in
60
+ const [params, setParams] = useSearchParams();
61
+ const router = createSearchParamsRouter(params, (next, { replace }) => setParams(next, { replace }));
62
+ const provider = useMemo(
63
+ () => createStudioProvider({ url: "/api/studio", getAccessToken: () => session?.access_token ?? null }),
64
+ [session],
65
+ );
66
+ return <DataStudio provider={provider} router={router} sessionKey={session?.user.id} signIn={<SignInForm />} />;
67
+ }
68
+ ```
69
+
70
+ The server half — the handler at `/api/studio` — is
71
+ [`@marktiderman/genesis-studio-server`](../studio-server/README.md).
72
+
73
+ Tailwind v4 needs to see the studio's classes:
74
+
75
+ ```css
76
+ @import "tailwindcss";
77
+ @import "@marktiderman/genesis-ui/styles.css";
78
+ @import "@marktiderman/genesis-studio/styles.css";
79
+ ```
80
+
81
+ ## Props
82
+
83
+ | Prop | |
84
+ | --- | --- |
85
+ | `provider` | Any Genesis `DataProvider` with `describe` and `listResources` — usually `createStudioProvider`. |
86
+ | `router` | `{ location, navigate }`. `createSearchParamsRouter` adapts React Router or Next.js; `useHistoryRouter` works without a router; `useMemoryRouter` for tests. URL keys: `table`, `page`, `sort`, `q`, `filter`, `group`, `record`. |
87
+ | `signIn` | Rendered instead of the studio while the provider reports nobody signed in. |
88
+ | `sessionKey` | The signed-in user's id. When it changes, everything loaded for the previous user is dropped. |
89
+ | `forms` | Per `schema.table`: `{ sections: [{ title, fields }], visibleWhen: { field: (values) => boolean } }`. |
90
+ | `title`, `perPage`, `className` | Presentation. |
91
+
92
+ ## What it does
93
+
94
+ - **Open a record** with the ⤢ button on any row. The pop-up has three tabs:
95
+ - **Details:** a form built from the schema, with validation for required fields, numbers,
96
+ dates, enums and JSON, and pickers for links. Read-only fields show why they can't change.
97
+ - **Related:** records this one links to and from.
98
+ - **History:** the record's audit trail.
99
+
100
+ The URL names the record (`?record=…`), so **Copy link** shares it. History entries
101
+ have an **Undo** button.
102
+ - **New row** opens a form of only the fields the user may set.
103
+ - **Filter, Sort, Group** run on the server and live in the URL. **Views** saves them, with
104
+ search and visible columns, per table.
105
+ - **Export CSV** pages through every matching row, up to 10 000. Cells a spreadsheet would run
106
+ as formulas are neutralised.
107
+ - **Sheet view** (Table | Sheet, `?view=sheet`): arrows, Tab and Shift+arrows move and select;
108
+ Enter, F2 or typing edits; Delete clears; Cmd/Ctrl+C and Cmd/Ctrl+V copy and paste with Sheets
109
+ and Excel. A paste across rows is **one transaction**: every row changes at the version you
110
+ see, or none does, and the failing cells are marked. **Cmd/Ctrl+Z** undoes your latest
111
+ change from its audit entry.
112
+ - **Select rows** to set a field on all of them, or delete them: all or none.
113
+ - **Import CSV:** map the file's columns, **Check rows** (a dry run against the database that
114
+ lists every row that won't import, and why), then **Import**, in one transaction.
115
+ - **Board view** (`?view=kanban&by=status`): one column per value of an enum or yes/no field,
116
+ 50 cards per column with **Load more**, the same filters, search and sort as the table. Drag a
117
+ card, or use its **Move to** menu from the keyboard: a move is a version-checked update.
118
+ - **Calendar view** (`?view=calendar&by=starts_on&month=2026-10`): a month of rows by a date or
119
+ timestamp field, one request per month, up to 200 rows. Timestamps show on the viewer's day.
120
+ - **Row actions** the server offers are buttons in the record pop-up, asking first when the
121
+ action has a `confirm` question. A **Comments** tab lists and adds comments.
122
+ - **Link cells** show the linked row's label (one query per linked table per page). Links are
123
+ edited in the record form, with a picker that searches on the server.
124
+
125
+ ## What it shows
126
+
127
+ - **Read-only tables** carry a lock in the sidebar and a banner with the server's reason (no version
128
+ trigger, editing not enabled, RLS off…).
129
+ - **Columns** are exactly the fields `describe()` returned for this user; a column they can't read
130
+ never renders. Only fields with `permissions.update` are editable; enum and boolean fields edit
131
+ as a select.
132
+ - **A conflict** keeps the editor open with the message, and the row then shows the other person's
133
+ value, so the next edit starts from it.
134
+ - **This session's edits** lists every save and refusal, with the audit id, actor, time and the
135
+ before → after of each changed field.
@@ -0,0 +1,157 @@
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`; several, comma-separated, sort by each in turn. */
13
+ sort?: string;
14
+ search?: string;
15
+ /** Filters, as a JSON array of `[field, operator, value?]`. */
16
+ filters?: string;
17
+ /** Group rows by this field. */
18
+ group?: string;
19
+ /** The open record's id (`encodeRecordKey`), for a copyable link to it. */
20
+ record?: string;
21
+ /** `sheet`, `kanban` or `calendar`; the table otherwise. */
22
+ view?: StudioView;
23
+ /** For `kanban` and `calendar`: the field that places each row. */
24
+ by?: string;
25
+ /** For `calendar`: the month shown, `YYYY-MM`. */
26
+ month?: string;
27
+ }
28
+ /** The views other than the table. */
29
+ type StudioView = "sheet" | "kanban" | "calendar";
30
+ interface StudioNavigateOptions {
31
+ /** Replace the current history entry instead of pushing one (e.g. while typing a search). */
32
+ replace?: boolean;
33
+ }
34
+ interface StudioRouter {
35
+ location: StudioLocation;
36
+ navigate(next: StudioLocation, options?: StudioNavigateOptions): void;
37
+ }
38
+ declare function locationFromSearchParams(params: URLSearchParams): StudioLocation;
39
+ /**
40
+ * The studio's location as query parameters, keeping every parameter in
41
+ * `base` that isn't the studio's own.
42
+ */
43
+ declare function searchParamsFromLocation(location: StudioLocation, base?: URLSearchParams): URLSearchParams;
44
+ /**
45
+ * An adapter over any router that exposes search params with a setter —
46
+ * React Router's `useSearchParams`, or Next.js's `useSearchParams` plus
47
+ * `router.replace`/`push`.
48
+ *
49
+ * @example React Router
50
+ * const [params, setParams] = useSearchParams();
51
+ * const router = createSearchParamsRouter(params, (next, { replace }) => setParams(next, { replace }));
52
+ */
53
+ declare function createSearchParamsRouter(params: URLSearchParams, setParams: (next: URLSearchParams, options: {
54
+ replace: boolean;
55
+ }) => void): StudioRouter;
56
+ /**
57
+ * For an app with no router: keeps the studio's state in the current page's
58
+ * query string through the History API.
59
+ */
60
+ declare function useHistoryRouter(): StudioRouter;
61
+ /** A router held in memory, for tests and embedded previews. */
62
+ declare function useMemoryRouter(initial?: StudioLocation): StudioRouter;
63
+
64
+ /** How one resource's record form is laid out. */
65
+ interface StudioFormConfig {
66
+ /** Fields grouped under headings, in order. Fields in no section follow, under "Other". */
67
+ sections?: {
68
+ title: string;
69
+ fields: string[];
70
+ }[];
71
+ /**
72
+ * Show a field only when this returns true for the form's current values,
73
+ * e.g. `{ cancel_reason: (v) => v.status === "cancelled" }`. A hidden field
74
+ * is left out of the save.
75
+ */
76
+ visibleWhen?: Record<string, (values: Record<string, unknown>) => boolean>;
77
+ }
78
+
79
+ /** One entry in the session's activity panel. */
80
+ interface StudioActivity {
81
+ id: string;
82
+ at: Date;
83
+ resource: string;
84
+ field: string;
85
+ /** The record's key, as shown to the user. */
86
+ record: string;
87
+ outcome: "saved" | "not_saved";
88
+ /** What was attempted. Default `update`. */
89
+ action?: "update" | "create" | "delete";
90
+ message: string;
91
+ /** Present when the edit committed with an audit entry from the `table` sink. */
92
+ audit?: AuditEntry;
93
+ /**
94
+ * A bulk write's audit entries, one per row. A single entry holds them all,
95
+ * so its Undo reaches every row however many there were.
96
+ */
97
+ audits?: AuditEntry[];
98
+ /** True once this change has been undone. */
99
+ undone?: boolean;
100
+ }
101
+ interface DataStudioProps {
102
+ /**
103
+ * Where the data comes from: the studio handler's client
104
+ * (`createStudioProvider` from `@marktiderman/genesis-studio-server/client`)
105
+ * or any Genesis `DataProvider` that implements `describe` and
106
+ * `listResources`.
107
+ */
108
+ provider: DataProvider;
109
+ /**
110
+ * URL state, from the app's router (see `createSearchParamsRouter`).
111
+ * Default: the page's own query string, through the History API.
112
+ */
113
+ router?: StudioRouter;
114
+ /** Shown in place of the studio while nobody is signed in. */
115
+ signIn?: ReactNode;
116
+ /**
117
+ * Changes when the signed-in user does (their id, say). The studio drops
118
+ * everything it loaded for the previous user and asks again. Not needed
119
+ * with a provider that reports its session (`createSupabaseStudioProvider`).
120
+ */
121
+ sessionKey?: string | number | null;
122
+ /** Heading above the table list. Default "Data"; `null` for none (the app's own page header names it). */
123
+ title?: string | null;
124
+ /**
125
+ * `card` (default): the studio draws its own frame. `plain`: no frame, so
126
+ * it sits flush inside an app's layout, under its own header and nav,
127
+ * like any other page of the app.
128
+ */
129
+ variant?: "card" | "plain";
130
+ /** Rows per page. Default 50. */
131
+ perPage?: number;
132
+ /**
133
+ * Record-form layout per resource (`schema.table`): sections, and fields
134
+ * shown only when a condition holds. Code first; see `StudioFormConfig`.
135
+ */
136
+ forms?: Readonly<Record<string, StudioFormConfig>>;
137
+ className?: string;
138
+ }
139
+ /**
140
+ * The Data Studio: a sidebar of the tables this user may see, and a sheet of
141
+ * the selected one where a cell is edited in place. Every edit carries the
142
+ * version it was read at, so a stale edit is refused rather than saved over
143
+ * someone else's, and the outcome — saved with its audit entry, or not saved
144
+ * and why — is shown on screen.
145
+ *
146
+ * It renders inside its own `GenesisProvider`, so it works whether or not
147
+ * the app already has one.
148
+ *
149
+ * @stability Experimental
150
+ */
151
+ declare function DataStudio(props: DataStudioProps): react.JSX.Element;
152
+ /** A provider's report of who is signed in (see `createSupabaseStudioProvider`). */
153
+ interface StudioSessionSource {
154
+ subscribe(listener: (userId: string | null) => void): () => void;
155
+ }
156
+
157
+ export { DataStudio, type DataStudioProps, type StudioActivity, type StudioLocation, type StudioNavigateOptions, type StudioRouter, type StudioSessionSource, type StudioView, createSearchParamsRouter, locationFromSearchParams, searchParamsFromLocation, useHistoryRouter, useMemoryRouter };