@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 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 ALL_KINDS = [...SIMPLE_KINDS, ...STRING_FORMATS, "phone", "enum", "select", "radio", "multiselect", "file", "belongsTo", "hasMany"];
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.7.3",
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.7.3"
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
- const result = await store.list(params);
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(resource: Resource, params: URLSearchParams): { query: ListQuery } | { issues: QueryIssue[] } {
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
- const result = await store.list(params);
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;