@rsc-kit/press 0.0.0-stage → 0.30.43
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/README.md +117 -2
- package/dist/admin/actions.d.ts +30 -0
- package/dist/admin/actions.js +171 -0
- package/dist/admin/actions.js.map +1 -0
- package/dist/admin/index.d.ts +5 -0
- package/dist/admin/index.js +2 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/panel.d.ts +96 -0
- package/dist/admin/panel.js +354 -0
- package/dist/admin/panel.js.map +1 -0
- package/dist/admin/registry.d.ts +58 -0
- package/dist/admin/registry.js +90 -0
- package/dist/admin/registry.js.map +1 -0
- package/dist/admin/theme.d.ts +251 -0
- package/dist/admin/theme.js +2 -0
- package/dist/admin/theme.js.map +1 -0
- package/dist/admin/url.d.ts +24 -0
- package/dist/admin/url.js +45 -0
- package/dist/admin/url.js.map +1 -0
- package/dist/blocks.d.ts +46 -0
- package/dist/blocks.js +33 -0
- package/dist/blocks.js.map +1 -0
- package/dist/forms/actions.d.ts +7 -0
- package/dist/forms/actions.js +26 -0
- package/dist/forms/actions.js.map +1 -0
- package/dist/forms/form.d.ts +42 -0
- package/dist/forms/form.js +44 -0
- package/dist/forms/form.js.map +1 -0
- package/dist/forms/honeypot.d.ts +2 -0
- package/dist/forms/honeypot.js +3 -0
- package/dist/forms/honeypot.js.map +1 -0
- package/dist/forms/index.d.ts +3 -0
- package/dist/forms/index.js +2 -0
- package/dist/forms/index.js.map +1 -0
- package/dist/forms/registry.d.ts +10 -0
- package/dist/forms/registry.js +17 -0
- package/dist/forms/registry.js.map +1 -0
- package/dist/forms/theme.d.ts +29 -0
- package/dist/forms/theme.js +2 -0
- package/dist/forms/theme.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/installable.d.ts +173 -0
- package/dist/installable.js +490 -0
- package/dist/installable.js.map +1 -0
- package/dist/memory.d.ts +7 -0
- package/dist/memory.js +50 -0
- package/dist/memory.js.map +1 -0
- package/dist/pages/index.d.ts +2 -0
- package/dist/pages/index.js +2 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/pages.d.ts +78 -0
- package/dist/pages/pages.js +183 -0
- package/dist/pages/pages.js.map +1 -0
- package/dist/plugin.d.ts +51 -0
- package/dist/plugin.js +20 -0
- package/dist/plugin.js.map +1 -0
- package/dist/resource.d.ts +320 -0
- package/dist/resource.js +415 -0
- package/dist/resource.js.map +1 -0
- package/dist/shadcn/breadcrumbs.d.ts +6 -0
- package/dist/shadcn/breadcrumbs.js +21 -0
- package/dist/shadcn/breadcrumbs.js.map +1 -0
- package/dist/shadcn/components/ui/alert-dialog.d.ts +18 -0
- package/dist/shadcn/components/ui/alert-dialog.js +44 -0
- package/dist/shadcn/components/ui/alert-dialog.js.map +1 -0
- package/dist/shadcn/components/ui/avatar.d.ts +11 -0
- package/dist/shadcn/components/ui/avatar.js +25 -0
- package/dist/shadcn/components/ui/avatar.js.map +1 -0
- package/dist/shadcn/components/ui/badge.d.ts +7 -0
- package/dist/shadcn/components/ui/badge.js +34 -0
- package/dist/shadcn/components/ui/badge.js.map +1 -0
- package/dist/shadcn/components/ui/breadcrumb.d.ts +10 -0
- package/dist/shadcn/components/ui/breadcrumb.js +38 -0
- package/dist/shadcn/components/ui/breadcrumb.js.map +1 -0
- package/dist/shadcn/components/ui/button.d.ts +8 -0
- package/dist/shadcn/components/ui/button.js +35 -0
- package/dist/shadcn/components/ui/button.js.map +1 -0
- package/dist/shadcn/components/ui/card.d.ts +11 -0
- package/dist/shadcn/components/ui/card.js +26 -0
- package/dist/shadcn/components/ui/card.js.map +1 -0
- package/dist/shadcn/components/ui/checkbox.d.ts +3 -0
- package/dist/shadcn/components/ui/checkbox.js +10 -0
- package/dist/shadcn/components/ui/checkbox.js.map +1 -0
- package/dist/shadcn/components/ui/dropdown-menu.d.ts +29 -0
- package/dist/shadcn/components/ui/dropdown-menu.js +53 -0
- package/dist/shadcn/components/ui/dropdown-menu.js.map +1 -0
- package/dist/shadcn/components/ui/field.d.ts +24 -0
- package/dist/shadcn/components/ui/field.js +69 -0
- package/dist/shadcn/components/ui/field.js.map +1 -0
- package/dist/shadcn/components/ui/input.d.ts +3 -0
- package/dist/shadcn/components/ui/input.js +9 -0
- package/dist/shadcn/components/ui/input.js.map +1 -0
- package/dist/shadcn/components/ui/label.d.ts +3 -0
- package/dist/shadcn/components/ui/label.js +9 -0
- package/dist/shadcn/components/ui/label.js.map +1 -0
- package/dist/shadcn/components/ui/select.d.ts +15 -0
- package/dist/shadcn/components/ui/select.js +36 -0
- package/dist/shadcn/components/ui/select.js.map +1 -0
- package/dist/shadcn/components/ui/separator.d.ts +3 -0
- package/dist/shadcn/components/ui/separator.js +9 -0
- package/dist/shadcn/components/ui/separator.js.map +1 -0
- package/dist/shadcn/components/ui/sheet.d.ts +14 -0
- package/dist/shadcn/components/ui/sheet.js +39 -0
- package/dist/shadcn/components/ui/sheet.js.map +1 -0
- package/dist/shadcn/components/ui/sidebar.d.ts +63 -0
- package/dist/shadcn/components/ui/sidebar.js +257 -0
- package/dist/shadcn/components/ui/sidebar.js.map +1 -0
- package/dist/shadcn/components/ui/skeleton.d.ts +2 -0
- package/dist/shadcn/components/ui/skeleton.js +7 -0
- package/dist/shadcn/components/ui/skeleton.js.map +1 -0
- package/dist/shadcn/components/ui/sonner.d.ts +3 -0
- package/dist/shadcn/components/ui/sonner.js +38 -0
- package/dist/shadcn/components/ui/sonner.js.map +1 -0
- package/dist/shadcn/components/ui/switch.d.ts +5 -0
- package/dist/shadcn/components/ui/switch.js +9 -0
- package/dist/shadcn/components/ui/switch.js.map +1 -0
- package/dist/shadcn/components/ui/table.d.ts +10 -0
- package/dist/shadcn/components/ui/table.js +30 -0
- package/dist/shadcn/components/ui/table.js.map +1 -0
- package/dist/shadcn/components/ui/textarea.d.ts +3 -0
- package/dist/shadcn/components/ui/textarea.js +8 -0
- package/dist/shadcn/components/ui/textarea.js.map +1 -0
- package/dist/shadcn/components/ui/tooltip.d.ts +6 -0
- package/dist/shadcn/components/ui/tooltip.js +18 -0
- package/dist/shadcn/components/ui/tooltip.js.map +1 -0
- package/dist/shadcn/data-table.d.ts +12 -0
- package/dist/shadcn/data-table.js +195 -0
- package/dist/shadcn/data-table.js.map +1 -0
- package/dist/shadcn/fields.d.ts +20 -0
- package/dist/shadcn/fields.js +179 -0
- package/dist/shadcn/fields.js.map +1 -0
- package/dist/shadcn/flash-toasts.d.ts +2 -0
- package/dist/shadcn/flash-toasts.js +11 -0
- package/dist/shadcn/flash-toasts.js.map +1 -0
- package/dist/shadcn/hooks/use-mobile.d.ts +1 -0
- package/dist/shadcn/hooks/use-mobile.js +16 -0
- package/dist/shadcn/hooks/use-mobile.js.map +1 -0
- package/dist/shadcn/index.d.ts +10 -0
- package/dist/shadcn/index.js +31 -0
- package/dist/shadcn/index.js.map +1 -0
- package/dist/shadcn/mode-toggle.d.ts +7 -0
- package/dist/shadcn/mode-toggle.js +52 -0
- package/dist/shadcn/mode-toggle.js.map +1 -0
- package/dist/shadcn/nav.d.ts +5 -0
- package/dist/shadcn/nav.js +19 -0
- package/dist/shadcn/nav.js.map +1 -0
- package/dist/shadcn/pages.d.ts +15 -0
- package/dist/shadcn/pages.js +59 -0
- package/dist/shadcn/pages.js.map +1 -0
- package/dist/shadcn/plugin-forms.d.ts +16 -0
- package/dist/shadcn/plugin-forms.js +25 -0
- package/dist/shadcn/plugin-forms.js.map +1 -0
- package/dist/shadcn/public-form.d.ts +8 -0
- package/dist/shadcn/public-form.js +26 -0
- package/dist/shadcn/public-form.js.map +1 -0
- package/dist/shadcn/resource-form.d.ts +19 -0
- package/dist/shadcn/resource-form.js +30 -0
- package/dist/shadcn/resource-form.js.map +1 -0
- package/dist/shadcn/styles.css +12 -0
- package/dist/shadcn/tenant-switcher.d.ts +3 -0
- package/dist/shadcn/tenant-switcher.js +17 -0
- package/dist/shadcn/tenant-switcher.js.map +1 -0
- package/dist/shadcn/theme-script.d.ts +7 -0
- package/dist/shadcn/theme-script.js +10 -0
- package/dist/shadcn/theme-script.js.map +1 -0
- package/dist/shadcn/user-menu.d.ts +3 -0
- package/dist/shadcn/user-menu.js +23 -0
- package/dist/shadcn/user-menu.js.map +1 -0
- package/dist/signing.d.ts +43 -0
- package/dist/signing.js +82 -0
- package/dist/signing.js.map +1 -0
- package/dist/tenant.d.ts +12 -0
- package/dist/tenant.js +29 -0
- package/dist/tenant.js.map +1 -0
- package/dist/uploads.d.ts +32 -0
- package/dist/uploads.js +28 -0
- package/dist/uploads.js.map +1 -0
- package/package.json +74 -4
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rsc-kit/press
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Admin panels, public forms and pages built from blocks for [rsc-kit](https://rsc-kit.dev). You describe each resource once: its table, its form and where its rows come from. The package renders the list, create and edit pages for every resource from that, and a theme draws them. shadcn/ui is the theme that ships.
|
|
4
|
+
|
|
5
|
+
| Import | What it has |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `@rsc-kit/press` | `defineResource`, `memorySource`, `perTenant`, `definePlugin`, `defineBlock`, `Blocks`, and the `Storage` an upload field writes through. Nothing here draws. |
|
|
8
|
+
| `@rsc-kit/press/admin` | `definePanel`, the url schemas its routes export, and the `AdminTheme` contract |
|
|
9
|
+
| `@rsc-kit/press/forms` | `defineForm`: a contact form, a sign-up or a survey from a Standard Schema, and the `FormsTheme` contract |
|
|
10
|
+
| `@rsc-kit/press/pages` | `definePages`: landing pages an editor builds in the panel from the app's blocks, served by one catch-all route |
|
|
11
|
+
| `@rsc-kit/press/shadcn` | `shadcn`, the theme that draws them |
|
|
12
|
+
| `@rsc-kit/press/signing` | `verifyPluginRequest`, for an installable plugin's server |
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// src/admin/resources/products.ts
|
|
16
|
+
import * as z from 'zod'
|
|
17
|
+
import { defineResource, memorySource } from '@rsc-kit/press'
|
|
18
|
+
|
|
19
|
+
const form = z.object({
|
|
20
|
+
name: z.string().min(2),
|
|
21
|
+
price: z.number().positive(),
|
|
22
|
+
status: z.enum(['draft', 'active', 'archived']),
|
|
23
|
+
featured: z.boolean().default(false),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export const products = defineResource<Product>()({
|
|
27
|
+
name: 'products',
|
|
28
|
+
key: 'id',
|
|
29
|
+
title: 'name',
|
|
30
|
+
source: memorySource<Product, z.infer<typeof form>>(seed, 'id', (id, values) => ({ id, ...values })),
|
|
31
|
+
table: (col) => [col.text('name').searchable().sortable(), col.money('price').sortable(), col.badge('status')],
|
|
32
|
+
filters: ['status'],
|
|
33
|
+
form,
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// src/admin/panel.ts
|
|
39
|
+
import { definePanel } from '@rsc-kit/press/admin'
|
|
40
|
+
import { shadcn } from '@rsc-kit/press/shadcn'
|
|
41
|
+
import { products } from './resources/products'
|
|
42
|
+
|
|
43
|
+
export default definePanel({
|
|
44
|
+
path: '/admin',
|
|
45
|
+
theme: shadcn,
|
|
46
|
+
resources: [products],
|
|
47
|
+
authorize: async () => (await currentUser())?.isAdmin === true,
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The form is any Standard Schema - zod, valibot, arktype, Effect Schema - so the app uses the validator it already has. That one schema validates every save on the server, and decides which fields the form shows when it describes itself as JSON Schema (zod 4.2+ does); with any other, list the fields in `fields`. A refused save shows each error under its field and keeps what was typed.
|
|
52
|
+
|
|
53
|
+
## Mounting it
|
|
54
|
+
|
|
55
|
+
The engine finds routes, url schemas and sections by their files, so the panel is mounted with seven small route files:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// src/app/admin/middleware.ts
|
|
59
|
+
export default panel.middleware
|
|
60
|
+
// src/app/admin/layout.tsx
|
|
61
|
+
export default panel.Layout
|
|
62
|
+
// src/app/admin/page.tsx
|
|
63
|
+
export default panel.Dashboard
|
|
64
|
+
|
|
65
|
+
// src/app/admin/[resource]/page.tsx
|
|
66
|
+
export const params = resourceParams
|
|
67
|
+
export const searchParams = listSearch
|
|
68
|
+
export default panel.Index
|
|
69
|
+
// src/app/admin/[resource]/admin-table.section.tsx
|
|
70
|
+
export default panel.Table
|
|
71
|
+
|
|
72
|
+
// src/app/admin/[resource]/new/page.tsx
|
|
73
|
+
export const params = resourceParams
|
|
74
|
+
export default panel.Create
|
|
75
|
+
// src/app/admin/[resource]/[id]/page.tsx
|
|
76
|
+
export const params = recordParams
|
|
77
|
+
export default panel.Edit
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Each file also imports `panel`, and imports `resourceParams`, `recordParams` and `listSearch` from `@rsc-kit/press/admin`.
|
|
81
|
+
|
|
82
|
+
## What you get
|
|
83
|
+
|
|
84
|
+
- **Search and filters** are a GET form on the list page. The table stays on screen while a query runs.
|
|
85
|
+
- **Sorting and paging** are held in the url, so a view can be bookmarked or shared.
|
|
86
|
+
- **Delete** refreshes only the table (`admin-table`). The search box keeps what was typed in it.
|
|
87
|
+
- **`authorize`** runs in the middleware, which answers a refusal with a real 404 before anything is sent. It runs again on every page and on every save and delete, because an action can be called without the page that rendered it.
|
|
88
|
+
- **Every panel page is rendered per request.** The build stores each page's skeleton and never runs `authorize` or a data source.
|
|
89
|
+
|
|
90
|
+
## Themes
|
|
91
|
+
|
|
92
|
+
A theme is an object of components: `Shell`, `Brand`, `TenantSwitcher`, `Nav`, `Breadcrumbs`, `UserMenu`, `Dashboard`, `Index`, `Table`, `Form`, `Plugins`, `Cell` and `Loading`. The panel decides what each page shows and who may see it, and hands the theme data, server actions already bound, and elements already drawn - never a component, so any of them may be a client component. To replace one page, spread the shipped theme: `theme: { ...shadcn, Dashboard: MyDashboard }`.
|
|
93
|
+
|
|
94
|
+
## Data sources
|
|
95
|
+
|
|
96
|
+
`memorySource` keeps rows in memory, for prototypes and tests. A database adapter is the same five functions:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
interface DataSource<Row, Values> {
|
|
100
|
+
list(query: ListQuery): Promise<{ rows: Row[]; total: number }>
|
|
101
|
+
find(id: string): Promise<Row | null>
|
|
102
|
+
create(values: Values): Promise<Row>
|
|
103
|
+
update(id: string, values: Values): Promise<Row>
|
|
104
|
+
delete(id: string): Promise<void>
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Server or client tables
|
|
109
|
+
|
|
110
|
+
`mode: 'server'` (the default) asks `list` for one page per search, filter, sort and page, and keeps the state in the url. `mode: 'client'` asks once with `perPage: null`, gets every row, and searches, sorts and pages in the browser - for small data.
|
|
111
|
+
|
|
112
|
+
## Auth, tenants and plugins
|
|
113
|
+
|
|
114
|
+
- `authorize` asks the app's own auth, on every page and every save, delete and switch. A refusal is a 404. `user` and `signOut` fill the sidebar's footer.
|
|
115
|
+
- `tenancy: { tenants, current, switch }` adds a team switcher, and every data source call is told `{ tenant }`. A tenant that is not the visitor's is refused.
|
|
116
|
+
- `plugins: [auditLog()]` adds resources, dashboard widgets and a reaction to every change. Writing, publishing and selling one: [Admin plugins](https://rsc-kit.dev/guides/admin-plugins/).
|
|
117
|
+
|
|
118
|
+
The shadcn theme needs Tailwind v4 and shadcn, and `@import "@rsc-kit/press/shadcn.css";` in the app's stylesheet. The full guide is [Admin panels](https://rsc-kit.dev/guides/admin/), and `examples/admin` is a panel over a per-team catalogue, a client-mode table and a REST API.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One save for every resource, bound by the page:
|
|
3
|
+
* saveRecord.bind(null, '/admin', 'products', id). The resource's own schema
|
|
4
|
+
* is the check, and the panel's authorize runs first, on every call - an
|
|
5
|
+
* action is reachable without the page that rendered it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function saveRecord(path: string, name: string, id: string | null, form: FormData): Promise<never>;
|
|
8
|
+
/** Deleting from the table refreshes the table, and nothing else on the page. */
|
|
9
|
+
export declare function deleteRecord(path: string, name: string, id: string): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* The rows ticked in the table, deleted or handed to one of the resource's
|
|
12
|
+
* bulk actions. Each id is found as the visitor first, so one from another
|
|
13
|
+
* tenant, or already gone, is left out rather than acted on.
|
|
14
|
+
*/
|
|
15
|
+
export declare function bulkRecords(path: string, name: string, action: string, ids: string[]): Promise<void>;
|
|
16
|
+
/** The switcher's choice, checked against the visitor's own tenants, then the dashboard. */
|
|
17
|
+
export declare function switchTenant(path: string, id: string): Promise<void>;
|
|
18
|
+
export declare function signOut(path: string): Promise<void>;
|
|
19
|
+
/** A manifest url, fetched and checked, then shown for review. Nothing is installed yet. */
|
|
20
|
+
export declare function reviewPlugin(path: string, form: FormData): Promise<never>;
|
|
21
|
+
/**
|
|
22
|
+
* Install what was reviewed. The manifest is fetched again and must match the
|
|
23
|
+
* digest the review page showed: a plugin that changed its scopes between the
|
|
24
|
+
* review and the click is refused, not installed with scopes nobody read.
|
|
25
|
+
*/
|
|
26
|
+
export declare function installPlugin(path: string, url: string, digest: string): Promise<void>;
|
|
27
|
+
/** Uninstalled: its secret goes with it, so every call it could have answered stops at once. */
|
|
28
|
+
export declare function uninstallPlugin(path: string, id: string): Promise<void>;
|
|
29
|
+
/** The secret was copied into the plugin; the plugins page stops showing it. */
|
|
30
|
+
export declare function secretCopied(path: string, id: string): Promise<void>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
import { fieldErrors } from '@rsc-kit/core/action';
|
|
3
|
+
import { flash } from '@rsc-kit/core/flash';
|
|
4
|
+
import { redirect } from '@rsc-kit/core/redirect';
|
|
5
|
+
import { revalidate } from '@rsc-kit/core/revalidate';
|
|
6
|
+
import { digestOf, fetchManifest, newInstallation } from '../installable.js';
|
|
7
|
+
import { allowOf, allowedResource, contextOf, panelAt, resourcesFor, tellAll } from './registry.js';
|
|
8
|
+
import { bulkToast, parse } from '../resource.js';
|
|
9
|
+
/**
|
|
10
|
+
* One save for every resource, bound by the page:
|
|
11
|
+
* saveRecord.bind(null, '/admin', 'products', id). The resource's own schema
|
|
12
|
+
* is the check, and the panel's authorize runs first, on every call - an
|
|
13
|
+
* action is reachable without the page that rendered it.
|
|
14
|
+
*/
|
|
15
|
+
export async function saveRecord(path, name, id, form) {
|
|
16
|
+
const { resource, context } = await allowedResource(path, name);
|
|
17
|
+
const current = id === null ? null : await resource.source.find(id, context);
|
|
18
|
+
if (id !== null && !current)
|
|
19
|
+
throw new Error(`[admin] no ${name} ${id} here`);
|
|
20
|
+
// What the record had is what a file field may keep without uploading again.
|
|
21
|
+
const parsed = await parse(resource, form, { current: current, tenant: context.tenant });
|
|
22
|
+
if (!parsed.ok)
|
|
23
|
+
return fieldErrors(parsed.errors);
|
|
24
|
+
// A relation's value is an id the browser sent, so it is looked up as the
|
|
25
|
+
// visitor: one from another tenant, or none at all, is not one of theirs.
|
|
26
|
+
const values = parsed.values;
|
|
27
|
+
const unrelated = {};
|
|
28
|
+
for (const field of resource.fields) {
|
|
29
|
+
const value = values[field.name];
|
|
30
|
+
if (!field.relation || value === undefined || value === null || value === '')
|
|
31
|
+
continue;
|
|
32
|
+
const related = (await resourcesFor(panelAt(path), context)).find((candidate) => candidate.name === field.relation);
|
|
33
|
+
if (!(await related.source.find(String(value), context)))
|
|
34
|
+
unrelated[field.name] = `Choose one of the listed ${related.label.toLowerCase()}.`;
|
|
35
|
+
}
|
|
36
|
+
if (Object.keys(unrelated).length > 0)
|
|
37
|
+
return fieldErrors(unrelated);
|
|
38
|
+
const row = id === null ? await resource.source.create(parsed.values, context) : await resource.source.update(id, parsed.values, context);
|
|
39
|
+
await tellAll(panelAt(path), { panel: path, resource: name, id: String(row[resource.key]), change: id === null ? 'created' : 'updated', values: parsed.values, tenant: context.tenant }, context);
|
|
40
|
+
// The form is replaced by the list the redirect lands on, so the word
|
|
41
|
+
// that it worked rides along to that page.
|
|
42
|
+
await flash(`${resource.singular} ${id === null ? 'created' : 'saved'}.`);
|
|
43
|
+
redirect(`${path}/${name}`);
|
|
44
|
+
}
|
|
45
|
+
/** Deleting from the table refreshes the table, and nothing else on the page. */
|
|
46
|
+
export async function deleteRecord(path, name, id) {
|
|
47
|
+
const { resource, context } = await allowedResource(path, name);
|
|
48
|
+
if (!(await resource.source.find(id, context)))
|
|
49
|
+
return;
|
|
50
|
+
await resource.source.delete(id, context);
|
|
51
|
+
await tellAll(panelAt(path), { panel: path, resource: name, id, change: 'deleted', tenant: context.tenant }, context);
|
|
52
|
+
await flash(`${resource.singular} deleted.`);
|
|
53
|
+
revalidate('admin-table');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The rows ticked in the table, deleted or handed to one of the resource's
|
|
57
|
+
* bulk actions. Each id is found as the visitor first, so one from another
|
|
58
|
+
* tenant, or already gone, is left out rather than acted on.
|
|
59
|
+
*/
|
|
60
|
+
export async function bulkRecords(path, name, action, ids) {
|
|
61
|
+
const { resource, context } = await allowedResource(path, name);
|
|
62
|
+
const bulk = action === 'delete' ? null : resource.bulkActions.find((candidate) => candidate.name === action);
|
|
63
|
+
if (bulk === undefined)
|
|
64
|
+
throw new Error(`[admin] ${name} has no bulk action ${action}`);
|
|
65
|
+
const found = await Promise.all([...new Set(ids.map(String))].map(async (id) => [id, await resource.source.find(id, context)]));
|
|
66
|
+
const rows = found.filter(([, row]) => row !== null && row !== undefined);
|
|
67
|
+
const noun = (count) => (count === 1 ? resource.singular : resource.label).toLowerCase();
|
|
68
|
+
if (bulk === null) {
|
|
69
|
+
for (const [id] of rows) {
|
|
70
|
+
await resource.source.delete(id, context);
|
|
71
|
+
await tellAll(panelAt(path), { panel: path, resource: name, id, change: 'deleted', tenant: context.tenant }, context);
|
|
72
|
+
}
|
|
73
|
+
await flash(bulkToast({ label: 'Delete', done: 'deleted' }, rows.length, noun(rows.length)));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const said = await bulk.run(rows.map(([, row]) => row), context);
|
|
77
|
+
await flash(typeof said === 'string' ? said : bulkToast(bulk, rows.length, noun(rows.length)));
|
|
78
|
+
}
|
|
79
|
+
revalidate('admin-table');
|
|
80
|
+
}
|
|
81
|
+
/** The switcher's choice, checked against the visitor's own tenants, then the dashboard. */
|
|
82
|
+
export async function switchTenant(path, id) {
|
|
83
|
+
const panel = panelAt(path);
|
|
84
|
+
if (!(await panel.authorize()) || !panel.tenancy)
|
|
85
|
+
throw new Error('[admin] not allowed');
|
|
86
|
+
const theirs = await panel.tenancy.tenants();
|
|
87
|
+
if (!theirs.some((tenant) => tenant.id === id))
|
|
88
|
+
throw new Error('[admin] not one of this visitor\'s tenants');
|
|
89
|
+
await panel.tenancy.switch(id);
|
|
90
|
+
// The team's name and nav are in the layout, which a redirect keeps as it
|
|
91
|
+
// was unless the action says what it changed.
|
|
92
|
+
revalidate('all');
|
|
93
|
+
redirect(path);
|
|
94
|
+
}
|
|
95
|
+
export async function signOut(path) {
|
|
96
|
+
const panel = panelAt(path);
|
|
97
|
+
await panel.signOut?.();
|
|
98
|
+
redirect('/');
|
|
99
|
+
}
|
|
100
|
+
/** The panel, once this visitor may install and uninstall there. */
|
|
101
|
+
async function managed(path) {
|
|
102
|
+
const panel = panelAt(path);
|
|
103
|
+
if (!panel.installs || !(await panel.authorize()))
|
|
104
|
+
throw new Error('[admin] not allowed');
|
|
105
|
+
if (panel.installs.manage && !(await panel.installs.manage()))
|
|
106
|
+
throw new Error('[admin] not allowed to manage plugins');
|
|
107
|
+
const context = await contextOf(panel);
|
|
108
|
+
if (!context)
|
|
109
|
+
throw new Error('[admin] no tenant chosen, or not one of this visitor\'s');
|
|
110
|
+
return { panel, installs: panel.installs, context };
|
|
111
|
+
}
|
|
112
|
+
/** A manifest url, fetched and checked, then shown for review. Nothing is installed yet. */
|
|
113
|
+
export async function reviewPlugin(path, form) {
|
|
114
|
+
const { panel } = await managed(path);
|
|
115
|
+
const url = String(form.get('manifest') ?? '').trim();
|
|
116
|
+
try {
|
|
117
|
+
await fetchManifest(url, allowOf(panel), panel.resources.map((resource) => resource.name));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return fieldErrors({ manifest: error instanceof Error ? error.message : 'The manifest could not be read.' });
|
|
121
|
+
}
|
|
122
|
+
redirect(`${path}/plugins?review=${encodeURIComponent(url)}`);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Install what was reviewed. The manifest is fetched again and must match the
|
|
126
|
+
* digest the review page showed: a plugin that changed its scopes between the
|
|
127
|
+
* review and the click is refused, not installed with scopes nobody read.
|
|
128
|
+
*/
|
|
129
|
+
export async function installPlugin(path, url, digest) {
|
|
130
|
+
const { panel, installs, context } = await managed(path);
|
|
131
|
+
const host = panel.resources.map((resource) => resource.name);
|
|
132
|
+
const manifest = await fetchManifest(url, allowOf(panel), host);
|
|
133
|
+
if ((await digestOf(manifest)) !== digest) {
|
|
134
|
+
await flash('The plugin changed since you reviewed it. Review it again.', 'error');
|
|
135
|
+
redirect(`${path}/plugins?review=${encodeURIComponent(url)}`);
|
|
136
|
+
}
|
|
137
|
+
const installed = await installs.store.list(context.tenant);
|
|
138
|
+
const taken = new Set([...host, ...installed.flatMap((installation) => installation.manifest.resources.map((resource) => resource.name))]);
|
|
139
|
+
if (installed.some((installation) => installation.manifest.name === manifest.name)) {
|
|
140
|
+
await flash(`${manifest.label} is already installed.`, 'error');
|
|
141
|
+
redirect(`${path}/plugins`);
|
|
142
|
+
}
|
|
143
|
+
const clash = manifest.resources.find((resource) => taken.has(resource.name));
|
|
144
|
+
if (clash) {
|
|
145
|
+
await flash(`Another plugin already adds ${clash.name}.`, 'error');
|
|
146
|
+
redirect(`${path}/plugins`);
|
|
147
|
+
}
|
|
148
|
+
await installs.store.add(context.tenant, newInstallation(url, manifest));
|
|
149
|
+
await flash(`${manifest.label} installed. Copy its secret into the plugin.`);
|
|
150
|
+
// Re-rendered in place, not redirected: its resources join the sidebar,
|
|
151
|
+
// which is in a layout a redirect keeps mounted from before the install.
|
|
152
|
+
// The review it came from closes itself once the manifest is installed.
|
|
153
|
+
revalidate('all');
|
|
154
|
+
}
|
|
155
|
+
/** Uninstalled: its secret goes with it, so every call it could have answered stops at once. */
|
|
156
|
+
export async function uninstallPlugin(path, id) {
|
|
157
|
+
const { installs, context } = await managed(path);
|
|
158
|
+
const installation = (await installs.store.list(context.tenant)).find((candidate) => candidate.id === id);
|
|
159
|
+
if (!installation)
|
|
160
|
+
return;
|
|
161
|
+
await installs.store.remove(context.tenant, id);
|
|
162
|
+
await flash(`${installation.manifest.label} uninstalled.`);
|
|
163
|
+
revalidate('all');
|
|
164
|
+
}
|
|
165
|
+
/** The secret was copied into the plugin; the plugins page stops showing it. */
|
|
166
|
+
export async function secretCopied(path, id) {
|
|
167
|
+
const { installs, context } = await managed(path);
|
|
168
|
+
await installs.store.seen(context.tenant, id);
|
|
169
|
+
redirect(`${path}/plugins`);
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=actions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"actions.js","sourceRoot":"","sources":["../../src/admin/actions.ts"],"names":[],"mappings":"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","sourcesContent":["'use server'\n\nimport { fieldErrors } from '@rsc-kit/core/action'\nimport { flash } from '@rsc-kit/core/flash'\nimport { redirect } from '@rsc-kit/core/redirect'\nimport { revalidate } from '@rsc-kit/core/revalidate'\nimport { digestOf, fetchManifest, newInstallation } from '../installable.js'\nimport { allowOf, allowedResource, contextOf, panelAt, resourcesFor, tellAll } from './registry.js'\nimport { bulkToast, parse } from '../resource.js'\n\n/**\n * One save for every resource, bound by the page:\n * saveRecord.bind(null, '/admin', 'products', id). The resource's own schema\n * is the check, and the panel's authorize runs first, on every call - an\n * action is reachable without the page that rendered it.\n */\nexport async function saveRecord(path: string, name: string, id: string | null, form: FormData) {\n const { resource, context } = await allowedResource(path, name)\n const current = id === null ? null : await resource.source.find(id, context)\n\n if (id !== null && !current) throw new Error(`[admin] no ${name} ${id} here`)\n\n // What the record had is what a file field may keep without uploading again.\n const parsed = await parse(resource, form, { current: current as Record<string, unknown> | null, tenant: context.tenant })\n\n if (!parsed.ok) return fieldErrors(parsed.errors)\n\n // A relation's value is an id the browser sent, so it is looked up as the\n // visitor: one from another tenant, or none at all, is not one of theirs.\n const values = parsed.values as Record<string, unknown>\n const unrelated: Record<string, string> = {}\n\n for (const field of resource.fields) {\n const value = values[field.name]\n\n if (!field.relation || value === undefined || value === null || value === '') continue\n\n const related = (await resourcesFor(panelAt(path), context)).find((candidate) => candidate.name === field.relation)!\n\n if (!(await related.source.find(String(value), context))) unrelated[field.name] = `Choose one of the listed ${related.label.toLowerCase()}.`\n }\n\n if (Object.keys(unrelated).length > 0) return fieldErrors(unrelated)\n\n const row = id === null ? await resource.source.create(parsed.values, context) : await resource.source.update(id, parsed.values, context)\n\n await tellAll(\n panelAt(path),\n { panel: path, resource: name, id: String(row[resource.key]), change: id === null ? 'created' : 'updated', values: parsed.values, tenant: context.tenant },\n context,\n )\n\n // The form is replaced by the list the redirect lands on, so the word\n // that it worked rides along to that page.\n await flash(`${resource.singular} ${id === null ? 'created' : 'saved'}.`)\n\n redirect(`${path}/${name}` as never)\n}\n\n/** Deleting from the table refreshes the table, and nothing else on the page. */\nexport async function deleteRecord(path: string, name: string, id: string) {\n const { resource, context } = await allowedResource(path, name)\n\n if (!(await resource.source.find(id, context))) return\n\n await resource.source.delete(id, context)\n await tellAll(panelAt(path), { panel: path, resource: name, id, change: 'deleted', tenant: context.tenant }, context)\n await flash(`${resource.singular} deleted.`)\n revalidate('admin-table' as never)\n}\n\n/**\n * The rows ticked in the table, deleted or handed to one of the resource's\n * bulk actions. Each id is found as the visitor first, so one from another\n * tenant, or already gone, is left out rather than acted on.\n */\nexport async function bulkRecords(path: string, name: string, action: string, ids: string[]) {\n const { resource, context } = await allowedResource(path, name)\n const bulk = action === 'delete' ? null : resource.bulkActions.find((candidate) => candidate.name === action)\n\n if (bulk === undefined) throw new Error(`[admin] ${name} has no bulk action ${action}`)\n\n const found = await Promise.all([...new Set(ids.map(String))].map(async (id) => [id, await resource.source.find(id, context)] as const))\n const rows = found.filter(([, row]) => row !== null && row !== undefined)\n const noun = (count: number) => (count === 1 ? resource.singular : resource.label).toLowerCase()\n\n if (bulk === null) {\n for (const [id] of rows) {\n await resource.source.delete(id, context)\n await tellAll(panelAt(path), { panel: path, resource: name, id, change: 'deleted', tenant: context.tenant }, context)\n }\n\n await flash(bulkToast({ label: 'Delete', done: 'deleted' }, rows.length, noun(rows.length)))\n } else {\n const said = await bulk.run(rows.map(([, row]) => row), context)\n\n await flash(typeof said === 'string' ? said : bulkToast(bulk, rows.length, noun(rows.length)))\n }\n\n revalidate('admin-table' as never)\n}\n\n/** The switcher's choice, checked against the visitor's own tenants, then the dashboard. */\nexport async function switchTenant(path: string, id: string) {\n const panel = panelAt(path)\n\n if (!(await panel.authorize()) || !panel.tenancy) throw new Error('[admin] not allowed')\n\n const theirs = await panel.tenancy.tenants()\n\n if (!theirs.some((tenant) => tenant.id === id)) throw new Error('[admin] not one of this visitor\\'s tenants')\n\n await panel.tenancy.switch(id)\n // The team's name and nav are in the layout, which a redirect keeps as it\n // was unless the action says what it changed.\n revalidate('all' as never)\n redirect(path as never)\n}\n\nexport async function signOut(path: string) {\n const panel = panelAt(path)\n\n await panel.signOut?.()\n redirect('/' as never)\n}\n\n/** The panel, once this visitor may install and uninstall there. */\nasync function managed(path: string) {\n const panel = panelAt(path)\n\n if (!panel.installs || !(await panel.authorize())) throw new Error('[admin] not allowed')\n if (panel.installs.manage && !(await panel.installs.manage())) throw new Error('[admin] not allowed to manage plugins')\n\n const context = await contextOf(panel)\n\n if (!context) throw new Error('[admin] no tenant chosen, or not one of this visitor\\'s')\n\n return { panel, installs: panel.installs, context }\n}\n\n/** A manifest url, fetched and checked, then shown for review. Nothing is installed yet. */\nexport async function reviewPlugin(path: string, form: FormData) {\n const { panel } = await managed(path)\n const url = String(form.get('manifest') ?? '').trim()\n\n try {\n await fetchManifest(url, allowOf(panel), panel.resources.map((resource) => resource.name))\n } catch (error) {\n return fieldErrors({ manifest: error instanceof Error ? error.message : 'The manifest could not be read.' })\n }\n\n redirect(`${path}/plugins?review=${encodeURIComponent(url)}` as never)\n}\n\n/**\n * Install what was reviewed. The manifest is fetched again and must match the\n * digest the review page showed: a plugin that changed its scopes between the\n * review and the click is refused, not installed with scopes nobody read.\n */\nexport async function installPlugin(path: string, url: string, digest: string) {\n const { panel, installs, context } = await managed(path)\n const host = panel.resources.map((resource) => resource.name)\n const manifest = await fetchManifest(url, allowOf(panel), host)\n\n if ((await digestOf(manifest)) !== digest) {\n await flash('The plugin changed since you reviewed it. Review it again.', 'error')\n redirect(`${path}/plugins?review=${encodeURIComponent(url)}` as never)\n }\n\n const installed = await installs.store.list(context.tenant)\n const taken = new Set([...host, ...installed.flatMap((installation) => installation.manifest.resources.map((resource) => resource.name))])\n\n if (installed.some((installation) => installation.manifest.name === manifest.name)) {\n await flash(`${manifest.label} is already installed.`, 'error')\n redirect(`${path}/plugins` as never)\n }\n\n const clash = manifest.resources.find((resource) => taken.has(resource.name))\n\n if (clash) {\n await flash(`Another plugin already adds ${clash.name}.`, 'error')\n redirect(`${path}/plugins` as never)\n }\n\n await installs.store.add(context.tenant, newInstallation(url, manifest))\n await flash(`${manifest.label} installed. Copy its secret into the plugin.`)\n // Re-rendered in place, not redirected: its resources join the sidebar,\n // which is in a layout a redirect keeps mounted from before the install.\n // The review it came from closes itself once the manifest is installed.\n revalidate('all' as never)\n}\n\n/** Uninstalled: its secret goes with it, so every call it could have answered stops at once. */\nexport async function uninstallPlugin(path: string, id: string) {\n const { installs, context } = await managed(path)\n const installation = (await installs.store.list(context.tenant)).find((candidate) => candidate.id === id)\n\n if (!installation) return\n\n await installs.store.remove(context.tenant, id)\n await flash(`${installation.manifest.label} uninstalled.`)\n revalidate('all' as never)\n}\n\n/** The secret was copied into the plugin; the plugins page stops showing it. */\nexport async function secretCopied(path: string, id: string) {\n const { installs, context } = await managed(path)\n\n await installs.store.seen(context.tenant, id)\n redirect(`${path}/plugins` as never)\n}\n"]}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { definePanel, listSearch, pluginsSearch, recordParams, resourceParams } from './panel.js';
|
|
2
|
+
export type { Panel, PanelDefinition } from './panel.js';
|
|
3
|
+
export type { ListSearch, PluginsSearch } from './url.js';
|
|
4
|
+
export type { Tenancy } from './registry.js';
|
|
5
|
+
export type { AdminTheme, BrandProps, BreadcrumbsProps, DashboardProps, FormProps, IndexProps, InstalledPlugin, NavItem, PanelUser, PluginReview, PluginsProps, ShellProps, TableBulkAction, TableColumn, TableProps, TableQuery, TableRowData, TenantSwitcherProps, UserMenuProps, } from './theme.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/admin/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA","sourcesContent":["export { definePanel, listSearch, pluginsSearch, recordParams, resourceParams } from './panel.js'\nexport type { Panel, PanelDefinition } from './panel.js'\nexport type { ListSearch, PluginsSearch } from './url.js'\nexport type { Tenancy } from './registry.js'\nexport type {\n AdminTheme,\n BrandProps,\n BreadcrumbsProps,\n DashboardProps,\n FormProps,\n IndexProps,\n InstalledPlugin,\n NavItem,\n PanelUser,\n PluginReview,\n PluginsProps,\n ShellProps,\n TableBulkAction,\n TableColumn,\n TableProps,\n TableQuery,\n TableRowData,\n TenantSwitcherProps,\n UserMenuProps,\n} from './theme.js'\n"]}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { Installs } from '../installable.js';
|
|
3
|
+
import type { PanelPlugin } from '../plugin.js';
|
|
4
|
+
import type { Tenancy } from './registry.js';
|
|
5
|
+
import type { Resource } from '../resource.js';
|
|
6
|
+
import type { AdminTheme, PanelUser } from './theme.js';
|
|
7
|
+
import type { ListSearch, PluginsSearch } from './url.js';
|
|
8
|
+
export { listSearch, pluginsSearch, recordParams, resourceParams } from './url.js';
|
|
9
|
+
export interface PanelDefinition {
|
|
10
|
+
/** Where the panel's routes are: the folder under src/app it is mounted in. */
|
|
11
|
+
path?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
/** The sidebar's mark beside the title, in a panel without tenants. */
|
|
14
|
+
logo?: ComponentType<{
|
|
15
|
+
className?: string;
|
|
16
|
+
}>;
|
|
17
|
+
resources: Resource[];
|
|
18
|
+
/**
|
|
19
|
+
* What draws the panel: import { shadcn } from '@rsc-kit/press/shadcn', or
|
|
20
|
+
* any object that fulfils AdminTheme. The panel decides what is shown and
|
|
21
|
+
* who may see it; the theme only draws.
|
|
22
|
+
*/
|
|
23
|
+
theme: AdminTheme;
|
|
24
|
+
/**
|
|
25
|
+
* Whether this request may use the panel. Runs on every page and on every
|
|
26
|
+
* save, delete and switch - an action is reachable without the page that
|
|
27
|
+
* rendered it, so a guard on the pages alone would not be one. A refusal is
|
|
28
|
+
* a 404. The panel has no login of its own: this asks the app's.
|
|
29
|
+
*/
|
|
30
|
+
authorize?: () => boolean | Promise<boolean>;
|
|
31
|
+
/** Who is signed in, for the sidebar's footer. */
|
|
32
|
+
user?: () => PanelUser | null | Promise<PanelUser | null>;
|
|
33
|
+
/** Sign the visitor out. Runs in a server action; the visitor lands on /. */
|
|
34
|
+
signOut?: () => void | Promise<void>;
|
|
35
|
+
/** Whose records the panel shows: a switcher in the sidebar, and a tenant on every data source call. */
|
|
36
|
+
tenancy?: Tenancy;
|
|
37
|
+
/** Packages that add resources, dashboard widgets and reactions to changes. */
|
|
38
|
+
plugins?: PanelPlugin[];
|
|
39
|
+
/**
|
|
40
|
+
* Plugins a person installs from the panel while it runs, with no rebuild:
|
|
41
|
+
* a manifest url, reviewed, then installed for the current tenant. Mounted
|
|
42
|
+
* as src/app/admin/plugins/page.tsx. See installable.ts for what they can
|
|
43
|
+
* and cannot reach.
|
|
44
|
+
*/
|
|
45
|
+
installs?: Installs;
|
|
46
|
+
/** The light/dark switch in the header. On unless the app has its own. */
|
|
47
|
+
modeToggle?: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** A type, not an interface: section() takes props that are a record, and an interface has no index signature. */
|
|
50
|
+
type Props<P, S = never> = {
|
|
51
|
+
params: Promise<P>;
|
|
52
|
+
searchParams: Promise<S>;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A panel: its resources, and the pages that show them, already bound to it.
|
|
56
|
+
* The app mounts each in a route file of its own, because the engine finds
|
|
57
|
+
* routes, url schemas and sections by their files:
|
|
58
|
+
*
|
|
59
|
+
* src/app/admin/middleware.ts export default panel.middleware
|
|
60
|
+
* src/app/admin/layout.tsx export default panel.Layout
|
|
61
|
+
* src/app/admin/page.tsx export default panel.Dashboard
|
|
62
|
+
* src/app/admin/[resource]/page.tsx export const params = resourceParams
|
|
63
|
+
* export const searchParams = listSearch
|
|
64
|
+
* export default panel.Index
|
|
65
|
+
* src/app/admin/[resource]/admin-table.section.tsx export default panel.Table
|
|
66
|
+
* src/app/admin/[resource]/new/page.tsx export const params = resourceParams
|
|
67
|
+
* export default panel.Create
|
|
68
|
+
* src/app/admin/[resource]/[id]/page.tsx export const params = recordParams
|
|
69
|
+
* export default panel.Edit
|
|
70
|
+
*/
|
|
71
|
+
export declare function definePanel(definition: PanelDefinition): {
|
|
72
|
+
path: string;
|
|
73
|
+
resources: Resource<any, import("../resource.js").FormSchema>[];
|
|
74
|
+
middleware: (params?: Record<string, string | undefined>) => Promise<void>;
|
|
75
|
+
Layout: ({ children }: {
|
|
76
|
+
children: ReactNode;
|
|
77
|
+
}) => import("react").JSX.Element;
|
|
78
|
+
Dashboard: () => import("react").JSX.Element;
|
|
79
|
+
Index: (props: Props<{
|
|
80
|
+
resource: string;
|
|
81
|
+
}, ListSearch>) => import("react").JSX.Element;
|
|
82
|
+
Table: ComponentType<Props<{
|
|
83
|
+
resource: string;
|
|
84
|
+
}, ListSearch>>;
|
|
85
|
+
Create: (props: Props<{
|
|
86
|
+
resource: string;
|
|
87
|
+
}>) => import("react").JSX.Element;
|
|
88
|
+
Edit: (props: Props<{
|
|
89
|
+
resource: string;
|
|
90
|
+
id: string;
|
|
91
|
+
}>) => import("react").JSX.Element;
|
|
92
|
+
Plugins: (props: {
|
|
93
|
+
searchParams: Promise<PluginsSearch>;
|
|
94
|
+
}) => import("react").JSX.Element;
|
|
95
|
+
};
|
|
96
|
+
export type Panel = ReturnType<typeof definePanel>;
|