@flaredev/cli 0.8.1 → 0.9.1

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.
Files changed (33) hide show
  1. package/dist/index.js +2488 -1796
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/actions.ts +42 -4
  4. package/templates/app/components/dashboard/fields/field-widget.tsx +34 -2
  5. package/templates/app/components/dashboard/fields/json-field.tsx +90 -0
  6. package/templates/app/components/dashboard/fields/markdown-field.tsx +82 -0
  7. package/templates/app/components/dashboard/fields/markdown.tsx +159 -0
  8. package/templates/app/components/dashboard/fields/tags-field.tsx +100 -0
  9. package/templates/app/components/dashboard/import-dialog.tsx +19 -0
  10. package/templates/app/components/dashboard/record-actions.tsx +4 -1
  11. package/templates/app/components/dashboard/record-detail.tsx +18 -0
  12. package/templates/app/components/dashboard/resource-form-page.tsx +5 -1
  13. package/templates/app/components/dashboard/resource-form-sheet.tsx +16 -2
  14. package/templates/app/components/dashboard/resource-form.tsx +11 -2
  15. package/templates/app/components/dashboard/resource-table.tsx +21 -2
  16. package/templates/app/components/dashboard/row-actions.tsx +42 -8
  17. package/templates/app/components/dashboard/trash-toggle.tsx +30 -0
  18. package/templates/app/lib/api-keys.ts +61 -0
  19. package/templates/app/lib/api.ts +35 -9
  20. package/templates/app/lib/dashboard.ts +36 -7
  21. package/templates/app/lib/realtime.ts +5 -0
  22. package/templates/app/lib/resource/drizzle-rows.ts +9 -2
  23. package/templates/app/lib/resource/http.ts +19 -3
  24. package/templates/app/lib/resource/query.ts +15 -1
  25. package/templates/app/lib/resource/rows.ts +11 -1
  26. package/templates/app/lib/resource/store.ts +176 -13
  27. package/templates/locks/package-lock.json +269 -263
  28. package/templates/locks/pnpm-lock.yaml +328 -322
  29. package/templates/locks/yarn.lock +288 -288
  30. package/templates/next/app/dashboard/actions.ts +42 -4
  31. package/templates/next/lib/dashboard.ts +38 -7
  32. package/templates/next/lib/realtime.ts +28 -5
  33. 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.8.1",
3
+ "version": "0.9.1",
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.8.1"
44
+ "@flaredev/core": "0.9.1"
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
- export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
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 existing = await store.get(id);
52
- const result = await store.delete(id);
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 { normalizeDomain, optionLabel, slugify, type FileField as FileFieldDef, type MultiSelectField as MultiSelectDef, type StoredField } from "@flaredev/core";
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
- return <Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />;
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">