@flaredev/cli 0.7.3 → 0.8.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.
- package/dist/index.js +30 -2
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +3 -1
- package/templates/app/components/dashboard/fields/field-widget.tsx +82 -1
- package/templates/app/lib/resource/query.ts +14 -2
- package/templates/app/lib/resource/store.ts +2 -2
- package/templates/next/app/dashboard/actions.ts +3 -1
package/dist/index.js
CHANGED
|
@@ -457,8 +457,26 @@ import pc6 from "picocolors";
|
|
|
457
457
|
// src/generator/grammar.ts
|
|
458
458
|
import { camelCase, FILE_CATEGORIES, STRING_FORMATS } from "@flaredev/core";
|
|
459
459
|
var SIMPLE_KINDS = ["string", "text", "int", "float", "boolean", "date", "datetime"];
|
|
460
|
-
var FORMAT_ALIASES = { phone: "tel" };
|
|
461
|
-
var
|
|
460
|
+
var FORMAT_ALIASES = { phone: "tel", website: "url" };
|
|
461
|
+
var NUMBER_SHORTHANDS = {
|
|
462
|
+
money: { kind: "float", format: "money" },
|
|
463
|
+
percent: { kind: "float", format: "percent" },
|
|
464
|
+
rating: { kind: "int", format: "rating" }
|
|
465
|
+
};
|
|
466
|
+
var ALL_KINDS = [
|
|
467
|
+
...SIMPLE_KINDS,
|
|
468
|
+
...STRING_FORMATS,
|
|
469
|
+
"phone",
|
|
470
|
+
"website",
|
|
471
|
+
...Object.keys(NUMBER_SHORTHANDS),
|
|
472
|
+
"enum",
|
|
473
|
+
"select",
|
|
474
|
+
"radio",
|
|
475
|
+
"multiselect",
|
|
476
|
+
"file",
|
|
477
|
+
"belongsTo",
|
|
478
|
+
"hasMany"
|
|
479
|
+
];
|
|
462
480
|
var FieldGrammarError = class extends Error {
|
|
463
481
|
constructor(message) {
|
|
464
482
|
super(message);
|
|
@@ -543,6 +561,10 @@ function parseField(spec) {
|
|
|
543
561
|
} else if (STRING_FORMATS.includes(type) || type in FORMAT_ALIASES) {
|
|
544
562
|
field.kind = "string";
|
|
545
563
|
field.format = FORMAT_ALIASES[type] ?? type;
|
|
564
|
+
} else if (type in NUMBER_SHORTHANDS) {
|
|
565
|
+
const shorthand = NUMBER_SHORTHANDS[type];
|
|
566
|
+
field.kind = shorthand.kind;
|
|
567
|
+
field.numberFormat = shorthand.format;
|
|
546
568
|
} else if (match = /^(enum|select|radio|multiselect)\((.*)\)$/.exec(type)) {
|
|
547
569
|
const choice = match[1];
|
|
548
570
|
field.kind = choice === "multiselect" ? "multiselect" : "enum";
|
|
@@ -710,6 +732,7 @@ function options(field) {
|
|
|
710
732
|
if (!field.required) opts.required = false;
|
|
711
733
|
if (field.unique) opts.unique = true;
|
|
712
734
|
if (field.format) opts.format = field.format;
|
|
735
|
+
if (field.numberFormat) opts.format = field.numberFormat;
|
|
713
736
|
if (field.widget) opts.widget = field.widget;
|
|
714
737
|
if (field.kind === "belongsTo" && !field.required) opts.onDelete = "set null";
|
|
715
738
|
if (field.maxBytes !== void 0) opts.maxBytes = field.maxBytes;
|
|
@@ -4095,6 +4118,11 @@ Move what you want to keep, or pass --force to delete anyway.`);
|
|
|
4095
4118
|
These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
|
|
4096
4119
|
}
|
|
4097
4120
|
let migrations = [];
|
|
4121
|
+
if (readStack(appRoot) === "next") {
|
|
4122
|
+
if (entry) log2(`
|
|
4123
|
+
Next: ${pc15.bold("npx prisma migrate dev")} to drop the table.`);
|
|
4124
|
+
return { removed, migrations };
|
|
4125
|
+
}
|
|
4098
4126
|
if (!options2.skipMigration && entry) {
|
|
4099
4127
|
const result = await generateSchemaMigration(appRoot, `drop_${entry.resource.table}`);
|
|
4100
4128
|
migrations = result.files;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.
|
|
44
|
+
"@flaredev/core": "0.8.1"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -122,7 +122,9 @@ export async function exportRecordsAction(resourceName: string, query: string):
|
|
|
122
122
|
const params = new URLSearchParams(query);
|
|
123
123
|
params.set("page", String(page));
|
|
124
124
|
params.set("perPage", String(EXPORT_PAGE));
|
|
125
|
-
|
|
125
|
+
// A bigger page than a request from outside may ask for: this is the server
|
|
126
|
+
// paging through everything on purpose, and 500 round trips would be absurd.
|
|
127
|
+
const result = await store.list(params, { maxPerPage: EXPORT_PAGE });
|
|
126
128
|
if (!result.ok) return result;
|
|
127
129
|
rows.push(...result.data.data);
|
|
128
130
|
if (page >= result.data.meta.totalPages) break;
|
|
@@ -59,7 +59,56 @@ export function FieldWidget(props: WidgetProps) {
|
|
|
59
59
|
return <Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />;
|
|
60
60
|
|
|
61
61
|
case "int":
|
|
62
|
-
case "float":
|
|
62
|
+
case "float": {
|
|
63
|
+
// A rating is a row of stars, not a number to type.
|
|
64
|
+
if (field.format === "rating") {
|
|
65
|
+
const most = field.max ?? 5;
|
|
66
|
+
const score = Number(text) || 0;
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex items-center gap-1" role="radiogroup" aria-label={field.label}>
|
|
69
|
+
{Array.from({ length: most }, (_, index) => index + 1).map((star) => (
|
|
70
|
+
<button
|
|
71
|
+
key={star}
|
|
72
|
+
type="button"
|
|
73
|
+
role="radio"
|
|
74
|
+
aria-checked={score === star}
|
|
75
|
+
aria-label={`${star} of ${most}`}
|
|
76
|
+
disabled={disabled}
|
|
77
|
+
// Clicking the current score clears it, which is the only way to unset one.
|
|
78
|
+
onClick={() => onChange(score === star ? "" : String(star))}
|
|
79
|
+
className="rounded p-0.5 text-lg leading-none text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50 data-[on=true]:text-foreground"
|
|
80
|
+
data-on={star <= score}
|
|
81
|
+
>
|
|
82
|
+
{star <= score ? "★" : "☆"}
|
|
83
|
+
</button>
|
|
84
|
+
))}
|
|
85
|
+
{score > 0 && !disabled && (
|
|
86
|
+
<button type="button" onClick={() => onChange("")} className="ml-1 text-xs text-muted-foreground hover:text-foreground">
|
|
87
|
+
clear
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Money and percent get their unit where the number is, so nobody has to guess.
|
|
95
|
+
if (field.format === "money" || field.format === "percent") {
|
|
96
|
+
return (
|
|
97
|
+
<InputGroup>
|
|
98
|
+
{field.format === "money" && <InputGroupAddon>{field.currency ?? "$"}</InputGroupAddon>}
|
|
99
|
+
<InputGroupInput
|
|
100
|
+
{...common}
|
|
101
|
+
type="text"
|
|
102
|
+
inputMode="decimal"
|
|
103
|
+
className="tabular-nums"
|
|
104
|
+
value={groupDigits(text)}
|
|
105
|
+
onChange={(event) => onChange(ungroup(event.target.value, true))}
|
|
106
|
+
/>
|
|
107
|
+
{field.format === "percent" && <InputGroupAddon align="inline-end">%</InputGroupAddon>}
|
|
108
|
+
</InputGroup>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
63
112
|
// Shown grouped (2,000) and stored plain (2000), so a price can be read at a glance
|
|
64
113
|
// and nothing downstream has to strip a comma back out.
|
|
65
114
|
return (
|
|
@@ -72,6 +121,7 @@ export function FieldWidget(props: WidgetProps) {
|
|
|
72
121
|
onChange={(event) => onChange(ungroup(event.target.value, field.kind === "float"))}
|
|
73
122
|
/>
|
|
74
123
|
);
|
|
124
|
+
}
|
|
75
125
|
|
|
76
126
|
case "boolean":
|
|
77
127
|
return (
|
|
@@ -189,6 +239,37 @@ export function FieldWidget(props: WidgetProps) {
|
|
|
189
239
|
return <Input {...common} type="email" autoComplete="email" value={text} onChange={(event) => onChange(event.target.value)} />;
|
|
190
240
|
case "url":
|
|
191
241
|
return <Input {...common} type="url" placeholder={field.placeholder ?? "https://"} value={text} onChange={(event) => onChange(event.target.value)} />;
|
|
242
|
+
case "username":
|
|
243
|
+
return (
|
|
244
|
+
<InputGroup>
|
|
245
|
+
<InputGroupAddon>@</InputGroupAddon>
|
|
246
|
+
<InputGroupInput {...common} autoCapitalize="none" spellCheck={false} placeholder={field.placeholder ?? "ada_lovelace"} value={text} onChange={(event) => onChange(event.target.value)} />
|
|
247
|
+
</InputGroup>
|
|
248
|
+
);
|
|
249
|
+
case "ip":
|
|
250
|
+
return <Input {...common} inputMode="numeric" spellCheck={false} className="font-mono" placeholder={field.placeholder ?? "192.168.1.1"} value={text} onChange={(event) => onChange(event.target.value)} />;
|
|
251
|
+
case "uuid":
|
|
252
|
+
return <Input {...common} spellCheck={false} className="font-mono" placeholder={field.placeholder ?? "3f2504e0-4f89-11d3-9a0c-0305e82c3301"} value={text} onChange={(event) => onChange(event.target.value)} />;
|
|
253
|
+
case "timezone":
|
|
254
|
+
// The browser knows every zone; no list to ship or keep current.
|
|
255
|
+
return (
|
|
256
|
+
<>
|
|
257
|
+
<Input {...common} list={`${common.id}-zones`} spellCheck={false} placeholder={field.placeholder ?? "Africa/Kampala"} value={text} onChange={(event) => onChange(event.target.value)} />
|
|
258
|
+
<datalist id={`${common.id}-zones`}>
|
|
259
|
+
{(Intl.supportedValuesOf?.("timeZone") ?? []).map((zone) => (
|
|
260
|
+
<option key={zone} value={zone} />
|
|
261
|
+
))}
|
|
262
|
+
</datalist>
|
|
263
|
+
</>
|
|
264
|
+
);
|
|
265
|
+
case "currency":
|
|
266
|
+
return (
|
|
267
|
+
<Input {...common} spellCheck={false} maxLength={3} className="font-mono uppercase" placeholder={field.placeholder ?? "UGX"} value={text} onChange={(event) => onChange(event.target.value.toUpperCase())} />
|
|
268
|
+
);
|
|
269
|
+
case "locale":
|
|
270
|
+
return <Input {...common} spellCheck={false} className="font-mono" placeholder={field.placeholder ?? "en-GB"} value={text} onChange={(event) => onChange(event.target.value)} />;
|
|
271
|
+
case "postcode":
|
|
272
|
+
return <Input {...common} autoCapitalize="characters" spellCheck={false} placeholder={field.placeholder ?? "SW1A 1AA"} value={text} onChange={(event) => onChange(event.target.value.toUpperCase())} />;
|
|
192
273
|
default:
|
|
193
274
|
return (
|
|
194
275
|
<WithSuggestion suggest={suggest} disabled={disabled} onChange={onChange}>
|
|
@@ -93,7 +93,19 @@ function coerce(def: StoredField, raw: string): string | number | boolean | null
|
|
|
93
93
|
* resource. Only fields the descriptor marks sortable/filterable/searchable are
|
|
94
94
|
* accepted, so clients can't probe arbitrary columns.
|
|
95
95
|
*/
|
|
96
|
-
export function parseListQuery(
|
|
96
|
+
export function parseListQuery(
|
|
97
|
+
resource: Resource,
|
|
98
|
+
params: URLSearchParams,
|
|
99
|
+
/**
|
|
100
|
+
* The largest page this caller may ask for.
|
|
101
|
+
*
|
|
102
|
+
* The cap exists to stop a request from the outside asking for ten thousand rows at
|
|
103
|
+
* once. Server-side callers that page through everything deliberately — the CSV
|
|
104
|
+
* export — pass their own, which is why this is an argument rather than a constant
|
|
105
|
+
* everything obeys.
|
|
106
|
+
*/
|
|
107
|
+
options: { maxPerPage?: number } = {},
|
|
108
|
+
): { query: ListQuery } | { issues: QueryIssue[] } {
|
|
97
109
|
const issues: QueryIssue[] = [];
|
|
98
110
|
const fields = new Map(storedFields(resource));
|
|
99
111
|
|
|
@@ -109,7 +121,7 @@ export function parseListQuery(resource: Resource, params: URLSearchParams): { q
|
|
|
109
121
|
};
|
|
110
122
|
|
|
111
123
|
const page = positiveInt("page", 1);
|
|
112
|
-
const perPage = positiveInt("perPage", resource.perPage, MAX_PER_PAGE);
|
|
124
|
+
const perPage = positiveInt("perPage", resource.perPage, options.maxPerPage ?? MAX_PER_PAGE);
|
|
113
125
|
|
|
114
126
|
let sort = resource.defaultSort;
|
|
115
127
|
const rawSort = params.get("sort");
|
|
@@ -206,8 +206,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
|
|
|
206
206
|
resource,
|
|
207
207
|
|
|
208
208
|
/** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
|
|
209
|
-
async list(params: URLSearchParams): Promise<Result<ListResult>> {
|
|
210
|
-
const parsed = parseListQuery(resource, params);
|
|
209
|
+
async list(params: URLSearchParams, options: { maxPerPage?: number } = {}): Promise<Result<ListResult>> {
|
|
210
|
+
const parsed = parseListQuery(resource, params, options);
|
|
211
211
|
if ("issues" in parsed) return fail(400, "Invalid query.", { queryIssues: parsed.issues });
|
|
212
212
|
const { page, perPage, sort, q, filters, cursor } = parsed.query;
|
|
213
213
|
|
|
@@ -120,7 +120,9 @@ export async function exportRecordsAction(resourceName: string, query: string):
|
|
|
120
120
|
const params = new URLSearchParams(query);
|
|
121
121
|
params.set("page", String(page));
|
|
122
122
|
params.set("perPage", String(EXPORT_PAGE));
|
|
123
|
-
|
|
123
|
+
// A bigger page than a request from outside may ask for: this is the server
|
|
124
|
+
// paging through everything on purpose, and 500 round trips would be absurd.
|
|
125
|
+
const result = await store.list(params, { maxPerPage: EXPORT_PAGE });
|
|
124
126
|
if (!result.ok) return result;
|
|
125
127
|
rows.push(...result.data.data);
|
|
126
128
|
if (page >= result.data.meta.totalPages) break;
|