@kematjaya/crud-ui-generator 0.1.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.
@@ -0,0 +1,172 @@
1
+ import { humanize } from '../naming.js';
2
+ function fieldMarkup(field, entitiesKebab, autoFocus) {
3
+ const label = humanize(field.name);
4
+ const id = `${entitiesKebab}-${field.name}`;
5
+ const autoFocusProp = autoFocus ? '\n autoFocus={mode === \'create\'}' : '';
6
+ if (field.type === 'textarea') {
7
+ return ` <TextareaField
8
+ id="${id}"
9
+ label="${label}"
10
+ rows={6}${field.maxLength !== null ? `\n maxLength={${field.maxLength}}` : ''}
11
+ error={errors.${field.name}}
12
+ registration={register('${field.name}')}
13
+ />`;
14
+ }
15
+ if (field.type === 'boolean') {
16
+ return ` <div className="form-check mb-3">
17
+ <input
18
+ id="${id}"
19
+ type="checkbox"
20
+ className="form-check-input"
21
+ {...register('${field.name}')}
22
+ />
23
+ <label htmlFor="${id}" className="form-check-label">
24
+ ${label}
25
+ </label>
26
+ </div>`;
27
+ }
28
+ if (field.type === 'number') {
29
+ return ` <TextField
30
+ id="${id}"
31
+ label="${label}"
32
+ type="number"${autoFocusProp}
33
+ error={errors.${field.name}}
34
+ registration={register('${field.name}', { valueAsNumber: true })}
35
+ />`;
36
+ }
37
+ return ` <TextField
38
+ id="${id}"
39
+ label="${label}"
40
+ type="text"${field.maxLength !== null ? `\n maxLength={${field.maxLength}}` : ''}
41
+ autoComplete="off"${autoFocusProp}
42
+ error={errors.${field.name}}
43
+ registration={register('${field.name}')}
44
+ />`;
45
+ }
46
+ export function form(spec, names) {
47
+ const { entityPascal, entityCamel, entitiesKebab } = names;
48
+ const usesTextarea = spec.fields.some((f) => f.type === 'textarea');
49
+ const usesText = spec.fields.some((f) => f.type === 'text' || f.type === 'number');
50
+ const fieldComponents = [usesText ? 'TextField' : null, usesTextarea ? 'TextareaField' : null]
51
+ .filter((c) => c !== null)
52
+ .join(', ');
53
+ const fieldsMarkup = spec.fields
54
+ .map((f, i) => fieldMarkup(f, entitiesKebab, i === 0))
55
+ .join('\n');
56
+ const resetFields = spec.fields.map((f) => `${f.name}: data.${f.name}`).join(', ');
57
+ return `'use client';
58
+
59
+ import { zodResolver } from '@hookform/resolvers/zod';
60
+ import { Button, ListPageCard${fieldComponents ? `, ${fieldComponents}` : ''} } from '@kematjaya/bootstrap-ui-kit';
61
+ import { useRouter } from 'next/navigation';
62
+ import { useEffect, useState } from 'react';
63
+ import { useForm } from 'react-hook-form';
64
+ import { is${entityPascal} } from '@/lib/api-shapes';
65
+ import { ${entityCamel}Schema, type ${entityPascal}FormValues } from '@/lib/schemas';
66
+
67
+ type Props = {
68
+ mode: 'create' | 'edit';
69
+ ${entityCamel}Id?: string;
70
+ };
71
+
72
+ export function ${entityPascal}Form({ mode, ${entityCamel}Id }: Props) {
73
+ const router = useRouter();
74
+ const [error, setError] = useState('');
75
+ const [loading, setLoading] = useState(mode === 'edit');
76
+ const {
77
+ register,
78
+ handleSubmit,
79
+ reset,
80
+ formState: { errors, isSubmitting }
81
+ } = useForm<${entityPascal}FormValues>({
82
+ resolver: zodResolver(${entityCamel}Schema),
83
+ mode: 'onBlur',
84
+ reValidateMode: 'onChange'
85
+ });
86
+
87
+ useEffect(() => {
88
+ if (mode !== 'edit' || !${entityCamel}Id) return;
89
+ let cancelled = false;
90
+ (async () => {
91
+ let response: Response;
92
+ try {
93
+ response = await fetch(\`/api/${entitiesKebab}/\${${entityCamel}Id}\`, { cache: 'no-store' });
94
+ } catch {
95
+ if (!cancelled) setError('Network error while loading the record. Check your connection and try again.');
96
+ return;
97
+ }
98
+ if (response.status === 401) {
99
+ router.push('/login');
100
+ return;
101
+ }
102
+ if (!response.ok) {
103
+ if (!cancelled) setError('Could not load the record. Try again.');
104
+ return;
105
+ }
106
+ const data: unknown = await response.json();
107
+ if (!is${entityPascal}(data)) {
108
+ if (!cancelled) setError('Unexpected response.');
109
+ return;
110
+ }
111
+ if (!cancelled) {
112
+ reset({ ${resetFields} });
113
+ setLoading(false);
114
+ }
115
+ })();
116
+ return () => {
117
+ cancelled = true;
118
+ };
119
+ }, [mode, ${entityCamel}Id, reset, router]);
120
+
121
+ async function submit(values: ${entityPascal}FormValues) {
122
+ setError('');
123
+ const path = mode === 'edit' ? \`/api/${entitiesKebab}/\${${entityCamel}Id}\` : '/api/${entitiesKebab}';
124
+ let response: Response;
125
+ try {
126
+ response = await fetch(path, {
127
+ method: mode === 'edit' ? 'PATCH' : 'POST',
128
+ headers: { 'content-type': 'application/json' },
129
+ body: JSON.stringify(values)
130
+ });
131
+ } catch {
132
+ setError('Network error while saving. Check your connection and try again.');
133
+ return;
134
+ }
135
+ if (!response.ok) {
136
+ setError('Could not save. Try again.');
137
+ return;
138
+ }
139
+ const toast = mode === 'edit' ? 'Updated.' : 'Created.';
140
+ router.push(\`/dashboard/${entitiesKebab}?toast=\${encodeURIComponent(toast)}\`);
141
+ }
142
+
143
+ if (loading) {
144
+ return <p style={{ fontSize: 14, color: 'var(--color-fog)' }}>Loading...</p>;
145
+ }
146
+
147
+ return (
148
+ <ListPageCard
149
+ title={mode === 'edit' ? 'Edit' : 'New'}
150
+ error={error}
151
+ style={{ maxWidth: 640 }}
152
+ >
153
+ <form onSubmit={handleSubmit(submit)} noValidate>
154
+ ${fieldsMarkup}
155
+ <div className="d-flex gap-2">
156
+ <Button type="submit" disabled={isSubmitting}>
157
+ {isSubmitting ? 'Saving...' : mode === 'edit' ? 'Save changes' : 'Create'}
158
+ </Button>
159
+ <Button
160
+ type="button"
161
+ variant="outline"
162
+ onClick={() => router.push('/dashboard/${entitiesKebab}')}
163
+ >
164
+ Cancel
165
+ </Button>
166
+ </div>
167
+ </form>
168
+ </ListPageCard>
169
+ );
170
+ }
171
+ `;
172
+ }
@@ -0,0 +1,38 @@
1
+ export function listPage({ entitiesKebab, entityPascal, entitiesPascal }) {
2
+ return `import { Suspense } from 'react';
3
+ import { ${entityPascal}Table } from '@/components/${entitiesKebab}/${entityPascal}Table';
4
+ import { requirePermission } from '@/lib/permissions';
5
+
6
+ export default async function ${entitiesPascal}Page() {
7
+ await requirePermission('${entitiesKebab}');
8
+ return (
9
+ <Suspense>
10
+ <${entityPascal}Table />
11
+ </Suspense>
12
+ );
13
+ }
14
+ `;
15
+ }
16
+ export function newPage({ entitiesKebab, entityPascal }) {
17
+ return `import { ${entityPascal}Form } from '@/components/${entitiesKebab}/${entityPascal}Form';
18
+ import { requirePermission } from '@/lib/permissions';
19
+
20
+ export default async function New${entityPascal}Page() {
21
+ await requirePermission('${entitiesKebab}.create');
22
+ return <${entityPascal}Form mode="create" />;
23
+ }
24
+ `;
25
+ }
26
+ export function editPage({ entitiesKebab, entityPascal, entityCamel }) {
27
+ return `import { ${entityPascal}Form } from '@/components/${entitiesKebab}/${entityPascal}Form';
28
+ import { requirePermission } from '@/lib/permissions';
29
+
30
+ type Props = { params: Promise<{ id: string }> };
31
+
32
+ export default async function Edit${entityPascal}Page({ params }: Props) {
33
+ await requirePermission('${entitiesKebab}.edit');
34
+ const { id } = await params;
35
+ return <${entityPascal}Form mode="edit" ${entityCamel}Id={id} />;
36
+ }
37
+ `;
38
+ }
@@ -0,0 +1,77 @@
1
+ import { searchField } from '../naming.js';
2
+ export function queryLib(spec, names) {
3
+ const { entitiesKebab, entitiesPascal, entitiesCamel } = names;
4
+ const field = searchField(spec);
5
+ const constPrefix = entitiesCamel.toUpperCase();
6
+ return `export const ${constPrefix}_PAGE_SIZES = [10, 20, 30, 50] as const;
7
+ export const ${constPrefix}_DEFAULT_PAGE_SIZE = 30;
8
+
9
+ export type ${entitiesPascal}Query = {
10
+ ${field !== null ? ' search: string;\n' : ''} page: number;
11
+ itemsPerPage: number;
12
+ };
13
+
14
+ function parsePositiveInteger(value: string | null): number | null {
15
+ if (value === null || !/^[1-9]\\d*$/.test(value)) return null;
16
+ const parsed = Number(value);
17
+ return Number.isSafeInteger(parsed) ? parsed : null;
18
+ }
19
+
20
+ function isAllowedPageSize(
21
+ value: number | null
22
+ ): value is (typeof ${constPrefix}_PAGE_SIZES)[number] {
23
+ return value !== null && ${constPrefix}_PAGE_SIZES.some((size) => size === value);
24
+ }
25
+
26
+ export function parse${entitiesPascal}Query(searchParams: URLSearchParams): ${entitiesPascal}Query {
27
+ const state: ${entitiesPascal}Query = {
28
+ ${field !== null ? ` search: searchParams.get('${field}')?.trim() ?? '',\n` : ''} page: parsePositiveInteger(searchParams.get('page')) ?? 1,
29
+ itemsPerPage: ${constPrefix}_DEFAULT_PAGE_SIZE
30
+ };
31
+ const pageSize = parsePositiveInteger(searchParams.get('itemsPerPage'));
32
+ if (isAllowedPageSize(pageSize)) state.itemsPerPage = pageSize;
33
+
34
+ return state;
35
+ }
36
+
37
+ function buildParams(state: ${entitiesPascal}Query): URLSearchParams {
38
+ const params = new URLSearchParams();
39
+ ${field !== null ? ` if (state.search) params.set('${field}', state.search);\n` : ''} if (state.page !== 1) params.set('page', String(state.page));
40
+ if (state.itemsPerPage !== ${constPrefix}_DEFAULT_PAGE_SIZE) {
41
+ params.set('itemsPerPage', String(state.itemsPerPage));
42
+ }
43
+ return params;
44
+ }
45
+
46
+ export function build${entitiesPascal}Href(
47
+ current: ${entitiesPascal}Query,
48
+ patch: Partial<${entitiesPascal}Query> = {}
49
+ ): string {
50
+ const query = buildParams({ ...current, ...patch }).toString();
51
+ return query ? \`/dashboard/${entitiesKebab}?\${query}\` : '/dashboard/${entitiesKebab}';
52
+ }
53
+
54
+ export function build${entitiesPascal}ApiPath(state: ${entitiesPascal}Query): string {
55
+ const query = buildParams(state).toString();
56
+ return query ? \`/api/${entitiesKebab}?\${query}\` : '/api/${entitiesKebab}';
57
+ }
58
+
59
+ export function clampPageToTotal(
60
+ page: number,
61
+ itemsPerPage: number,
62
+ totalItems: number
63
+ ): number {
64
+ const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
65
+ return Math.min(Math.max(page, 1), totalPages);
66
+ }
67
+
68
+ export function nextItemsPerPagePage(
69
+ currentPage: number,
70
+ oldItemsPerPage: number,
71
+ newItemsPerPage: number
72
+ ): number {
73
+ const firstRowShown = (currentPage - 1) * oldItemsPerPage + 1;
74
+ return Math.max(1, Math.ceil(firstRowShown / newItemsPerPage));
75
+ }
76
+ `;
77
+ }
@@ -0,0 +1,36 @@
1
+ import { humanize } from '../naming.js';
2
+ /** Fresh-file bootstrap, only used when src/lib/schemas.ts doesn't exist yet. */
3
+ export const schemasFileHeader = `import { z } from 'zod';
4
+ `;
5
+ export function schemasMarker(entityCamel) {
6
+ return `export const ${entityCamel}Schema = `;
7
+ }
8
+ function zodField(field) {
9
+ const label = humanize(field.name);
10
+ if (field.type === 'boolean')
11
+ return 'z.boolean()';
12
+ if (field.type === 'number') {
13
+ return field.required ? 'z.number()' : 'z.number().optional()';
14
+ }
15
+ let expr = 'z.string().trim()';
16
+ if (field.required)
17
+ expr += `.min(1, '${label} is required')`;
18
+ if (field.maxLength !== null)
19
+ expr += `.max(${field.maxLength})`;
20
+ if (!field.required)
21
+ expr += '.optional()';
22
+ return expr;
23
+ }
24
+ export function schemasBlock(spec, names) {
25
+ const { entityPascal, entityCamel } = names;
26
+ const fields = spec.fields
27
+ .map((f) => ` ${f.name}: ${zodField(f)}`)
28
+ .join(',\n');
29
+ return `
30
+ export const ${entityCamel}Schema = z.object({
31
+ ${fields}
32
+ });
33
+
34
+ export type ${entityPascal}FormValues = z.infer<typeof ${entityCamel}Schema>;
35
+ `;
36
+ }