@kematjaya/crud-ui-generator 0.3.0 → 0.4.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/README.md +20 -11
- package/dist/cli.js +9 -3
- package/dist/naming.js +10 -6
- package/dist/spec.js +1 -1
- package/dist/templates/dateLib.js +25 -0
- package/dist/templates/form.js +42 -16
- package/dist/templates/sharedComponents.js +4 -6
- package/dist/templates/table.js +15 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,22 +56,31 @@ Appended to (multi-entity, idempotent — each entity gets one marker-guarded bl
|
|
|
56
56
|
## Assumptions / caveats
|
|
57
57
|
|
|
58
58
|
- **Id type comes from the spec's `idType`** (`uuid`/`int`/`string`, written by `ApiCrudRenderer::detectIdType()` off the entity's actual id column) — `validId()` in the generated `[id]/route.ts` and the `is{Entity}` type guard in `api-shapes.ts` are generated to match. Older spec files without `idType` default to `uuid`.
|
|
59
|
-
- **The list filter panel covers every `searchable` field except `textarea
|
|
60
|
-
`naming.ts`'s `filterableFields()` renders one control per
|
|
61
|
-
(ApiPlatform `SearchFilter` `'partial'`/`'exact'` strategy) and a boolean
|
|
62
|
-
(`'exact'`) — all AND-combined into the same list query (one query param per
|
|
63
|
-
by a single `#[ApiFilter(SearchFilter::class, properties: [...])]` on the
|
|
64
|
-
collapsible Filter button toggles a `crud-filter-collapse` panel around
|
|
65
|
-
`FilterPanel` component, and only appears when at least one field is filterable.
|
|
66
|
-
`searchable`
|
|
67
|
-
|
|
59
|
+
- **The list filter panel covers every `searchable` field except `textarea`, `date`, and
|
|
60
|
+
`datetime`.** `naming.ts`'s `filterableFields()` renders one control per remaining field —
|
|
61
|
+
text/number inputs (ApiPlatform `SearchFilter` `'partial'`/`'exact'` strategy) and a boolean
|
|
62
|
+
Any/Yes/No select (`'exact'`) — all AND-combined into the same list query (one query param per
|
|
63
|
+
property, backed by a single `#[ApiFilter(SearchFilter::class, properties: [...])]` on the
|
|
64
|
+
entity). The table's collapsible Filter button toggles a `crud-filter-collapse` panel around
|
|
65
|
+
the generated `FilterPanel` component, and only appears when at least one field is filterable.
|
|
66
|
+
Excluded `searchable` fields get a `cli.ts` next-steps note — no date-range filter control
|
|
67
|
+
exists yet (a future iteration), and long text isn't a sensible filter input.
|
|
68
68
|
- **CSV export ("Export all") only reflects the first filterable field's current value**, sent as
|
|
69
69
|
a single OR-search `search` param the backend matches across *every* `searchable` field
|
|
70
70
|
(textarea included) server-side — a separate, broader contract from the list's precise
|
|
71
71
|
AND-per-property filtering. The other filter panel fields don't currently narrow the export.
|
|
72
|
+
- **`date`/`datetime` fields are real form fields and table/CSV columns.** Backed by
|
|
73
|
+
`ui-kit`'s `DateField`/`DateTimeField` (native `<input type="date">`/`type="datetime-local">`).
|
|
74
|
+
`date` fields round-trip as plain `"Y-m-d"` strings — the backend maker writes a
|
|
75
|
+
`config/serializer/{Entity}.yaml` Symfony serializer mapping pinning that format (otherwise
|
|
76
|
+
Symfony's default `DateTimeNormalizer` would emit a full RFC3339 datetime with a spurious
|
|
77
|
+
time/timezone for a date-only column). `datetime` fields keep the default RFC3339 wire format;
|
|
78
|
+
the generated form converts to/from `<input type="datetime-local">`'s local-wall-clock string
|
|
79
|
+
via `src/lib/datetime.ts` (written once, shared across entities) at the `reset()`/`submit()`
|
|
80
|
+
boundaries — the `Date` constructor's local-time parsing makes this conversion timezone-safe.
|
|
72
81
|
- **The table/CSV export skip `textarea` fields** (long text), mirroring the hand-written Notes
|
|
73
|
-
feature (shows `title`, not `body`). Everything else (`text`/`number`/`boolean
|
|
74
|
-
column.
|
|
82
|
+
feature (shows `title`, not `body`). Everything else (`text`/`number`/`boolean`/`date`/
|
|
83
|
+
`datetime`) becomes a column.
|
|
75
84
|
- **Getters are assumed on the entity/generated types** in the conventional `get{Field}()` /
|
|
76
85
|
camelCase-property shape used throughout this boilerplate.
|
|
77
86
|
- **`npm run api:types` must be run first** (after adding the backend's `#[ApiResource]`/
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ import { apiShapesBlock, apiShapesFileHeader, apiShapesImport, apiShapesMarker }
|
|
|
15
15
|
import { schemasBlock, schemasFileHeader, schemasMarker } from './templates/schemas.js';
|
|
16
16
|
import { typesApiBlock, typesApiFileHeader, typesApiMarker } from './templates/typesApi.js';
|
|
17
17
|
import { exportRoute, itemRoute, listRoute } from './templates/bffRoutes.js';
|
|
18
|
+
import { dateLib } from './templates/dateLib.js';
|
|
18
19
|
function parseArgs(argv) {
|
|
19
20
|
const positional = [];
|
|
20
21
|
let srcDir = 'src';
|
|
@@ -86,6 +87,9 @@ function main() {
|
|
|
86
87
|
const libDir = join(src, 'lib');
|
|
87
88
|
writeNewFile(join(libDir, `${names.entitiesKebab}-query.ts`), queryLib(spec, names), log);
|
|
88
89
|
writeNewFile(join(libDir, `${names.entitiesKebab}-csv.ts`), csvLib(spec, names), log);
|
|
90
|
+
if (spec.fields.some((f) => f.type === 'datetime')) {
|
|
91
|
+
writeIfMissing(join(libDir, 'datetime.ts'), dateLib, log);
|
|
92
|
+
}
|
|
89
93
|
appendBlockWithImport(join(libDir, 'api-shapes.ts'), apiShapesFileHeader, apiShapesImport(names), apiShapesMarker(names.entityPascal), apiShapesBlock(spec, names), log);
|
|
90
94
|
appendBlock(join(libDir, 'schemas.ts'), schemasFileHeader, schemasMarker(names.entityCamel), schemasBlock(spec, names), log);
|
|
91
95
|
appendBlock(join(src, 'types', 'api.ts'), typesApiFileHeader, typesApiMarker(names.entityPascal), typesApiBlock(spec, names), log);
|
|
@@ -99,9 +103,11 @@ function main() {
|
|
|
99
103
|
console.log(` 1. Run the backend maker's printed next-steps (ApiResource/ApiFilter attributes, permission keys, rate limiter config).`);
|
|
100
104
|
console.log(` 2. Run "npm run api:types" in the frontend project so src/types/api.ts's paths/components lookups resolve.`);
|
|
101
105
|
console.log(` 3. Confirm the OpenAPI collection path is "/api/${names.entitiesKebab}" — if the entity's #[ApiResource] uses a custom uriTemplate, fix the "paths[...]" lookups in the appended src/types/api.ts block by hand.`);
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
const excludedFilterFields = spec.fields
|
|
107
|
+
.filter((f) => f.searchable && ['textarea', 'date', 'datetime'].includes(f.type))
|
|
108
|
+
.map((f) => f.name);
|
|
109
|
+
if (excludedFilterFields.length > 0) {
|
|
110
|
+
console.log(` 4. Note: "${excludedFilterFields.join(', ')}" ${excludedFilterFields.length > 1 ? 'are' : 'is'} marked searchable but excluded from the generated Filter panel (long text and date/datetime don't have a filter control yet) — long-text fields are also excluded from table/CSV columns.`);
|
|
105
111
|
}
|
|
106
112
|
console.log(` Id type: "${spec.idType}" (from the spec's "idType") — validId() in the generated app/api/${names.entitiesKebab}/[id]/route.ts was generated to match.`);
|
|
107
113
|
console.log(` Run "npm run format" afterwards — generated files aren't pre-formatted to this project's Prettier config.`);
|
package/dist/naming.js
CHANGED
|
@@ -13,16 +13,20 @@ export function namesFromSpec(spec) {
|
|
|
13
13
|
const entitiesCamel = entitiesPascal.charAt(0).toLowerCase() + entitiesPascal.slice(1);
|
|
14
14
|
return { entityPascal, entityCamel, entitiesKebab, entitiesPascal, entitiesCamel };
|
|
15
15
|
}
|
|
16
|
+
/** Field types with no filter-panel control yet — excluded from `filterableFields()`. */
|
|
17
|
+
const UNFILTERABLE_TYPES = ['textarea', 'date', 'datetime'];
|
|
16
18
|
/**
|
|
17
19
|
* Fields rendered as controls in the list view's Filter panel: every `searchable` field except
|
|
18
|
-
* `textarea` (long text doesn't make a sensible filter input)
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* `textarea` (long text doesn't make a sensible filter input) and `date`/`datetime` (no
|
|
21
|
+
* date-range filter control exists yet — a future iteration), mirroring `displayFields()`'s
|
|
22
|
+
* `textarea` exclusion plus this narrower one. All of these are wired into the list query and
|
|
23
|
+
* AND-combined. The CSV export endpoint's OR-search (against every `searchable` field, textarea
|
|
24
|
+
* included) is a separate, server-side-only concept — it reuses this list's first filterable
|
|
25
|
+
* field's current value as its convenience search text (see `useExport.ts` / `bffRoutes.ts`'s
|
|
26
|
+
* `exportRoute`).
|
|
23
27
|
*/
|
|
24
28
|
export function filterableFields(spec) {
|
|
25
|
-
return spec.fields.filter((f) => f.searchable && f.type
|
|
29
|
+
return spec.fields.filter((f) => f.searchable && !UNFILTERABLE_TYPES.includes(f.type));
|
|
26
30
|
}
|
|
27
31
|
/** "createdAt" -> "Created At", "TestArticle" -> "Test Article". */
|
|
28
32
|
export function humanize(identifier) {
|
package/dist/spec.js
CHANGED
|
@@ -4,7 +4,7 @@ function isFieldSpec(value) {
|
|
|
4
4
|
return false;
|
|
5
5
|
const f = value;
|
|
6
6
|
return (typeof f.name === 'string' &&
|
|
7
|
-
['text', 'textarea', 'number', 'boolean'].includes(f.type) &&
|
|
7
|
+
['text', 'textarea', 'number', 'boolean', 'date', 'datetime'].includes(f.type) &&
|
|
8
8
|
typeof f.required === 'boolean' &&
|
|
9
9
|
(f.maxLength === null || typeof f.maxLength === 'number') &&
|
|
10
10
|
typeof f.searchable === 'boolean');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Written once (via `writeIfMissing`, shared across every generated entity that has a
|
|
3
|
+
* `datetime` field) into `src/lib/datetime.ts` — converts between the API's RFC3339 wire value
|
|
4
|
+
* (`2026-09-03T14:30:00+07:00`) and `<input type="datetime-local">`'s native value
|
|
5
|
+
* (`2026-09-03T14:30`, local wall-clock, no offset). Both directions go through the `Date`
|
|
6
|
+
* constructor, which parses a string with no offset suffix as LOCAL time per spec — that makes
|
|
7
|
+
* `fromDatetimeLocalValue`'s `.toISOString()` call a correct, timezone-safe local -> UTC
|
|
8
|
+
* conversion, not a naive string transform.
|
|
9
|
+
*/
|
|
10
|
+
export const dateLib = `function pad(n: number): string {
|
|
11
|
+
return String(n).padStart(2, '0');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function toDatetimeLocalValue(iso: string): string {
|
|
15
|
+
const date = new Date(iso);
|
|
16
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
17
|
+
return \`\${date.getFullYear()}-\${pad(date.getMonth() + 1)}-\${pad(date.getDate())}T\${pad(date.getHours())}:\${pad(date.getMinutes())}\`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fromDatetimeLocalValue(local: string): string {
|
|
21
|
+
const date = new Date(local);
|
|
22
|
+
if (Number.isNaN(date.getTime())) return local;
|
|
23
|
+
return date.toISOString();
|
|
24
|
+
}
|
|
25
|
+
`;
|
package/dist/templates/form.js
CHANGED
|
@@ -13,17 +13,12 @@ function fieldMarkup(field, entitiesKebab, autoFocus) {
|
|
|
13
13
|
/>`;
|
|
14
14
|
}
|
|
15
15
|
if (field.type === 'boolean') {
|
|
16
|
-
return ` <
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
/>
|
|
23
|
-
<label htmlFor="${id}" className="form-check-label">
|
|
24
|
-
${label}
|
|
25
|
-
</label>
|
|
26
|
-
</div>`;
|
|
16
|
+
return ` <CheckboxField
|
|
17
|
+
id="${id}"
|
|
18
|
+
label="${label}"
|
|
19
|
+
error={errors.${field.name}}
|
|
20
|
+
registration={register('${field.name}')}
|
|
21
|
+
/>`;
|
|
27
22
|
}
|
|
28
23
|
if (field.type === 'number') {
|
|
29
24
|
return ` <TextField
|
|
@@ -34,6 +29,22 @@ function fieldMarkup(field, entitiesKebab, autoFocus) {
|
|
|
34
29
|
registration={register('${field.name}', { valueAsNumber: true })}
|
|
35
30
|
/>`;
|
|
36
31
|
}
|
|
32
|
+
if (field.type === 'date') {
|
|
33
|
+
return ` <DateField
|
|
34
|
+
id="${id}"
|
|
35
|
+
label="${label}"${autoFocusProp}
|
|
36
|
+
error={errors.${field.name}}
|
|
37
|
+
registration={register('${field.name}')}
|
|
38
|
+
/>`;
|
|
39
|
+
}
|
|
40
|
+
if (field.type === 'datetime') {
|
|
41
|
+
return ` <DateTimeField
|
|
42
|
+
id="${id}"
|
|
43
|
+
label="${label}"${autoFocusProp}
|
|
44
|
+
error={errors.${field.name}}
|
|
45
|
+
registration={register('${field.name}')}
|
|
46
|
+
/>`;
|
|
47
|
+
}
|
|
37
48
|
return ` <TextField
|
|
38
49
|
id="${id}"
|
|
39
50
|
label="${label}"
|
|
@@ -47,13 +58,28 @@ export function form(spec, names) {
|
|
|
47
58
|
const { entityPascal, entityCamel, entitiesKebab } = names;
|
|
48
59
|
const usesTextarea = spec.fields.some((f) => f.type === 'textarea');
|
|
49
60
|
const usesText = spec.fields.some((f) => f.type === 'text' || f.type === 'number');
|
|
50
|
-
const
|
|
61
|
+
const usesBoolean = spec.fields.some((f) => f.type === 'boolean');
|
|
62
|
+
const usesDate = spec.fields.some((f) => f.type === 'date');
|
|
63
|
+
const usesDateTime = spec.fields.some((f) => f.type === 'datetime');
|
|
64
|
+
const fieldComponents = [
|
|
65
|
+
usesText ? 'TextField' : null,
|
|
66
|
+
usesTextarea ? 'TextareaField' : null,
|
|
67
|
+
usesBoolean ? 'CheckboxField' : null,
|
|
68
|
+
usesDate ? 'DateField' : null,
|
|
69
|
+
usesDateTime ? 'DateTimeField' : null
|
|
70
|
+
]
|
|
51
71
|
.filter((c) => c !== null)
|
|
52
72
|
.join(', ');
|
|
53
73
|
const fieldsMarkup = spec.fields
|
|
54
74
|
.map((f, i) => fieldMarkup(f, entitiesKebab, i === 0))
|
|
55
75
|
.join('\n');
|
|
56
|
-
const resetFields = spec.fields
|
|
76
|
+
const resetFields = spec.fields
|
|
77
|
+
.map((f) => (f.type === 'datetime' ? `${f.name}: toDatetimeLocalValue(data.${f.name})` : `${f.name}: data.${f.name}`))
|
|
78
|
+
.join(', ');
|
|
79
|
+
const submitFields = spec.fields
|
|
80
|
+
.map((f) => (f.type === 'datetime' ? `${f.name}: fromDatetimeLocalValue(values.${f.name})` : null))
|
|
81
|
+
.filter((c) => c !== null)
|
|
82
|
+
.join(', ');
|
|
57
83
|
return `'use client';
|
|
58
84
|
|
|
59
85
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
@@ -62,7 +88,7 @@ import { useRouter } from 'next/navigation';
|
|
|
62
88
|
import { useEffect, useState } from 'react';
|
|
63
89
|
import { useForm } from 'react-hook-form';
|
|
64
90
|
import { is${entityPascal} } from '@/lib/api-shapes';
|
|
65
|
-
import { ${entityCamel}Schema, type ${entityPascal}FormValues } from '@/lib/schemas';
|
|
91
|
+
import { ${entityCamel}Schema, type ${entityPascal}FormValues } from '@/lib/schemas';${usesDateTime ? "\nimport { toDatetimeLocalValue, fromDatetimeLocalValue } from '@/lib/datetime';" : ''}
|
|
66
92
|
|
|
67
93
|
type Props = {
|
|
68
94
|
mode: 'create' | 'edit';
|
|
@@ -126,7 +152,7 @@ export function ${entityPascal}Form({ mode, ${entityCamel}Id }: Props) {
|
|
|
126
152
|
response = await fetch(path, {
|
|
127
153
|
method: mode === 'edit' ? 'PATCH' : 'POST',
|
|
128
154
|
headers: { 'content-type': 'application/json' },
|
|
129
|
-
body: JSON.stringify(values)
|
|
155
|
+
body: JSON.stringify(${usesDateTime ? `{ ...values, ${submitFields} }` : 'values'})
|
|
130
156
|
});
|
|
131
157
|
} catch {
|
|
132
158
|
setError('Network error while saving. Check your connection and try again.');
|
|
@@ -148,7 +174,7 @@ export function ${entityPascal}Form({ mode, ${entityCamel}Id }: Props) {
|
|
|
148
174
|
<ListPageCard
|
|
149
175
|
title={mode === 'edit' ? 'Edit' : 'New'}
|
|
150
176
|
error={error}
|
|
151
|
-
style={{
|
|
177
|
+
style={{ width: '100%' }}
|
|
152
178
|
>
|
|
153
179
|
<form onSubmit={handleSubmit(submit)} noValidate>
|
|
154
180
|
${fieldsMarkup}
|
|
@@ -418,7 +418,7 @@ export function SearchPanel({
|
|
|
418
418
|
}
|
|
419
419
|
`;
|
|
420
420
|
export const filterPanel = `import { useId } from 'react';
|
|
421
|
-
import { Button } from '@kematjaya/bootstrap-ui-kit';
|
|
421
|
+
import { Button, Input, Select } from '@kematjaya/bootstrap-ui-kit';
|
|
422
422
|
|
|
423
423
|
type FilterFieldDef =
|
|
424
424
|
| { name: string; label: string; kind: 'text' }
|
|
@@ -482,9 +482,8 @@ export function FilterPanel({
|
|
|
482
482
|
{field.label}
|
|
483
483
|
</label>
|
|
484
484
|
{field.kind === 'boolean' ? (
|
|
485
|
-
<
|
|
485
|
+
<Select
|
|
486
486
|
id={\`\${idPrefix}-\${field.name}\`}
|
|
487
|
-
className="form-select"
|
|
488
487
|
name={field.name}
|
|
489
488
|
defaultValue={values[field.name] ?? ''}
|
|
490
489
|
disabled={controlsDisabled}
|
|
@@ -492,12 +491,11 @@ export function FilterPanel({
|
|
|
492
491
|
<option value="">Any</option>
|
|
493
492
|
<option value="true">Yes</option>
|
|
494
493
|
<option value="false">No</option>
|
|
495
|
-
</
|
|
494
|
+
</Select>
|
|
496
495
|
) : (
|
|
497
|
-
<
|
|
496
|
+
<Input
|
|
498
497
|
id={\`\${idPrefix}-\${field.name}\`}
|
|
499
498
|
type={field.kind === 'number' ? 'number' : 'text'}
|
|
500
|
-
className="form-control"
|
|
501
499
|
name={field.name}
|
|
502
500
|
defaultValue={values[field.name] ?? ''}
|
|
503
501
|
placeholder={field.label}
|
package/dist/templates/table.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { displayFields, filterableFields, humanize, labelField, lowerWords } from '../naming.js';
|
|
2
|
-
function columnCell(fieldName, timestampField) {
|
|
3
|
-
if (fieldName === timestampField) {
|
|
2
|
+
function columnCell(fieldName, fieldType, timestampField) {
|
|
3
|
+
if (fieldName === timestampField || fieldType === 'datetime') {
|
|
4
4
|
return ` <td>
|
|
5
5
|
{new Date(
|
|
6
6
|
item.${fieldName}
|
|
7
7
|
).toLocaleString()}
|
|
8
8
|
</td>`;
|
|
9
9
|
}
|
|
10
|
+
if (fieldType === 'date') {
|
|
11
|
+
// "YYYY-MM-DD" alone parses as UTC midnight per spec (wrong day in timezones behind
|
|
12
|
+
// UTC); appending a time forces local-time parsing instead.
|
|
13
|
+
return ` <td>
|
|
14
|
+
{new Date(
|
|
15
|
+
\`\${item.${fieldName}}T00:00:00\`
|
|
16
|
+
).toLocaleDateString()}
|
|
17
|
+
</td>`;
|
|
18
|
+
}
|
|
10
19
|
return ` <td>{String(item.${fieldName})}</td>`;
|
|
11
20
|
}
|
|
12
21
|
function filterFieldKind(type) {
|
|
@@ -23,6 +32,7 @@ export function table(spec, names) {
|
|
|
23
32
|
if (null !== spec.timestampField && !cols.includes(spec.timestampField)) {
|
|
24
33
|
cols.push(spec.timestampField);
|
|
25
34
|
}
|
|
35
|
+
const fieldTypeByName = new Map(spec.fields.map((f) => [f.name, f.type]));
|
|
26
36
|
const label = labelField(spec);
|
|
27
37
|
const noun = lowerWords(entityPascal);
|
|
28
38
|
const pluralNoun = lowerWords(entitiesPascal);
|
|
@@ -31,7 +41,9 @@ export function table(spec, names) {
|
|
|
31
41
|
const hasFilter = filters.length > 0;
|
|
32
42
|
const filterPanelId = `${noun}-filter-panel`;
|
|
33
43
|
const headCells = cols.map((c) => ` <th>${humanize(c)}</th>`).join('\n');
|
|
34
|
-
const bodyCells = cols
|
|
44
|
+
const bodyCells = cols
|
|
45
|
+
.map((c) => columnCell(c, fieldTypeByName.get(c) ?? null, spec.timestampField))
|
|
46
|
+
.join('\n');
|
|
35
47
|
const filterButtonBlock = hasFilter
|
|
36
48
|
? ` <Button
|
|
37
49
|
variant="outline"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kematjaya/crud-ui-generator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Next.js CRUD frontend generator that reads crud-specs/{Entity}.json sidecars written by kematjaya/crud-maker-bundle's make:kmj-api-crud and generates pages, components, and BFF routes matching the boilerplate's hand-written Notes feature.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|