@omnifyjp/ts 3.13.0 → 3.14.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,10 @@
1
+ /**
2
+ * @omnify/ts — Frontend Hooks Generator (issue #58)
3
+ *
4
+ * Generates a `{Entity}Hooks.ts` per schema with `options.service` or
5
+ * `options.api`. Each file exports a `create{Entity}BaseHooks` factory
6
+ * that returns TanStack Query hooks (useQuery + useMutation) with toast
7
+ * notifications and cache invalidation.
8
+ */
9
+ import type { SchemaDefinition, TypeScriptFile } from './types.js';
10
+ export declare function generateTsHooks(schemas: Record<string, SchemaDefinition>): TypeScriptFile[];
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @omnify/ts — Frontend Hooks Generator (issue #58)
3
+ *
4
+ * Generates a `{Entity}Hooks.ts` per schema with `options.service` or
5
+ * `options.api`. Each file exports a `create{Entity}BaseHooks` factory
6
+ * that returns TanStack Query hooks (useQuery + useMutation) with toast
7
+ * notifications and cache invalidation.
8
+ */
9
+ // ---------------------------------------------------------------------------
10
+ // Public API
11
+ // ---------------------------------------------------------------------------
12
+ export function generateTsHooks(schemas) {
13
+ const files = [];
14
+ for (const [name, schema] of Object.entries(schemas)) {
15
+ if (schema.kind === 'enum')
16
+ continue;
17
+ if (schema.options?.hidden)
18
+ continue;
19
+ if (!schema.options?.api && !schema.options?.service)
20
+ continue;
21
+ files.push(generateHooksFile(name, schema));
22
+ }
23
+ return files;
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Per-schema generator
27
+ // ---------------------------------------------------------------------------
28
+ function generateHooksFile(name, schema) {
29
+ const hasSoftDelete = schema.options?.softDelete ?? false;
30
+ const hasRestore = schema.options?.api?.restore ?? hasSoftDelete;
31
+ const restoreHook = hasRestore ? `
32
+ useRestore: (slug: string) => {
33
+ const qc = useQueryClient();
34
+ return useMutation({
35
+ mutationFn: (id: string) => service.restore(slug, id),
36
+ onSuccess: () => {
37
+ toast.success(\`\${entityLabel} restored.\`);
38
+ qc.invalidateQueries({ queryKey: keys.all(slug) });
39
+ },
40
+ onError: (e: Error) =>
41
+ toast.error(e.message || \`Failed to restore \${entityLabel}.\`),
42
+ });
43
+ },` : '';
44
+ const content = `/**
45
+ * DO NOT EDIT - Auto-generated by Omnify.
46
+ * @generated by omnify
47
+ */
48
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
49
+ import { toast } from "sonner";
50
+ import type { ${name}Filters, ${name}BaseServiceType } from "./${name}Service";
51
+ import type { create${name}QueryKeys } from "./${name}QueryKeys";
52
+
53
+ export function create${name}BaseHooks(
54
+ service: ${name}BaseServiceType,
55
+ keys: ReturnType<typeof create${name}QueryKeys>,
56
+ entityLabel: string,
57
+ ) {
58
+ return {
59
+ useList: (slug: string, filters: ${name}Filters = {}) =>
60
+ useQuery({
61
+ queryKey: keys.list(slug, filters),
62
+ queryFn: () => service.list(slug, filters),
63
+ enabled: !!slug,
64
+ }),
65
+
66
+ useDetail: (slug: string, id: string) =>
67
+ useQuery({
68
+ queryKey: keys.detail(slug, id),
69
+ queryFn: () => service.getById(slug, id),
70
+ enabled: !!slug && !!id,
71
+ }),
72
+
73
+ useLookup: (slug: string) =>
74
+ useQuery({
75
+ queryKey: keys.lookup(slug),
76
+ queryFn: () => service.lookup(slug),
77
+ enabled: !!slug,
78
+ }),
79
+
80
+ useCreate: (slug: string) => {
81
+ const qc = useQueryClient();
82
+ return useMutation({
83
+ mutationFn: (data: Parameters<typeof service.create>[1]) =>
84
+ service.create(slug, data),
85
+ onSuccess: () => {
86
+ toast.success(\`\${entityLabel} created.\`);
87
+ qc.invalidateQueries({ queryKey: keys.all(slug) });
88
+ },
89
+ onError: (e: Error) =>
90
+ toast.error(e.message || \`Failed to create \${entityLabel}.\`),
91
+ });
92
+ },
93
+
94
+ useUpdate: (slug: string) => {
95
+ const qc = useQueryClient();
96
+ return useMutation({
97
+ mutationFn: ({ id, data }: { id: string; data: Parameters<typeof service.update>[2] }) =>
98
+ service.update(slug, id, data),
99
+ onSuccess: () => {
100
+ toast.success(\`\${entityLabel} updated.\`);
101
+ qc.invalidateQueries({ queryKey: keys.all(slug) });
102
+ },
103
+ onError: (e: Error) =>
104
+ toast.error(e.message || \`Failed to update \${entityLabel}.\`),
105
+ });
106
+ },
107
+
108
+ useDelete: (slug: string) => {
109
+ const qc = useQueryClient();
110
+ return useMutation({
111
+ mutationFn: (id: string) => service.delete(slug, id),
112
+ onSuccess: () => {
113
+ toast.success(\`\${entityLabel} deleted.\`);
114
+ qc.invalidateQueries({ queryKey: keys.all(slug) });
115
+ },
116
+ onError: (e: Error) =>
117
+ toast.error(e.message || \`Failed to delete \${entityLabel}.\`),
118
+ });
119
+ },
120
+ ${restoreHook}
121
+ };
122
+ }
123
+ `;
124
+ return {
125
+ filePath: `base/${name}Hooks.ts`,
126
+ content,
127
+ types: [`create${name}BaseHooks`],
128
+ overwrite: true,
129
+ category: 'base',
130
+ };
131
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @omnify/ts — Frontend QueryKeys Generator (issue #58)
3
+ *
4
+ * Generates a `{Entity}QueryKeys.ts` per schema with `options.service` or
5
+ * `options.api`. Each file exports a `create{Entity}QueryKeys` factory for
6
+ * TanStack Query cache key management.
7
+ */
8
+ import type { SchemaDefinition, TypeScriptFile } from './types.js';
9
+ export declare function generateTsQueryKeys(schemas: Record<string, SchemaDefinition>): TypeScriptFile[];
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @omnify/ts — Frontend QueryKeys Generator (issue #58)
3
+ *
4
+ * Generates a `{Entity}QueryKeys.ts` per schema with `options.service` or
5
+ * `options.api`. Each file exports a `create{Entity}QueryKeys` factory for
6
+ * TanStack Query cache key management.
7
+ */
8
+ // ---------------------------------------------------------------------------
9
+ // Public API
10
+ // ---------------------------------------------------------------------------
11
+ export function generateTsQueryKeys(schemas) {
12
+ const files = [];
13
+ for (const [name, schema] of Object.entries(schemas)) {
14
+ if (schema.kind === 'enum')
15
+ continue;
16
+ if (schema.options?.hidden)
17
+ continue;
18
+ if (!schema.options?.api && !schema.options?.service)
19
+ continue;
20
+ files.push(generateQueryKeysFile(name));
21
+ }
22
+ return files;
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Per-schema generator
26
+ // ---------------------------------------------------------------------------
27
+ function generateQueryKeysFile(name) {
28
+ const lowerName = name.charAt(0).toLowerCase() + name.slice(1);
29
+ const content = `/**
30
+ * DO NOT EDIT - Auto-generated by Omnify.
31
+ * @generated by omnify
32
+ */
33
+ export function create${name}QueryKeys(entity: string) {
34
+ return {
35
+ all: (slug: string) => [entity, slug] as const,
36
+ list: (slug: string, filters?: object) =>
37
+ [entity, slug, "list", filters] as const,
38
+ detail: (slug: string, id: string) =>
39
+ [entity, slug, "detail", id] as const,
40
+ lookup: (slug: string) =>
41
+ [entity, slug, "lookup"] as const,
42
+ };
43
+ }
44
+ `;
45
+ return {
46
+ filePath: `base/${name}QueryKeys.ts`,
47
+ content,
48
+ types: [`create${name}QueryKeys`],
49
+ overwrite: true,
50
+ category: 'base',
51
+ };
52
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @omnify/ts — Frontend BaseService Generator (issue #58)
3
+ *
4
+ * Generates a `{Entity}Service.ts` per schema with `options.service` or
5
+ * `options.api`. Each file exports a factory function `create{Entity}BaseService`
6
+ * that returns typed CRUD methods. URL construction is injected by the caller
7
+ * so the generated code never hardcodes routes.
8
+ */
9
+ import type { SchemaDefinition, TypeScriptFile, GeneratorOptions } from './types.js';
10
+ export declare function generateTsServices(schemas: Record<string, SchemaDefinition>, options: GeneratorOptions): TypeScriptFile[];
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @omnify/ts — Frontend BaseService Generator (issue #58)
3
+ *
4
+ * Generates a `{Entity}Service.ts` per schema with `options.service` or
5
+ * `options.api`. Each file exports a factory function `create{Entity}BaseService`
6
+ * that returns typed CRUD methods. URL construction is injected by the caller
7
+ * so the generated code never hardcodes routes.
8
+ */
9
+ import { toSnakeCase } from './interface-generator.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Public API
12
+ // ---------------------------------------------------------------------------
13
+ export function generateTsServices(schemas, options) {
14
+ const files = [];
15
+ for (const [name, schema] of Object.entries(schemas)) {
16
+ if (schema.kind === 'enum')
17
+ continue;
18
+ if (schema.options?.hidden)
19
+ continue;
20
+ if (!schema.options?.api && !schema.options?.service)
21
+ continue;
22
+ files.push(generateServiceFile(name, schema, options));
23
+ }
24
+ return files;
25
+ }
26
+ // ---------------------------------------------------------------------------
27
+ // Per-schema generator
28
+ // ---------------------------------------------------------------------------
29
+ function generateServiceFile(name, schema, options) {
30
+ const svc = schema.options?.service ?? {};
31
+ const api = schema.options?.api;
32
+ const hasSoftDelete = schema.options?.softDelete ?? false;
33
+ const hasRestore = api?.restore ?? hasSoftDelete;
34
+ const lowerName = name.charAt(0).toLowerCase() + name.slice(1);
35
+ // Resolve filterable/searchable fields
36
+ const properties = schema.properties ?? {};
37
+ const propertyOrder = schema.propertyOrder ?? Object.keys(properties);
38
+ const filterableFields = resolveFilterable(svc, properties, propertyOrder);
39
+ const lookupFields = svc.lookupFields ?? ['id', 'name'];
40
+ // Build filters interface
41
+ const filtersInterface = buildFiltersInterface(name, filterableFields, hasSoftDelete, properties);
42
+ // Build lookup item interface
43
+ const lookupInterface = buildLookupInterface(name, lookupFields, properties);
44
+ // Build toParams function
45
+ const toParamsBody = buildToParams(filterableFields, hasSoftDelete, properties);
46
+ // Build service factory
47
+ const serviceMethods = buildServiceMethods(name, hasRestore);
48
+ const content = `/**
49
+ * DO NOT EDIT - Auto-generated by Omnify.
50
+ * @generated by omnify
51
+ */
52
+ import type { ${name}, ${name}CreateFormState, ${name}UpdateFormState } from "./${name}";
53
+
54
+ ${filtersInterface}
55
+
56
+ ${lookupInterface}
57
+
58
+ ${toParamsBody}
59
+
60
+ // Factory — URL is injected by user-land, not hardcoded.
61
+ export function create${name}BaseService(baseUrl: (slug: string, path?: string) => string) {
62
+ return {
63
+ ${serviceMethods}
64
+ };
65
+ }
66
+
67
+ export type ${name}BaseServiceType = ReturnType<typeof create${name}BaseService>;
68
+ `;
69
+ return {
70
+ filePath: `base/${name}Service.ts`,
71
+ content,
72
+ types: [`${name}Filters`, `${name}LookupItem`, `create${name}BaseService`, `${name}BaseServiceType`],
73
+ overwrite: true,
74
+ category: 'base',
75
+ };
76
+ }
77
+ function resolveFilterable(svc, properties, propertyOrder) {
78
+ const fields = [];
79
+ const names = svc.filterable && svc.filterable.length > 0
80
+ ? svc.filterable
81
+ : propertyOrder.filter(p => properties[p]?.filterable);
82
+ for (const propName of names) {
83
+ const prop = properties[propName];
84
+ if (!prop)
85
+ continue;
86
+ const colName = toSnakeCase(propName);
87
+ const isBool = prop.type === 'Boolean';
88
+ const tsType = isBool ? 'boolean' : 'string';
89
+ fields.push({ name: propName, colName, tsType, isBool });
90
+ }
91
+ return fields;
92
+ }
93
+ function buildFiltersInterface(name, filterableFields, hasSoftDelete, properties) {
94
+ const lines = [];
95
+ lines.push(`export interface ${name}Filters {`);
96
+ lines.push(` page?: number;`);
97
+ lines.push(` per_page?: number;`);
98
+ lines.push(` search?: string;`);
99
+ for (const f of filterableFields) {
100
+ lines.push(` ${f.colName}?: ${f.tsType};`);
101
+ }
102
+ if (hasSoftDelete) {
103
+ lines.push(` with_trashed?: boolean;`);
104
+ lines.push(` only_trashed?: boolean;`);
105
+ }
106
+ lines.push(` sort?: string;`);
107
+ lines.push(`}`);
108
+ return lines.join('\n');
109
+ }
110
+ function buildLookupInterface(name, lookupFields, properties) {
111
+ const lines = [];
112
+ lines.push(`export interface ${name}LookupItem {`);
113
+ for (const f of lookupFields) {
114
+ const col = toSnakeCase(f);
115
+ const prop = properties[f];
116
+ let tsType = 'string';
117
+ if (prop) {
118
+ if (prop.type === 'Int' || prop.type === 'BigInt' || prop.type === 'TinyInt' || prop.type === 'Float' || prop.type === 'Decimal') {
119
+ tsType = 'number';
120
+ }
121
+ else if (prop.type === 'Boolean') {
122
+ tsType = 'boolean';
123
+ }
124
+ }
125
+ lines.push(` ${col}: ${tsType};`);
126
+ }
127
+ lines.push(`}`);
128
+ return lines.join('\n');
129
+ }
130
+ function buildToParams(filterableFields, hasSoftDelete, properties) {
131
+ const lines = [];
132
+ lines.push(`function toParams(filters: ${filterableFields.length > 0 ? 'Record<string, any>' : 'Record<string, any>'}): string {`);
133
+ lines.push(` const p = new URLSearchParams();`);
134
+ lines.push(` if (filters.page) p.set("page", String(filters.page));`);
135
+ lines.push(` if (filters.per_page) p.set("per_page", String(filters.per_page));`);
136
+ lines.push(` if (filters.search) p.set("search", filters.search);`);
137
+ for (const f of filterableFields) {
138
+ if (f.isBool) {
139
+ lines.push(` if (filters.${f.colName} !== undefined) p.set("${f.colName}", filters.${f.colName} ? "1" : "0");`);
140
+ }
141
+ else {
142
+ lines.push(` if (filters.${f.colName}) p.set("${f.colName}", filters.${f.colName});`);
143
+ }
144
+ }
145
+ if (hasSoftDelete) {
146
+ lines.push(` if (filters.with_trashed) p.set("with_trashed", "1");`);
147
+ lines.push(` if (filters.only_trashed) p.set("only_trashed", "1");`);
148
+ }
149
+ lines.push(` if (filters.sort) p.set("sort", filters.sort);`);
150
+ lines.push(` return p.toString();`);
151
+ lines.push(`}`);
152
+ return lines.join('\n');
153
+ }
154
+ function buildServiceMethods(name, hasRestore) {
155
+ const lines = [];
156
+ const indent = ' ';
157
+ lines.push(`${indent}list: (slug: string, filters: ${name}Filters = {}) =>`);
158
+ lines.push(`${indent} apiFetch<PaginatedResponse<${name}>>(`);
159
+ lines.push(`${indent} \`\${baseUrl(slug)}?\${toParams({ per_page: 25, ...filters })}\``);
160
+ lines.push(`${indent} ),`);
161
+ lines.push('');
162
+ lines.push(`${indent}getById: (slug: string, id: string) =>`);
163
+ lines.push(`${indent} apiFetch<{ data: ${name} }>(baseUrl(slug, \`/\${id}\`)),`);
164
+ lines.push('');
165
+ lines.push(`${indent}lookup: (slug: string) =>`);
166
+ lines.push(`${indent} apiFetch<{ data: ${name}LookupItem[] }>(baseUrl(slug, "/lookup")),`);
167
+ lines.push('');
168
+ lines.push(`${indent}create: (slug: string, data: ${name}CreateFormState) =>`);
169
+ lines.push(`${indent} apiFetch<{ data: ${name} }>(baseUrl(slug), {`);
170
+ lines.push(`${indent} method: "POST",`);
171
+ lines.push(`${indent} body: JSON.stringify(data),`);
172
+ lines.push(`${indent} }),`);
173
+ lines.push('');
174
+ lines.push(`${indent}update: (slug: string, id: string, data: Partial<${name}UpdateFormState>) =>`);
175
+ lines.push(`${indent} apiFetch<{ data: ${name} }>(baseUrl(slug, \`/\${id}\`), {`);
176
+ lines.push(`${indent} method: "PUT",`);
177
+ lines.push(`${indent} body: JSON.stringify(data),`);
178
+ lines.push(`${indent} }),`);
179
+ lines.push('');
180
+ lines.push(`${indent}delete: (slug: string, id: string) =>`);
181
+ lines.push(`${indent} apiFetch<null>(baseUrl(slug, \`/\${id}\`), { method: "DELETE" }),`);
182
+ if (hasRestore) {
183
+ lines.push('');
184
+ lines.push(`${indent}restore: (slug: string, id: string) =>`);
185
+ lines.push(`${indent} apiFetch<{ data: ${name} }>(baseUrl(slug, \`/\${id}/restore\`), {`);
186
+ lines.push(`${indent} method: "POST",`);
187
+ lines.push(`${indent} }),`);
188
+ }
189
+ return lines.join('\n');
190
+ }
package/dist/types.d.ts CHANGED
@@ -113,6 +113,15 @@ export interface ApiOptions {
113
113
  readonly middleware?: readonly string[];
114
114
  readonly perPage?: number;
115
115
  }
116
+ /** Service layer codegen options — controls BaseService generation. Issue #57. */
117
+ export interface ServiceOptions {
118
+ readonly searchable?: readonly string[];
119
+ readonly filterable?: readonly string[];
120
+ readonly defaultSort?: string;
121
+ readonly eagerLoad?: readonly string[];
122
+ readonly eagerCount?: readonly string[];
123
+ readonly lookupFields?: readonly string[];
124
+ }
116
125
  /** Schema options. */
117
126
  export interface SchemaOptions {
118
127
  readonly id?: boolean | string;
@@ -129,6 +138,8 @@ export interface SchemaOptions {
129
138
  readonly updatedBy?: boolean;
130
139
  readonly deletedBy?: boolean;
131
140
  };
141
+ /** Service layer codegen options. Issue #57. */
142
+ readonly service?: ServiceOptions;
132
143
  /** Schema-level default ordering — generates a global Eloquent scope. Issue #40. */
133
144
  readonly defaultOrder?: readonly OrderByItem[];
134
145
  }
@@ -328,6 +339,7 @@ export interface SchemaDisplayNames {
328
339
  readonly displayName: LocaleMap;
329
340
  readonly propertyDisplayNames: Record<string, LocaleMap>;
330
341
  readonly propertyPlaceholders: Record<string, LocaleMap>;
342
+ readonly propertyDescriptions: Record<string, LocaleMap>;
331
343
  }
332
344
  /** Generation options (internal). */
333
345
  export interface GeneratorOptions {
@@ -26,6 +26,27 @@ function getMultiLocaleDisplayName(value, locales, fallbackLocale, defaultValue)
26
26
  }
27
27
  return result;
28
28
  }
29
+ /**
30
+ * Auto-derive placeholder from label per locale (issue #59).
31
+ * - ja: "{label}を入力"
32
+ * - en: "Enter {label}"
33
+ * - vi: "Nhập {label}"
34
+ * Other locales: "Enter {label}" (fallback to English pattern)
35
+ */
36
+ function autoDerivePlaceholder(label, locales) {
37
+ const PREFIXES = {
38
+ ja: { prefix: '', suffix: 'を入力' },
39
+ en: { prefix: 'Enter ', suffix: '' },
40
+ vi: { prefix: 'Nhập ', suffix: '' },
41
+ };
42
+ const result = {};
43
+ for (const locale of locales) {
44
+ const l = label[locale] ?? '';
45
+ const pattern = PREFIXES[locale] ?? PREFIXES['en'];
46
+ result[locale] = `${pattern.prefix}${l}${pattern.suffix}`;
47
+ }
48
+ return result;
49
+ }
29
50
  /** Type categories for Zod rule applicability. */
30
51
  const ZOD_STRING_TYPES = new Set([
31
52
  'String', 'Email', 'Password', 'Text', 'MediumText', 'LongText',
@@ -340,6 +361,7 @@ export function generateDisplayNames(schema, options) {
340
361
  const displayName = getMultiLocaleDisplayName(schema.displayName, locales, fallbackLocale, schema.name);
341
362
  const propertyDisplayNames = {};
342
363
  const propertyPlaceholders = {};
364
+ const propertyDescriptions = {};
343
365
  if (schema.properties) {
344
366
  const propNames = schema.propertyOrder ?? Object.keys(schema.properties);
345
367
  for (const propName of propNames) {
@@ -378,14 +400,22 @@ export function generateDisplayNames(schema, options) {
378
400
  continue;
379
401
  }
380
402
  // Regular field display name
381
- propertyDisplayNames[fieldName] = getMultiLocaleDisplayName(propDef.displayName, locales, fallbackLocale, propName);
382
- // Placeholder
403
+ const label = getMultiLocaleDisplayName(propDef.displayName, locales, fallbackLocale, propName);
404
+ propertyDisplayNames[fieldName] = label;
405
+ // Placeholder — explicit or auto-derived from label (issue #59)
383
406
  if (propDef.placeholder) {
384
407
  propertyPlaceholders[fieldName] = getMultiLocaleDisplayName(propDef.placeholder, locales, fallbackLocale, '');
385
408
  }
409
+ else if (propDef.displayName) {
410
+ propertyPlaceholders[fieldName] = autoDerivePlaceholder(label, locales);
411
+ }
412
+ // Description (issue #59)
413
+ if (propDef.description) {
414
+ propertyDescriptions[fieldName] = getMultiLocaleDisplayName(propDef.description, locales, fallbackLocale, '');
415
+ }
386
416
  }
387
417
  }
388
- return { displayName, propertyDisplayNames, propertyPlaceholders };
418
+ return { displayName, propertyDisplayNames, propertyPlaceholders, propertyDescriptions };
389
419
  }
390
420
  /**
391
421
  * Get fields to exclude from create/update schemas.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/ts",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "TypeScript model type generator from Omnify schemas.json",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",