@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.
|
|
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.
|
|
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?: {
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
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 } }] }];
|