@flaredev/cli 0.8.1 → 0.9.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/dist/index.js +2488 -1796
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +42 -4
- package/templates/app/components/dashboard/fields/field-widget.tsx +34 -2
- package/templates/app/components/dashboard/fields/json-field.tsx +90 -0
- package/templates/app/components/dashboard/fields/markdown-field.tsx +82 -0
- package/templates/app/components/dashboard/fields/markdown.tsx +159 -0
- package/templates/app/components/dashboard/fields/tags-field.tsx +100 -0
- package/templates/app/components/dashboard/import-dialog.tsx +19 -0
- package/templates/app/components/dashboard/record-actions.tsx +4 -1
- package/templates/app/components/dashboard/record-detail.tsx +18 -0
- package/templates/app/components/dashboard/resource-form-page.tsx +5 -1
- package/templates/app/components/dashboard/resource-form-sheet.tsx +16 -2
- package/templates/app/components/dashboard/resource-form.tsx +11 -2
- package/templates/app/components/dashboard/resource-table.tsx +21 -2
- package/templates/app/components/dashboard/row-actions.tsx +42 -8
- package/templates/app/components/dashboard/trash-toggle.tsx +30 -0
- package/templates/app/lib/api-keys.ts +61 -0
- package/templates/app/lib/api.ts +35 -9
- package/templates/app/lib/dashboard.ts +36 -7
- package/templates/app/lib/realtime.ts +5 -0
- package/templates/app/lib/resource/drizzle-rows.ts +9 -2
- package/templates/app/lib/resource/http.ts +19 -3
- package/templates/app/lib/resource/query.ts +15 -1
- package/templates/app/lib/resource/rows.ts +11 -1
- package/templates/app/lib/resource/store.ts +176 -13
- package/templates/locks/package-lock.json +266 -260
- package/templates/locks/pnpm-lock.yaml +328 -322
- package/templates/locks/yarn.lock +285 -285
- package/templates/next/app/dashboard/actions.ts +42 -4
- package/templates/next/lib/dashboard.ts +38 -7
- package/templates/next/lib/realtime.ts +28 -5
- package/templates/next/lib/resource/prisma-rows.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Muke Johnbaptist",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"jsonc-parser": "^3.3.1",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
|
-
"@flaredev/core": "0.
|
|
44
|
+
"@flaredev/core": "0.9.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -43,20 +43,58 @@ async function removeUploads(resource: Resource, record: Record<string, unknown>
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Delete a record, or empty it from the trash with `force`.
|
|
48
|
+
*
|
|
49
|
+
* Uploads are only cleaned up when the row really goes. A soft delete is meant to be
|
|
50
|
+
* reversible, and a restore that gave back a record with its files missing would be a
|
|
51
|
+
* worse outcome than keeping the bytes a little longer.
|
|
52
|
+
*/
|
|
53
|
+
export async function deleteRecordAction(
|
|
54
|
+
resourceName: string,
|
|
55
|
+
id: string,
|
|
56
|
+
options: { force?: boolean } = {},
|
|
57
|
+
): Promise<ActionResult<{ id: string }>> {
|
|
47
58
|
const denied = await allowed(resourceName, "delete");
|
|
48
59
|
if (denied) return denied;
|
|
49
60
|
|
|
50
61
|
const store = dashboardStore(resourceName);
|
|
51
|
-
const
|
|
52
|
-
const
|
|
62
|
+
const force = options.force === true;
|
|
63
|
+
const soft = store.resource.softDelete && !force;
|
|
64
|
+
// `deleted: "all"` so emptying the trash can read the row it is about to remove.
|
|
65
|
+
const existing = await store.get(id, { deleted: "all" });
|
|
66
|
+
const result = await store.delete(id, { force });
|
|
53
67
|
if (result.ok) {
|
|
54
|
-
await removeUploads(store.resource, existing.ok ? existing.data : null);
|
|
68
|
+
if (!soft) await removeUploads(store.resource, existing.ok ? existing.data : null);
|
|
55
69
|
await recordAudit({
|
|
56
70
|
action: "delete",
|
|
57
71
|
resource: resourceName,
|
|
58
72
|
recordId: id,
|
|
59
73
|
recordLabel: existing.ok ? recordTitle(store.resource, existing.data) : null,
|
|
74
|
+
// Which kind of delete this was, since on a softDelete resource they are different
|
|
75
|
+
// events with the same name.
|
|
76
|
+
changes: store.resource.softDelete ? { trash: soft ? "moved to" : "emptied from" } : null,
|
|
77
|
+
});
|
|
78
|
+
revalidatePath(resourcePath(store.resource));
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Put a record back from the trash. Only a `softDelete` resource has anything to restore. */
|
|
84
|
+
export async function restoreRecordAction(resourceName: string, id: string): Promise<ActionResult<Record<string, unknown>>> {
|
|
85
|
+
// Restoring is a change to the record, so it needs the permission that changes records.
|
|
86
|
+
const denied = await allowed(resourceName, "update");
|
|
87
|
+
if (denied) return denied;
|
|
88
|
+
|
|
89
|
+
const store = dashboardStore(resourceName);
|
|
90
|
+
const result = await store.restore(id);
|
|
91
|
+
if (result.ok) {
|
|
92
|
+
await recordAudit({
|
|
93
|
+
action: "update",
|
|
94
|
+
resource: resourceName,
|
|
95
|
+
recordId: id,
|
|
96
|
+
recordLabel: recordTitle(store.resource, result.data),
|
|
97
|
+
changes: { trash: "restored from" },
|
|
60
98
|
});
|
|
61
99
|
revalidatePath(resourcePath(store.resource));
|
|
62
100
|
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
normalizeDomain,
|
|
5
|
+
optionLabel,
|
|
6
|
+
slugify,
|
|
7
|
+
type FileField as FileFieldDef,
|
|
8
|
+
type JsonField as JsonDef,
|
|
9
|
+
type MultiSelectField as MultiSelectDef,
|
|
10
|
+
type StoredField,
|
|
11
|
+
type TagsField as TagsDef,
|
|
12
|
+
} from "@flaredev/core";
|
|
4
13
|
import { Button } from "@/components/ui/button";
|
|
5
14
|
import { Input } from "@/components/ui/input";
|
|
6
15
|
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
|
@@ -13,7 +22,10 @@ import { groupDigits, ungroup } from "@/lib/number";
|
|
|
13
22
|
import { DateField, DateTimeField } from "./date-field";
|
|
14
23
|
import { CountryField } from "./country-field";
|
|
15
24
|
import { FileField } from "./file-field";
|
|
25
|
+
import { JsonField } from "./json-field";
|
|
26
|
+
import { MarkdownField } from "./markdown-field";
|
|
16
27
|
import { MultiSelectField } from "./multi-select-field";
|
|
28
|
+
import { TagsField } from "./tags-field";
|
|
17
29
|
import { PhoneField } from "./phone-field";
|
|
18
30
|
import { RelationField } from "./relation-field";
|
|
19
31
|
|
|
@@ -56,7 +68,21 @@ export function FieldWidget(props: WidgetProps) {
|
|
|
56
68
|
|
|
57
69
|
switch (field.kind) {
|
|
58
70
|
case "text":
|
|
59
|
-
|
|
71
|
+
// A markdown field is the same column; the editor is what differs.
|
|
72
|
+
return field.format === "markdown" ? (
|
|
73
|
+
<MarkdownField
|
|
74
|
+
id={id}
|
|
75
|
+
value={text}
|
|
76
|
+
onChange={onChange}
|
|
77
|
+
invalid={invalid}
|
|
78
|
+
disabled={disabled}
|
|
79
|
+
required={field.required}
|
|
80
|
+
placeholder={field.placeholder}
|
|
81
|
+
maxLength={field.maxLength}
|
|
82
|
+
/>
|
|
83
|
+
) : (
|
|
84
|
+
<Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />
|
|
85
|
+
);
|
|
60
86
|
|
|
61
87
|
case "int":
|
|
62
88
|
case "float": {
|
|
@@ -174,6 +200,12 @@ export function FieldWidget(props: WidgetProps) {
|
|
|
174
200
|
<MultiSelectField id={id} field={field as MultiSelectDef & { label: string }} value={text} onChange={onChange} invalid={invalid} disabled={disabled} />
|
|
175
201
|
);
|
|
176
202
|
|
|
203
|
+
case "tags":
|
|
204
|
+
return <TagsField id={id} field={field as TagsDef & { label: string }} value={text} onChange={onChange} invalid={invalid} disabled={disabled} />;
|
|
205
|
+
|
|
206
|
+
case "json":
|
|
207
|
+
return <JsonField id={id} field={field as JsonDef & { label: string }} value={text} onChange={onChange} invalid={invalid} disabled={disabled} />;
|
|
208
|
+
|
|
177
209
|
case "date":
|
|
178
210
|
return (
|
|
179
211
|
<DateField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { BracesIcon, CheckIcon } from "lucide-react";
|
|
5
|
+
import type { JsonField as JsonDef } from "@flaredev/core";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
id: string;
|
|
11
|
+
field: JsonDef & { label: string };
|
|
12
|
+
/** Form state: the JSON source as typed. */
|
|
13
|
+
value: string;
|
|
14
|
+
onChange: (value: string) => void;
|
|
15
|
+
invalid: boolean;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parse, and say what is wrong if it will not. */
|
|
20
|
+
function problem(source: string, mustBeObject: boolean): string | null {
|
|
21
|
+
const text = source.trim();
|
|
22
|
+
if (text === "") return null;
|
|
23
|
+
let parsed: unknown;
|
|
24
|
+
try {
|
|
25
|
+
parsed = JSON.parse(text);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
// The browser's message names the position, which is the useful part.
|
|
28
|
+
return error instanceof Error ? error.message.replace(/^JSON\.parse:\s*/, "") : "Not valid JSON";
|
|
29
|
+
}
|
|
30
|
+
if (mustBeObject && (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))) {
|
|
31
|
+
return "This field holds a JSON object, like { }";
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A `json` field: a textarea that says what is wrong with the JSON as you type.
|
|
38
|
+
*
|
|
39
|
+
* The form state is the source text, not a parsed value, so what you typed is what you
|
|
40
|
+
* see — including while it is half-written. The save is blocked by the form's own
|
|
41
|
+
* validation, which rejects text that does not parse, so nothing malformed can be stored;
|
|
42
|
+
* this is here to tell you *why* before you get there.
|
|
43
|
+
*/
|
|
44
|
+
export function JsonField({ id, field, value, onChange, invalid, disabled }: Props) {
|
|
45
|
+
const [touched, setTouched] = useState(false);
|
|
46
|
+
const message = problem(value, field.object === true);
|
|
47
|
+
const lines = Math.min(16, Math.max(4, value.split("\n").length + 1));
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex flex-col gap-2">
|
|
51
|
+
<Textarea
|
|
52
|
+
id={id}
|
|
53
|
+
value={value}
|
|
54
|
+
rows={lines}
|
|
55
|
+
spellCheck={false}
|
|
56
|
+
disabled={disabled}
|
|
57
|
+
aria-invalid={invalid || (touched && message !== null) || undefined}
|
|
58
|
+
aria-describedby={message ? `${id}-json-error` : undefined}
|
|
59
|
+
onBlur={() => setTouched(true)}
|
|
60
|
+
onChange={(event) => onChange(event.target.value)}
|
|
61
|
+
placeholder={field.object ? '{\n "key": "value"\n}' : "null"}
|
|
62
|
+
className="font-mono text-sm"
|
|
63
|
+
/>
|
|
64
|
+
<div className="flex items-center justify-between gap-2">
|
|
65
|
+
{message ? (
|
|
66
|
+
<p id={`${id}-json-error`} className="text-destructive text-sm">
|
|
67
|
+
{message}
|
|
68
|
+
</p>
|
|
69
|
+
) : value.trim() === "" ? (
|
|
70
|
+
<p className="text-muted-foreground text-sm">Empty</p>
|
|
71
|
+
) : (
|
|
72
|
+
<p className="text-muted-foreground flex items-center gap-1 text-sm">
|
|
73
|
+
<CheckIcon className="size-3.5" />
|
|
74
|
+
Valid JSON
|
|
75
|
+
</p>
|
|
76
|
+
)}
|
|
77
|
+
<Button
|
|
78
|
+
type="button"
|
|
79
|
+
variant="ghost"
|
|
80
|
+
size="sm"
|
|
81
|
+
disabled={disabled || message !== null || value.trim() === ""}
|
|
82
|
+
onClick={() => onChange(JSON.stringify(JSON.parse(value), null, 2))}
|
|
83
|
+
>
|
|
84
|
+
<BracesIcon data-icon="inline-start" />
|
|
85
|
+
Format
|
|
86
|
+
</Button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { EyeIcon, PencilIcon } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
7
|
+
import { Markdown } from "./markdown";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
id: string;
|
|
11
|
+
value: string;
|
|
12
|
+
onChange: (value: string) => void;
|
|
13
|
+
invalid: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
maxLength?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A `markdown` field: write on one pane, see it on the other.
|
|
22
|
+
*
|
|
23
|
+
* Not a WYSIWYG editor, and that is a choice rather than a shortcut. A rich-text editor is
|
|
24
|
+
* a large dependency that stores HTML you then have to sanitise everywhere it is shown;
|
|
25
|
+
* Markdown is text, it diffs, it survives being edited by a script, and the preview uses
|
|
26
|
+
* the same renderer as the record page.
|
|
27
|
+
*
|
|
28
|
+
* Two buttons rather than a Tabs component, because this is two states and Tabs would mean
|
|
29
|
+
* adding another Radix package to every app for one field.
|
|
30
|
+
*/
|
|
31
|
+
export function MarkdownField({ id, value, onChange, invalid, disabled, required, placeholder, maxLength }: Props) {
|
|
32
|
+
const [previewing, setPreviewing] = useState(false);
|
|
33
|
+
const lines = Math.min(24, Math.max(6, value.split("\n").length + 1));
|
|
34
|
+
const empty = value.trim() === "";
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex flex-col gap-2">
|
|
38
|
+
<div className="flex items-center gap-1" role="group" aria-label="Markdown editor view">
|
|
39
|
+
<Button
|
|
40
|
+
type="button"
|
|
41
|
+
size="sm"
|
|
42
|
+
variant={previewing ? "ghost" : "secondary"}
|
|
43
|
+
aria-pressed={!previewing}
|
|
44
|
+
onClick={() => setPreviewing(false)}
|
|
45
|
+
>
|
|
46
|
+
<PencilIcon data-icon="inline-start" />
|
|
47
|
+
Write
|
|
48
|
+
</Button>
|
|
49
|
+
<Button
|
|
50
|
+
type="button"
|
|
51
|
+
size="sm"
|
|
52
|
+
variant={previewing ? "secondary" : "ghost"}
|
|
53
|
+
aria-pressed={previewing}
|
|
54
|
+
disabled={empty}
|
|
55
|
+
onClick={() => setPreviewing(true)}
|
|
56
|
+
>
|
|
57
|
+
<EyeIcon data-icon="inline-start" />
|
|
58
|
+
Preview
|
|
59
|
+
</Button>
|
|
60
|
+
</div>
|
|
61
|
+
{previewing ? (
|
|
62
|
+
// Same minimum height as the editor, so switching does not move the page about.
|
|
63
|
+
<div className="min-h-32 rounded-md border px-3 py-2">
|
|
64
|
+
<Markdown source={value} />
|
|
65
|
+
</div>
|
|
66
|
+
) : (
|
|
67
|
+
<Textarea
|
|
68
|
+
id={id}
|
|
69
|
+
value={value}
|
|
70
|
+
rows={lines}
|
|
71
|
+
disabled={disabled}
|
|
72
|
+
required={required}
|
|
73
|
+
maxLength={maxLength}
|
|
74
|
+
aria-invalid={invalid || undefined}
|
|
75
|
+
onChange={(event) => onChange(event.target.value)}
|
|
76
|
+
placeholder={placeholder ?? "Markdown: **bold**, [links](https://example.com), - lists"}
|
|
77
|
+
className="font-mono text-sm"
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A small Markdown renderer for `markdown` fields.
|
|
5
|
+
*
|
|
6
|
+
* It builds React elements, not an HTML string. That is the whole reason it exists rather
|
|
7
|
+
* than a dependency: there is no `dangerouslySetInnerHTML` anywhere in it, so there is no
|
|
8
|
+
* sanitiser to get right and no way for stored text to become markup. Link hrefs are the
|
|
9
|
+
* one place a value reaches an attribute, and those are checked against a protocol list.
|
|
10
|
+
*
|
|
11
|
+
* What it supports, and nothing else: headings, paragraphs, bulleted and numbered lists,
|
|
12
|
+
* blockquotes, fenced and inline code, bold, italic, links, and `---`. Tables, footnotes,
|
|
13
|
+
* images and raw HTML are not rendered — they appear as the text you typed. If you need
|
|
14
|
+
* more than this, this file is yours: replace it with a parser you like.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** `javascript:` in a link is the one way text here could become an action. */
|
|
18
|
+
const SAFE_PROTOCOL = /^(https?:|mailto:|tel:|\/|#)/i;
|
|
19
|
+
|
|
20
|
+
/** Inline: **bold**, *italic*, `code`, [text](href). Leftmost match wins, then recurse. */
|
|
21
|
+
function inline(text: string, keyPrefix: string): ReactNode[] {
|
|
22
|
+
const out: ReactNode[] = [];
|
|
23
|
+
let rest = text;
|
|
24
|
+
let index = 0;
|
|
25
|
+
|
|
26
|
+
while (rest.length > 0) {
|
|
27
|
+
const match = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`([^`]+?)`)|(\[([^\]]*)\]\(([^)\s]+)\))/.exec(rest);
|
|
28
|
+
if (!match) {
|
|
29
|
+
out.push(rest);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
if (match.index > 0) out.push(rest.slice(0, match.index));
|
|
33
|
+
const key = `${keyPrefix}-${index++}`;
|
|
34
|
+
|
|
35
|
+
if (match[2] !== undefined) out.push(<strong key={key}>{inline(match[2], key)}</strong>);
|
|
36
|
+
else if (match[4] !== undefined) out.push(<em key={key}>{inline(match[4], key)}</em>);
|
|
37
|
+
else if (match[6] !== undefined)
|
|
38
|
+
out.push(
|
|
39
|
+
<code key={key} className="bg-muted rounded px-1 py-0.5 font-mono text-[0.9em]">
|
|
40
|
+
{match[6]}
|
|
41
|
+
</code>,
|
|
42
|
+
);
|
|
43
|
+
else if (match[9] !== undefined) {
|
|
44
|
+
const href = match[9];
|
|
45
|
+
const label = match[8] || href;
|
|
46
|
+
out.push(
|
|
47
|
+
SAFE_PROTOCOL.test(href) ? (
|
|
48
|
+
<a key={key} href={href} className="underline underline-offset-2" rel="noreferrer noopener" target="_blank">
|
|
49
|
+
{label}
|
|
50
|
+
</a>
|
|
51
|
+
) : (
|
|
52
|
+
// Not a link: shown as the text it is, so nothing is silently dropped either.
|
|
53
|
+
<span key={key}>{match[7]}</span>
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
rest = rest.slice(match.index + match[0]!.length);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const HEADINGS = { 1: "text-xl font-semibold", 2: "text-lg font-semibold", 3: "text-base font-semibold" } as const;
|
|
63
|
+
|
|
64
|
+
export function Markdown({ source }: { source: string }) {
|
|
65
|
+
const lines = source.replace(/\r\n/g, "\n").split("\n");
|
|
66
|
+
const blocks: ReactNode[] = [];
|
|
67
|
+
let paragraph: string[] = [];
|
|
68
|
+
let at = 0;
|
|
69
|
+
|
|
70
|
+
const flush = () => {
|
|
71
|
+
if (paragraph.length === 0) return;
|
|
72
|
+
blocks.push(<p key={`p-${blocks.length}`}>{inline(paragraph.join(" "), `p-${blocks.length}`)}</p>);
|
|
73
|
+
paragraph = [];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
while (at < lines.length) {
|
|
77
|
+
const line = lines[at]!;
|
|
78
|
+
|
|
79
|
+
if (line.trim() === "") {
|
|
80
|
+
flush();
|
|
81
|
+
at += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ``` fenced code: taken literally to the closing fence, or to the end.
|
|
86
|
+
if (/^\s*```/.test(line)) {
|
|
87
|
+
flush();
|
|
88
|
+
const body: string[] = [];
|
|
89
|
+
at += 1;
|
|
90
|
+
while (at < lines.length && !/^\s*```/.test(lines[at]!)) body.push(lines[at++]!);
|
|
91
|
+
at += 1;
|
|
92
|
+
blocks.push(
|
|
93
|
+
<pre key={`pre-${blocks.length}`} className="bg-muted overflow-x-auto rounded-md p-3 font-mono text-sm">
|
|
94
|
+
<code>{body.join("\n")}</code>
|
|
95
|
+
</pre>,
|
|
96
|
+
);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
|
|
101
|
+
if (heading) {
|
|
102
|
+
flush();
|
|
103
|
+
const level = heading[1]!.length as 1 | 2 | 3;
|
|
104
|
+
const Tag = (["h1", "h2", "h3"] as const)[level - 1]!;
|
|
105
|
+
blocks.push(
|
|
106
|
+
<Tag key={`h-${blocks.length}`} className={HEADINGS[level]}>
|
|
107
|
+
{inline(heading[2]!, `h-${blocks.length}`)}
|
|
108
|
+
</Tag>,
|
|
109
|
+
);
|
|
110
|
+
at += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) {
|
|
115
|
+
flush();
|
|
116
|
+
blocks.push(<hr key={`hr-${blocks.length}`} className="border-border" />);
|
|
117
|
+
at += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const bullet = /^\s*[-*+]\s+/;
|
|
122
|
+
const numbered = /^\s*\d+[.)]\s+/;
|
|
123
|
+
if (bullet.test(line) || numbered.test(line)) {
|
|
124
|
+
flush();
|
|
125
|
+
const ordered = numbered.test(line);
|
|
126
|
+
const pattern = ordered ? numbered : bullet;
|
|
127
|
+
const items: string[] = [];
|
|
128
|
+
while (at < lines.length && pattern.test(lines[at]!)) items.push(lines[at++]!.replace(pattern, ""));
|
|
129
|
+
const List = ordered ? "ol" : "ul";
|
|
130
|
+
blocks.push(
|
|
131
|
+
<List key={`l-${blocks.length}`} className={ordered ? "list-decimal pl-5" : "list-disc pl-5"}>
|
|
132
|
+
{items.map((item, index) => (
|
|
133
|
+
<li key={index}>{inline(item, `l-${blocks.length}-${index}`)}</li>
|
|
134
|
+
))}
|
|
135
|
+
</List>,
|
|
136
|
+
);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (/^\s*>\s?/.test(line)) {
|
|
141
|
+
flush();
|
|
142
|
+
const quoted: string[] = [];
|
|
143
|
+
while (at < lines.length && /^\s*>\s?/.test(lines[at]!)) quoted.push(lines[at++]!.replace(/^\s*>\s?/, ""));
|
|
144
|
+
blocks.push(
|
|
145
|
+
<blockquote key={`q-${blocks.length}`} className="text-muted-foreground border-l-2 pl-3 italic">
|
|
146
|
+
{inline(quoted.join(" "), `q-${blocks.length}`)}
|
|
147
|
+
</blockquote>,
|
|
148
|
+
);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
paragraph.push(line.trim());
|
|
153
|
+
at += 1;
|
|
154
|
+
}
|
|
155
|
+
flush();
|
|
156
|
+
|
|
157
|
+
if (blocks.length === 0) return <p className="text-muted-foreground text-sm">Nothing to preview.</p>;
|
|
158
|
+
return <div className="flex flex-col gap-3 text-sm leading-relaxed [&_a]:text-primary">{blocks}</div>;
|
|
159
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, type KeyboardEvent } from "react";
|
|
4
|
+
import { XIcon } from "lucide-react";
|
|
5
|
+
import { parseMultiValue, type TagsField as TagsDef } from "@flaredev/core";
|
|
6
|
+
import { Badge } from "@/components/ui/badge";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
id: string;
|
|
11
|
+
field: TagsDef & { label: string };
|
|
12
|
+
/** Form state: a JSON array of the tags. */
|
|
13
|
+
value: string;
|
|
14
|
+
onChange: (value: string) => void;
|
|
15
|
+
invalid: boolean;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A `tags` field: type a label, press Enter.
|
|
21
|
+
*
|
|
22
|
+
* Unlike a multiselect there is no list to pick from, so the input has to carry the whole
|
|
23
|
+
* interaction: Enter and comma commit, Backspace on an empty box removes the last tag, and
|
|
24
|
+
* a duplicate is refused rather than added twice — case-insensitively, because "Urgent"
|
|
25
|
+
* and "urgent" as two tags is a mistake every time.
|
|
26
|
+
*/
|
|
27
|
+
export function TagsField({ id, field, value, onChange, invalid, disabled }: Props) {
|
|
28
|
+
const tags = parseMultiValue(value);
|
|
29
|
+
const [draft, setDraft] = useState("");
|
|
30
|
+
const input = useRef<HTMLInputElement>(null);
|
|
31
|
+
const maxLength = field.maxLength ?? 32;
|
|
32
|
+
const full = field.maxItems !== undefined && tags.length >= field.maxItems;
|
|
33
|
+
|
|
34
|
+
const commit = (raw: string) => {
|
|
35
|
+
const tag = raw.trim().slice(0, maxLength);
|
|
36
|
+
setDraft("");
|
|
37
|
+
if (!tag || full) return;
|
|
38
|
+
if (tags.some((existing) => existing.toLowerCase() === tag.toLowerCase())) return;
|
|
39
|
+
onChange(JSON.stringify([...tags, tag]));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const remove = (tag: string) => onChange(JSON.stringify(tags.filter((existing) => existing !== tag)));
|
|
43
|
+
|
|
44
|
+
function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
|
45
|
+
if (event.key === "Enter" || event.key === ",") {
|
|
46
|
+
// Enter in a text input submits the form; this is a tag, not a save.
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
commit(draft);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (event.key === "Backspace" && draft === "" && tags.length > 0) {
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
remove(tags[tags.length - 1]!);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
id={id}
|
|
60
|
+
role="group"
|
|
61
|
+
aria-label={field.label}
|
|
62
|
+
aria-invalid={invalid || undefined}
|
|
63
|
+
className="border-input focus-within:border-ring focus-within:ring-ring/50 aria-invalid:border-destructive flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent px-2 py-1.5 focus-within:ring-[3px]"
|
|
64
|
+
// The box looks like one input, so clicking the empty part of it focuses the input.
|
|
65
|
+
onClick={() => input.current?.focus()}
|
|
66
|
+
>
|
|
67
|
+
{tags.map((tag) => (
|
|
68
|
+
<Badge key={tag} variant="secondary" className="gap-1 pr-1">
|
|
69
|
+
{tag}
|
|
70
|
+
{!disabled && (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
aria-label={`Remove ${tag}`}
|
|
74
|
+
className="hover:text-foreground text-muted-foreground rounded-sm"
|
|
75
|
+
onClick={(event) => {
|
|
76
|
+
event.stopPropagation();
|
|
77
|
+
remove(tag);
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<XIcon className="size-3" />
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
</Badge>
|
|
84
|
+
))}
|
|
85
|
+
<Input
|
|
86
|
+
ref={input}
|
|
87
|
+
value={draft}
|
|
88
|
+
disabled={disabled || full}
|
|
89
|
+
maxLength={maxLength}
|
|
90
|
+
// Committing on blur too: typing a tag and clicking Save should not lose it.
|
|
91
|
+
onBlur={() => commit(draft)}
|
|
92
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
93
|
+
onKeyDown={onKeyDown}
|
|
94
|
+
placeholder={full ? `${field.maxItems} is the most` : (field.placeholder ?? (tags.length === 0 ? "Type and press Enter" : ""))}
|
|
95
|
+
className="h-6 min-w-32 flex-1 border-0 bg-transparent p-0 shadow-none focus-visible:ring-0 dark:bg-transparent"
|
|
96
|
+
aria-label={`Add a ${field.label.toLowerCase()} tag`}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -62,6 +62,11 @@ function exampleFor(key: string, def: StoredField & { label: string }): string {
|
|
|
62
62
|
return String((def as EnumField).options[0] ?? "");
|
|
63
63
|
case "multiselect":
|
|
64
64
|
return ((def as MultiSelectField).options.slice(0, 2) as string[]).join("; ");
|
|
65
|
+
case "tags":
|
|
66
|
+
return "urgent; q3";
|
|
67
|
+
case "json":
|
|
68
|
+
// Quoted, because a CSV cell holding JSON has commas in it.
|
|
69
|
+
return '{"key": "value"}';
|
|
65
70
|
case "belongsTo":
|
|
66
71
|
return "id of an existing " + (def as { target: string }).target;
|
|
67
72
|
default:
|
|
@@ -177,6 +182,20 @@ function toValue(def: StoredField, raw: string): unknown {
|
|
|
177
182
|
.map((part) => part.trim())
|
|
178
183
|
.filter(Boolean)
|
|
179
184
|
.map((part) => matchOption(def, part));
|
|
185
|
+
case "tags":
|
|
186
|
+
// Semicolons first so a tag may contain a comma: "a, b; c" is two tags.
|
|
187
|
+
return text
|
|
188
|
+
.split(text.includes(";") ? ";" : ",")
|
|
189
|
+
.map((part) => part.trim())
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
case "json":
|
|
192
|
+
try {
|
|
193
|
+
return JSON.parse(text);
|
|
194
|
+
} catch {
|
|
195
|
+
// Handed over as typed: the validator reports it against the row and the column,
|
|
196
|
+
// which is more use than this throwing halfway through a file.
|
|
197
|
+
return text;
|
|
198
|
+
}
|
|
180
199
|
default:
|
|
181
200
|
// Dates keep their text: the descriptor's schema decides which formats it takes.
|
|
182
201
|
return text;
|
|
@@ -25,6 +25,8 @@ interface Props {
|
|
|
25
25
|
id: string;
|
|
26
26
|
record: Record<string, unknown>;
|
|
27
27
|
relations: FormRelations;
|
|
28
|
+
/** Fields the edit form leaves out — see ResourceForm. */
|
|
29
|
+
omit?: string[];
|
|
28
30
|
listHref: string;
|
|
29
31
|
editHref: string;
|
|
30
32
|
canUpdate: boolean;
|
|
@@ -34,7 +36,7 @@ interface Props {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/** Edit and delete, at the top of a record's own page. Deleting goes back to the list. */
|
|
37
|
-
export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms }: Props) {
|
|
39
|
+
export function RecordActions({ resource, id, record, relations, omit, listHref, editHref, canUpdate, canDelete, overlayForms }: Props) {
|
|
38
40
|
const router = useRouter();
|
|
39
41
|
const [editing, setEditing] = useState(false);
|
|
40
42
|
const [confirming, setConfirming] = useState(false);
|
|
@@ -81,6 +83,7 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
|
|
|
81
83
|
<ResourceFormSheet
|
|
82
84
|
resource={resource}
|
|
83
85
|
relations={relations}
|
|
86
|
+
omit={omit}
|
|
84
87
|
listHref={listHref}
|
|
85
88
|
mode="edit"
|
|
86
89
|
id={id}
|
|
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
|
|
3
3
|
import { clientResource, formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
|
|
4
4
|
import { Badge } from "@/components/ui/badge";
|
|
5
5
|
import { FileCell } from "@/components/dashboard/fields/file-cell";
|
|
6
|
+
import { Markdown } from "@/components/dashboard/fields/markdown";
|
|
6
7
|
import { Button } from "@/components/ui/button";
|
|
7
8
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
9
|
import { recentAudit } from "@/lib/audit";
|
|
@@ -115,6 +116,23 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
|
|
|
115
116
|
} else if (def.kind === "file" && typeof value === "string" && value) {
|
|
116
117
|
// Bigger here than in the table: this is the page for looking at one record.
|
|
117
118
|
content = <FileCell resourceName={resource.name} fieldKey={key} value={value} size={96} />;
|
|
119
|
+
} else if (def.kind === "tags" && Array.isArray(value) && value.length > 0) {
|
|
120
|
+
content = (
|
|
121
|
+
<span className="flex flex-wrap gap-1">
|
|
122
|
+
{value.map((item) => (
|
|
123
|
+
<Badge key={String(item)} variant="outline">
|
|
124
|
+
{String(item)}
|
|
125
|
+
</Badge>
|
|
126
|
+
))}
|
|
127
|
+
</span>
|
|
128
|
+
);
|
|
129
|
+
} else if (def.kind === "json" && value !== null && value !== undefined) {
|
|
130
|
+
// In full here, indented. The table cell gets one truncated line.
|
|
131
|
+
content = (
|
|
132
|
+
<pre className="bg-muted max-h-80 overflow-auto rounded-md p-3 font-mono text-xs">{JSON.stringify(value, null, 2)}</pre>
|
|
133
|
+
);
|
|
134
|
+
} else if (def.kind === "text" && def.format === "markdown" && typeof value === "string" && value.trim()) {
|
|
135
|
+
content = <Markdown source={value} />;
|
|
118
136
|
} else if (def.kind === "multiselect" && Array.isArray(value) && value.length > 0) {
|
|
119
137
|
content = (
|
|
120
138
|
<span className="flex flex-wrap gap-1">
|