@flaredev/cli 0.7.2 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.7.2",
3
+ "version": "0.8.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.7.2"
44
+ "@flaredev/core": "0.8.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22"
@@ -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}>
@@ -36,7 +36,20 @@ export interface RowsQuery {
36
36
  * `(sort field, id)` together so rows sharing a sort value are neither skipped nor
37
37
  * repeated.
38
38
  */
39
- cursor?: { field: string; value: unknown; id: string; greaterThan: boolean };
39
+ cursor?: {
40
+ field: string;
41
+ value: unknown;
42
+ id: string;
43
+ greaterThan: boolean;
44
+ /**
45
+ * Whether the sort column holds a date.
46
+ *
47
+ * A cursor travels through a URL, so a Date arrives back as a number. SQLite stores
48
+ * dates as milliseconds and wants that number; Postgres wants a Date. Neither
49
+ * adapter can tell which a column is, and the store can — so it says.
50
+ */
51
+ isDate?: boolean;
52
+ };
40
53
  limit: number;
41
54
  /** Offset paging, for the page-number case. Never combined with a cursor. */
42
55
  offset?: number;
@@ -42,7 +42,7 @@ export type Result<T> = { ok: true; data: T } | Failure;
42
42
  */
43
43
  export const COUNT_LIMIT = 10_000;
44
44
 
45
- /** Dates are stored as milliseconds, and a cursor has to compare the way the column does. */
45
+ /** A cursor is JSON in a URL, so a Date has to cross it as a number. */
46
46
  function toCursorValue(value: unknown): string | number | boolean | null {
47
47
  if (value instanceof Date) return value.getTime();
48
48
  if (value === null || value === undefined) return null;
@@ -211,6 +211,16 @@ export function createResourceStore(options: ResourceStoreOptions) {
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
 
214
+ /**
215
+ * Whether a sort column holds a date, so the adapter can shape the cursor for it.
216
+ * The timestamps every row has, plus any date or datetime field.
217
+ */
218
+ const isDateField = (key: string) => {
219
+ if (key === "createdAt" || key === "updatedAt") return true;
220
+ const def = fields.find(([name]) => name === key)?.[1];
221
+ return def?.kind === "date" || def?.kind === "datetime";
222
+ };
223
+
214
224
  const searchable = fields.filter(([, def]) => isSearchable(def)).map(([key]) => key);
215
225
  const matching = { search: q ? { term: q, fields: searchable } : undefined, filters };
216
226
 
@@ -224,7 +234,9 @@ export function createResourceStore(options: ResourceStoreOptions) {
224
234
  rows.find({
225
235
  ...matching,
226
236
  sort: { field: sort.field, direction: descending ? "desc" : "asc" },
227
- cursor: cursor ? { field: sort.field, value: cursor.value, id: cursor.id, greaterThan: !descending } : undefined,
237
+ cursor: cursor
238
+ ? { field: sort.field, value: cursor.value, id: cursor.id, greaterThan: !descending, isDate: isDateField(sort.field) }
239
+ : undefined,
228
240
  limit: perPage + 1,
229
241
  offset: cursor ? undefined : (page - 1) * perPage,
230
242
  }),
@@ -97,8 +97,12 @@ export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceR
97
97
  async find(query) {
98
98
  const where = matching(query);
99
99
  if (query.cursor) {
100
- const { field, value, id, greaterThan } = query.cursor;
100
+ const { field, id, greaterThan, isDate } = query.cursor;
101
101
  const op = greaterThan ? "gt" : "lt";
102
+ // Postgres compares a timestamp against a Date, not the number the cursor
103
+ // carried. Without this, paging past the first page of a date-sorted list fails
104
+ // with "Expected DateTime, provided Int".
105
+ const value = isDate && (typeof query.cursor.value === "number" || typeof query.cursor.value === "string") ? new Date(query.cursor.value) : query.cursor.value;
102
106
  // (sort, id) as one comparison, so rows sharing a sort value are neither skipped
103
107
  // nor repeated. AND-ed with the filters rather than replacing them.
104
108
  const keyset = [{ [field]: { [op]: value } }, { AND: [{ [field]: value }, { id: { [op]: id } }] }];