@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 +21 -0
- package/README.md +135 -1
- package/dist/index.d.ts +157 -0
- package/dist/index.js +3227 -0
- package/dist/index.js.map +1 -0
- package/dist/standalone.css +2 -0
- package/dist/styles.css +12 -0
- package/package.json +76 -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,135 @@
|
|
|
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.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.
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|