@zerotal/admin 1.0.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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/package.json +78 -0
- package/src/Cluster.ts +50 -0
- package/src/Panel.ts +288 -0
- package/src/PanelInstance.ts +644 -0
- package/src/Resource.ts +918 -0
- package/src/actions/Action.ts +607 -0
- package/src/actions/ImportRecordsJob.ts +108 -0
- package/src/actions/csv.ts +123 -0
- package/src/actions/index.ts +39 -0
- package/src/actions/render.tsx +181 -0
- package/src/actions/transfer.ts +307 -0
- package/src/actions/xlsx.ts +304 -0
- package/src/auth/AuthLayout.tsx +34 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
- package/src/auth/pages/LoginPage.tsx +121 -0
- package/src/auth/pages/ProfilePage.tsx +216 -0
- package/src/auth/pages/ResetPasswordPage.tsx +103 -0
- package/src/auth/pages/VerifyEmailPage.tsx +68 -0
- package/src/auth/register.ts +44 -0
- package/src/authRoles.ts +141 -0
- package/src/commands/MakeAdminResourceCommand.ts +181 -0
- package/src/config.ts +128 -0
- package/src/dashboardLayout.ts +101 -0
- package/src/databaseMedia.ts +148 -0
- package/src/databaseNotifications.ts +169 -0
- package/src/form/Field.ts +928 -0
- package/src/form/ResourceForm.ts +48 -0
- package/src/form/Section.ts +364 -0
- package/src/form/editors.ts +43 -0
- package/src/form/index.ts +59 -0
- package/src/history.ts +151 -0
- package/src/impersonation.ts +126 -0
- package/src/index.ts +380 -0
- package/src/infolist/Entry.ts +537 -0
- package/src/infolist/Section.ts +99 -0
- package/src/infolist/index.ts +38 -0
- package/src/media.ts +297 -0
- package/src/notifications.ts +65 -0
- package/src/pages/AdminPage.ts +100 -0
- package/src/pages/ConsolePage.tsx +324 -0
- package/src/pages/DashboardPage.tsx +264 -0
- package/src/pages/MediaPage.tsx +346 -0
- package/src/pages/NotificationsPage.tsx +155 -0
- package/src/pages/RecordViewPage.tsx +951 -0
- package/src/pages/ResourceFormPage.tsx +1856 -0
- package/src/pages/ResourceListPage.tsx +2552 -0
- package/src/pages/RolesPage.tsx +325 -0
- package/src/pages/SearchPage.tsx +169 -0
- package/src/plugin.ts +283 -0
- package/src/provider/AdminAbilityMiddleware.ts +25 -0
- package/src/provider/AdminGuardMiddleware.ts +29 -0
- package/src/provider/AdminProvider.ts +334 -0
- package/src/relations/RelationManager.ts +114 -0
- package/src/renderHooks.ts +86 -0
- package/src/roles.ts +175 -0
- package/src/savedViews.ts +79 -0
- package/src/support/ability.ts +73 -0
- package/src/support/authorize.ts +105 -0
- package/src/support/countCache.ts +37 -0
- package/src/support/hostPage.ts +30 -0
- package/src/table/Column.ts +353 -0
- package/src/table/Constraint.ts +238 -0
- package/src/table/Filter.ts +275 -0
- package/src/table/Group.ts +73 -0
- package/src/table/Tab.ts +77 -0
- package/src/testing.ts +121 -0
- package/src/theme.ts +70 -0
- package/src/ui/AdminLayout.tsx +355 -0
- package/src/ui/Breadcrumbs.tsx +84 -0
- package/src/ui/environmentIndicator.tsx +63 -0
- package/src/ui/icons.tsx +124 -0
- package/src/widgets/Widget.ts +251 -0
- package/src/widgets/render.tsx +154 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The queued half of {@link importAction}.
|
|
3
|
+
*
|
|
4
|
+
* A synchronous import holds a WebSocket round-trip open, so a large file looks
|
|
5
|
+
* like a hang and times out half-written. Handing the work to a queue turns that
|
|
6
|
+
* into a job the worker chews through while the user gets on with their day.
|
|
7
|
+
*
|
|
8
|
+
* This module imports `@zerotal/queue` lazily and only when an app actually
|
|
9
|
+
* queues an import, so the dependency stays optional for everyone else.
|
|
10
|
+
*
|
|
11
|
+
* The worker needs to be able to reconstruct the job from its payload, which
|
|
12
|
+
* means registering the class where the worker can see it:
|
|
13
|
+
*
|
|
14
|
+
* import { JobRegistry } from "@zerotal/queue";
|
|
15
|
+
* import { ImportRecordsJob } from "@zerotal/admin";
|
|
16
|
+
*
|
|
17
|
+
* JobRegistry.register(ImportRecordsJob);
|
|
18
|
+
*
|
|
19
|
+
* A job carries the panel id and resource slug rather than the resource itself,
|
|
20
|
+
* because a class cannot be serialised into a queue — the worker looks it up in
|
|
21
|
+
* the same registry the panel built at boot.
|
|
22
|
+
*/
|
|
23
|
+
import { Panel } from "../Panel.ts";
|
|
24
|
+
import { importCsv } from "./transfer.ts";
|
|
25
|
+
import type { ImportResult } from "./transfer.ts";
|
|
26
|
+
import { frameworkLog } from "@zerotal/core/logger";
|
|
27
|
+
|
|
28
|
+
/** What the job needs to do its work, once it is pulled off the queue. */
|
|
29
|
+
export interface ImportRecordsPayload {
|
|
30
|
+
panelId: string;
|
|
31
|
+
slug: string;
|
|
32
|
+
csv: string;
|
|
33
|
+
mapping?: Record<number, string> | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A queued CSV import.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately shaped like a `@zerotal/queue` `Job` — `handle()` plus
|
|
40
|
+
* `payload()` — without extending it, so `@zerotal/admin` needs no dependency
|
|
41
|
+
* on the queue package. Registering it works the same either way.
|
|
42
|
+
*/
|
|
43
|
+
export class ImportRecordsJob {
|
|
44
|
+
readonly queue = "default";
|
|
45
|
+
readonly maxAttempts = 1; // A half-applied retry would double-import rows.
|
|
46
|
+
|
|
47
|
+
constructor(private readonly _payload: ImportRecordsPayload) {}
|
|
48
|
+
|
|
49
|
+
payload(): Record<string, unknown> {
|
|
50
|
+
return { ...this._payload };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get className(): string {
|
|
54
|
+
return "ImportRecordsJob";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async handle(): Promise<void> {
|
|
58
|
+
const result = await runQueuedImport(this._payload);
|
|
59
|
+
const log = frameworkLog("admin");
|
|
60
|
+
if (result.failures.length > 0) {
|
|
61
|
+
log.warn(`Import finished with ${result.failures.length} skipped row(s)`, {
|
|
62
|
+
slug: this._payload.slug,
|
|
63
|
+
created: result.created,
|
|
64
|
+
failures: result.failures.slice(0, 10),
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
log.info(`Imported ${result.created} record(s)`, { slug: this._payload.slug });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run an import described by a payload. Exported so an app can drive it from its
|
|
74
|
+
* own job class without adopting {@link ImportRecordsJob}.
|
|
75
|
+
*/
|
|
76
|
+
export async function runQueuedImport(payload: ImportRecordsPayload): Promise<ImportResult> {
|
|
77
|
+
const panel = Panel.get(payload.panelId) ?? Panel.default();
|
|
78
|
+
const resource = panel.find(payload.slug);
|
|
79
|
+
if (!resource) {
|
|
80
|
+
return {
|
|
81
|
+
created: 0,
|
|
82
|
+
failures: [`No resource "${payload.slug}" on panel "${payload.panelId}".`],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// The row cap is a guard against holding a request open; a worker has no such
|
|
86
|
+
// constraint, so a queued import is allowed the whole file.
|
|
87
|
+
return importCsv(resource, payload.csv, payload.mapping, { limit: Number.POSITIVE_INFINITY });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Push an import onto the queue, returning false when no queue is available so
|
|
92
|
+
* the caller can fall back to importing inline.
|
|
93
|
+
*/
|
|
94
|
+
export async function dispatchImport(payload: ImportRecordsPayload): Promise<boolean> {
|
|
95
|
+
try {
|
|
96
|
+
// Resolved by name so `@zerotal/queue` stays a genuinely optional peer —
|
|
97
|
+
// a static import would make every admin install depend on it.
|
|
98
|
+
const mod = (await import(/* @vite-ignore */ "@zerotal/queue" as string)) as {
|
|
99
|
+
Bus?: { dispatch?: (job: unknown) => Promise<unknown> };
|
|
100
|
+
};
|
|
101
|
+
if (typeof mod.Bus?.dispatch !== "function") return false;
|
|
102
|
+
await mod.Bus.dispatch(new ImportRecordsJob(payload));
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
// No queue package, or no driver configured — the caller imports inline.
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV encoding and decoding for the import/export actions.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately small and dependency-free, but strict about the two things that
|
|
5
|
+
* actually bite: quoting on the way out (a comma, quote or newline inside a
|
|
6
|
+
* value must not shift every later column) and quote handling on the way in.
|
|
7
|
+
*/
|
|
8
|
+
import type { Column } from "../table/Column.ts";
|
|
9
|
+
|
|
10
|
+
/** Quote a field when it contains anything that would otherwise break the row. */
|
|
11
|
+
function encodeField(value: string): string {
|
|
12
|
+
if (!/[",\r\n]/.test(value)) return value;
|
|
13
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Render a value for a CSV cell.
|
|
18
|
+
*
|
|
19
|
+
* Primitives go out as themselves — a date export should be re-importable, not
|
|
20
|
+
* "3 days ago". Anything structured (a loaded relation, a JSON column) falls
|
|
21
|
+
* back to the column's display text, which is the only meaningful flat form.
|
|
22
|
+
*/
|
|
23
|
+
function encodeValue(column: Column, row: Record<string, unknown>): string {
|
|
24
|
+
const raw = column.raw(row);
|
|
25
|
+
if (raw == null) return "";
|
|
26
|
+
if (typeof raw === "string") return raw;
|
|
27
|
+
if (typeof raw === "number" || typeof raw === "boolean" || typeof raw === "bigint") {
|
|
28
|
+
return String(raw);
|
|
29
|
+
}
|
|
30
|
+
if (raw instanceof Date) return raw.toISOString();
|
|
31
|
+
return column.cell(row).text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Serialise rows to CSV with a header line, one column per exportable column. */
|
|
35
|
+
export function toCsv(rows: Record<string, unknown>[], columns: Column[]): string {
|
|
36
|
+
const header = columns.map((c) => encodeField(c.getLabel())).join(",");
|
|
37
|
+
const body = rows.map((row) => columns.map((c) => encodeField(encodeValue(c, row))).join(","));
|
|
38
|
+
return [header, ...body].join("\r\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse CSV into rows of raw strings, the first row being the header.
|
|
43
|
+
*
|
|
44
|
+
* Handles quoted fields containing commas, escaped quotes (`""`) and embedded
|
|
45
|
+
* newlines. Returns an empty array for empty input rather than a phantom row.
|
|
46
|
+
*/
|
|
47
|
+
export function parseCsv(text: string): string[][] {
|
|
48
|
+
const source = text.replace(/^/, ""); // strip a spreadsheet's byte-order mark
|
|
49
|
+
if (source.trim() === "") return [];
|
|
50
|
+
|
|
51
|
+
const rows: string[][] = [];
|
|
52
|
+
let row: string[] = [];
|
|
53
|
+
let field = "";
|
|
54
|
+
let quoted = false;
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < source.length; i++) {
|
|
57
|
+
const char = source[i]!;
|
|
58
|
+
|
|
59
|
+
if (quoted) {
|
|
60
|
+
if (char !== '"') {
|
|
61
|
+
field += char;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// A doubled quote is a literal one; a lone quote closes the field.
|
|
65
|
+
if (source[i + 1] === '"') {
|
|
66
|
+
field += '"';
|
|
67
|
+
i++;
|
|
68
|
+
} else {
|
|
69
|
+
quoted = false;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (char === '"' && field === "") {
|
|
75
|
+
quoted = true;
|
|
76
|
+
} else if (char === ",") {
|
|
77
|
+
row.push(field);
|
|
78
|
+
field = "";
|
|
79
|
+
} else if (char === "\r") {
|
|
80
|
+
// Swallow it; the \n that follows ends the row.
|
|
81
|
+
} else if (char === "\n") {
|
|
82
|
+
row.push(field);
|
|
83
|
+
rows.push(row);
|
|
84
|
+
row = [];
|
|
85
|
+
field = "";
|
|
86
|
+
} else {
|
|
87
|
+
field += char;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A file not ending in a newline still has one row left in hand.
|
|
92
|
+
if (field !== "" || row.length > 0) {
|
|
93
|
+
row.push(field);
|
|
94
|
+
rows.push(row);
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Pair a CSV's header cells with resource fields.
|
|
101
|
+
*
|
|
102
|
+
* Matching is forgiving about the ways a label and a key differ in practice —
|
|
103
|
+
* case, spaces, underscores, hyphens — so a file exported from the panel
|
|
104
|
+
* re-imports without the user mapping a single column by hand.
|
|
105
|
+
*/
|
|
106
|
+
export function guessColumnMapping(
|
|
107
|
+
headers: string[],
|
|
108
|
+
candidates: { key: string; label: string }[],
|
|
109
|
+
): Record<number, string> {
|
|
110
|
+
const normalize = (s: string): string => s.toLowerCase().replace(/[\s_-]+/g, "");
|
|
111
|
+
const byKey = new Map<string, string>();
|
|
112
|
+
for (const c of candidates) {
|
|
113
|
+
byKey.set(normalize(c.key), c.key);
|
|
114
|
+
byKey.set(normalize(c.label), c.key);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const mapping: Record<number, string> = {};
|
|
118
|
+
headers.forEach((header, i) => {
|
|
119
|
+
const match = byKey.get(normalize(header));
|
|
120
|
+
if (match) mapping[i] = match;
|
|
121
|
+
});
|
|
122
|
+
return mapping;
|
|
123
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export {
|
|
2
|
+
Action,
|
|
3
|
+
ActionGroup,
|
|
4
|
+
action,
|
|
5
|
+
actionGroup,
|
|
6
|
+
flattenActions,
|
|
7
|
+
viewAction,
|
|
8
|
+
editAction,
|
|
9
|
+
deleteAction,
|
|
10
|
+
createAction,
|
|
11
|
+
replicateAction,
|
|
12
|
+
impersonateAction,
|
|
13
|
+
bulkEditAction,
|
|
14
|
+
bulkDeleteAction,
|
|
15
|
+
restoreAction,
|
|
16
|
+
forceDeleteAction,
|
|
17
|
+
bulkRestoreAction,
|
|
18
|
+
bulkForceDeleteAction,
|
|
19
|
+
} from "./Action.ts";
|
|
20
|
+
export type {
|
|
21
|
+
ActionColor,
|
|
22
|
+
ActionContext,
|
|
23
|
+
ActionHandler,
|
|
24
|
+
ActionItem,
|
|
25
|
+
ActionPage,
|
|
26
|
+
ActionVisible,
|
|
27
|
+
} from "./Action.ts";
|
|
28
|
+
export { renderAction, renderActionGroup, renderActionMenuItem } from "./render.tsx";
|
|
29
|
+
export type { RenderActionOptions } from "./render.tsx";
|
|
30
|
+
export {
|
|
31
|
+
exportAction,
|
|
32
|
+
bulkExportAction,
|
|
33
|
+
importAction,
|
|
34
|
+
importCsv,
|
|
35
|
+
IMPORT_ROW_LIMIT,
|
|
36
|
+
MAPPING_FIELD_PREFIX,
|
|
37
|
+
} from "./transfer.ts";
|
|
38
|
+
export type { ImportResult } from "./transfer.ts";
|
|
39
|
+
export { toCsv, parseCsv, guessColumnMapping } from "./csv.ts";
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/** @jsxImportSource @zerotal/flow */
|
|
2
|
+
// Render an Action as a themed link (navigate) or server-action button (with an
|
|
3
|
+
// optional `confirm` dialog). Shared by row actions, header actions, and the
|
|
4
|
+
// bulk toolbar so every action looks and behaves consistently.
|
|
5
|
+
|
|
6
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
7
|
+
import { DropdownMenu } from "@zerotal/flow-ui";
|
|
8
|
+
import { Icon } from "../ui/icons.tsx";
|
|
9
|
+
import type { Action, ActionColor, ActionContext, ActionGroup } from "./Action.ts";
|
|
10
|
+
|
|
11
|
+
const COLOR: Record<ActionColor, string> = {
|
|
12
|
+
default:
|
|
13
|
+
"border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
|
14
|
+
primary: "border-transparent bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm",
|
|
15
|
+
success: "border-input bg-background text-success hover:bg-success/10 hover:border-success/40",
|
|
16
|
+
muted: "border-input bg-background text-muted-foreground hover:bg-accent",
|
|
17
|
+
destructive:
|
|
18
|
+
"border-input bg-background text-muted-foreground hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface RenderActionOptions {
|
|
22
|
+
/** The page's @expose handler invoked for callback actions. */
|
|
23
|
+
onRun?: unknown;
|
|
24
|
+
/** The page's @expose handler invoked for actions with a modal form (opens it). */
|
|
25
|
+
onForm?: unknown;
|
|
26
|
+
/** Args serialized into `data-args` for the handler (e.g. [key, id]). */
|
|
27
|
+
args?: unknown[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Render a single action. Link actions become `<a navigate>`, callbacks `<button>`. */
|
|
31
|
+
export function renderAction(
|
|
32
|
+
a: Action,
|
|
33
|
+
ctx: ActionContext,
|
|
34
|
+
opts: RenderActionOptions = {},
|
|
35
|
+
): HtmlNode | null {
|
|
36
|
+
if (!a.isVisibleFor(ctx.record, ctx)) return null;
|
|
37
|
+
|
|
38
|
+
const colorCls = COLOR[a._color];
|
|
39
|
+
const cls = a._iconOnly
|
|
40
|
+
? `inline-flex h-8 w-8 items-center justify-center rounded-md border transition ${colorCls}`
|
|
41
|
+
: `inline-flex h-9 items-center gap-1.5 rounded-lg border px-3 text-sm font-medium transition ${colorCls}`;
|
|
42
|
+
|
|
43
|
+
const inner = (
|
|
44
|
+
<>
|
|
45
|
+
{a._icon ? <Icon name={a._icon} class="h-4 w-4" /> : null}
|
|
46
|
+
{a._iconOnly ? null : <span>{a.getLabel()}</span>}
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Modal-form action: open the dialog (no confirm — the modal is the confirm).
|
|
51
|
+
if (a.hasForm() && opts.onForm) {
|
|
52
|
+
return (
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={opts.onForm}
|
|
56
|
+
data-args={JSON.stringify(opts.args ?? [])}
|
|
57
|
+
title={a.getLabel()}
|
|
58
|
+
class={cls}
|
|
59
|
+
>
|
|
60
|
+
{inner}
|
|
61
|
+
</button>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (a.isLink()) {
|
|
66
|
+
const href = a.href(ctx) ?? "#";
|
|
67
|
+
return (
|
|
68
|
+
<a href={href} navigate title={a.getLabel()} class={cls}>
|
|
69
|
+
{inner}
|
|
70
|
+
</a>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Callback action: server method + optional confirm dialog.
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={opts.onRun}
|
|
79
|
+
data-args={JSON.stringify(opts.args ?? [])}
|
|
80
|
+
{...(a._confirm ? { confirm: a._confirm } : {})}
|
|
81
|
+
title={a.getLabel()}
|
|
82
|
+
class={cls}
|
|
83
|
+
>
|
|
84
|
+
{inner}
|
|
85
|
+
</button>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render a group as one trigger opening a menu of its members.
|
|
91
|
+
*
|
|
92
|
+
* Returns `null` when nothing inside is visible, so an empty menu never appears.
|
|
93
|
+
* `argsFor` builds each member's handler arguments, since those carry the
|
|
94
|
+
* action's own key.
|
|
95
|
+
*/
|
|
96
|
+
export function renderActionGroup(
|
|
97
|
+
group: ActionGroup,
|
|
98
|
+
ctx: ActionContext,
|
|
99
|
+
opts: RenderActionOptions & { argsFor?: (a: Action) => unknown[] } = {},
|
|
100
|
+
): HtmlNode | null {
|
|
101
|
+
const members = group.visibleActions(ctx.record, ctx);
|
|
102
|
+
if (members.length === 0) return null;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<DropdownMenu
|
|
106
|
+
align="right"
|
|
107
|
+
trigger={
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
title={group.getLabel()}
|
|
111
|
+
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
|
|
112
|
+
>
|
|
113
|
+
<Icon name={group._icon} class="h-4 w-4" />
|
|
114
|
+
</button>
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
{members.map((a) => {
|
|
118
|
+
const args = opts.argsFor ? opts.argsFor(a) : opts.args;
|
|
119
|
+
return renderActionMenuItem(a, ctx, {
|
|
120
|
+
...opts,
|
|
121
|
+
...(args === undefined ? {} : { args }),
|
|
122
|
+
});
|
|
123
|
+
})}
|
|
124
|
+
</DropdownMenu>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Render an action as a full-width dropdown menu row (icon + label). Used by the
|
|
130
|
+
* row-action overflow menu when a row has more actions than fit inline.
|
|
131
|
+
*/
|
|
132
|
+
export function renderActionMenuItem(
|
|
133
|
+
a: Action,
|
|
134
|
+
ctx: ActionContext,
|
|
135
|
+
opts: RenderActionOptions = {},
|
|
136
|
+
): HtmlNode | null {
|
|
137
|
+
if (!a.isVisibleFor(ctx.record, ctx)) return null;
|
|
138
|
+
|
|
139
|
+
const tone =
|
|
140
|
+
a._color === "destructive"
|
|
141
|
+
? "text-destructive hover:bg-destructive/10"
|
|
142
|
+
: "hover:bg-accent hover:text-accent-foreground";
|
|
143
|
+
const cls = `flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors ${tone}`;
|
|
144
|
+
const inner = (
|
|
145
|
+
<>
|
|
146
|
+
{a._icon ? <Icon name={a._icon} class="h-4 w-4" /> : null}
|
|
147
|
+
<span>{a.getLabel()}</span>
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (a.hasForm() && opts.onForm) {
|
|
152
|
+
return (
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={opts.onForm}
|
|
156
|
+
data-args={JSON.stringify(opts.args ?? [])}
|
|
157
|
+
class={cls}
|
|
158
|
+
>
|
|
159
|
+
{inner}
|
|
160
|
+
</button>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (a.isLink()) {
|
|
164
|
+
return (
|
|
165
|
+
<a href={a.href(ctx) ?? "#"} navigate class={cls}>
|
|
166
|
+
{inner}
|
|
167
|
+
</a>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return (
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={opts.onRun}
|
|
174
|
+
data-args={JSON.stringify(opts.args ?? [])}
|
|
175
|
+
{...(a._confirm ? { confirm: a._confirm } : {})}
|
|
176
|
+
class={cls}
|
|
177
|
+
>
|
|
178
|
+
{inner}
|
|
179
|
+
</button>
|
|
180
|
+
);
|
|
181
|
+
}
|