@quazardous/qdadm 2.4.5 → 2.5.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 +2 -0
- package/package.json +1 -1
- package/src/components/editors/VanillaJsonEditor.vue +11 -5
- package/src/composables/useListPage.ts +38 -0
- package/src/composables/useListPage.types.ts +2 -0
- package/src/editors/index.ts +5 -0
- package/src/entity/EntityManager.interface.ts +2 -0
- package/src/entity/EntityManager.ts +2 -0
- package/src/gen/connectors/OpenAPIConnector.test.js +253 -1
- package/src/gen/connectors/OpenAPIConnector.ts +110 -4
- package/src/gen/generateManagers.test.js +78 -0
- package/src/gen/generateManagers.ts +33 -7
- package/src/gen/index.ts +1 -0
- package/src/index.ts +26 -0
- package/src/orchestrator/useOrchestrator.ts +20 -5
- package/src/utils/humanize.ts +26 -0
- package/src/utils/index.ts +3 -0
package/README.md
CHANGED
|
@@ -137,6 +137,8 @@ This is the canonical Vite/Webpack pattern for Vue plugin singletons. The `file:
|
|
|
137
137
|
|
|
138
138
|
Installing qdadm from npm (`@quazardous/qdadm@^1.19.3`) doesn't have this issue: the published tarball ships no `node_modules/`, so peers always resolve from the host. The trap is specific to `file:` / `workspace:` links onto a package that lives inside another monorepo.
|
|
139
139
|
|
|
140
|
+
One more caveat: qdadm ships TypeScript sources, so with a `file:` link your `vue-tsc` / lint / test counters measure qdadm's **working tree**, not a released version. Any local qdadm edit moves your numbers with zero change on your side — don't calibrate quality ratchets or CI thresholds against a linked checkout; pin them to the published package your CI actually installs.
|
|
141
|
+
|
|
140
142
|
## License
|
|
141
143
|
|
|
142
144
|
MIT
|
package/package.json
CHANGED
|
@@ -22,7 +22,13 @@ type JsonValue = Record<string, unknown> | unknown[] | string | null
|
|
|
22
22
|
|
|
23
23
|
interface Props {
|
|
24
24
|
modelValue?: JsonValue
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Editor mode. String literals are accepted (#1253) so consumers never
|
|
27
|
+
* need to import the `Mode` enum from vanilla-jsoneditor (which is not
|
|
28
|
+
* a direct dependency of theirs); `Mode` itself is re-exported from
|
|
29
|
+
* '@quazardous/qdadm/editors' for those who want it.
|
|
30
|
+
*/
|
|
31
|
+
mode?: Mode | 'tree' | 'text' | 'table'
|
|
26
32
|
height?: string
|
|
27
33
|
readOnly?: boolean
|
|
28
34
|
mainMenuBar?: boolean
|
|
@@ -40,7 +46,7 @@ interface Props {
|
|
|
40
46
|
|
|
41
47
|
const props = withDefaults(defineProps<Props>(), {
|
|
42
48
|
modelValue: () => ({}),
|
|
43
|
-
mode: 'tree'
|
|
49
|
+
mode: 'tree',
|
|
44
50
|
height: '400px',
|
|
45
51
|
readOnly: false,
|
|
46
52
|
mainMenuBar: true,
|
|
@@ -100,7 +106,7 @@ onMounted(() => {
|
|
|
100
106
|
target: containerRef.value,
|
|
101
107
|
props: {
|
|
102
108
|
content,
|
|
103
|
-
mode: props.mode,
|
|
109
|
+
mode: props.mode as Mode,
|
|
104
110
|
readOnly: props.readOnly,
|
|
105
111
|
mainMenuBar: props.mainMenuBar,
|
|
106
112
|
navigationBar: props.navigationBar,
|
|
@@ -161,9 +167,9 @@ watch(() => props.modelValue, (newVal: JsonValue | undefined) => {
|
|
|
161
167
|
}, { deep: true })
|
|
162
168
|
|
|
163
169
|
// Watch mode changes
|
|
164
|
-
watch(() => props.mode, (newMode
|
|
170
|
+
watch(() => props.mode, (newMode) => {
|
|
165
171
|
if (editor && newMode) {
|
|
166
|
-
editor.updateProps({ mode: newMode })
|
|
172
|
+
editor.updateProps({ mode: newMode as Mode })
|
|
167
173
|
}
|
|
168
174
|
})
|
|
169
175
|
|
|
@@ -35,6 +35,7 @@ import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } fr
|
|
|
35
35
|
import { useActiveStack } from '../chain/useActiveStack.js'
|
|
36
36
|
import { useI18n } from '../i18n/useI18n'
|
|
37
37
|
import { formatDateOnly } from '../utils/formatters'
|
|
38
|
+
import { humanizeFieldName } from '../utils/humanize'
|
|
38
39
|
|
|
39
40
|
// Import types from dedicated types file
|
|
40
41
|
import type {
|
|
@@ -294,6 +295,42 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
294
295
|
}
|
|
295
296
|
})
|
|
296
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Template-side column binding helper (#1255): spread onto a PrimeVue
|
|
300
|
+
* `<Column>` so `field` + `header` come from one source instead of being
|
|
301
|
+
* retyped in every list template:
|
|
302
|
+
*
|
|
303
|
+
* ```html
|
|
304
|
+
* <Column v-bind="list.column('botUuid')" sortable>
|
|
305
|
+
* <template #body="{ data }">...</template>
|
|
306
|
+
* </Column>
|
|
307
|
+
* ```
|
|
308
|
+
*
|
|
309
|
+
* Header resolution: i18n key (`entities.{entity}.fields.{field}`) >
|
|
310
|
+
* `overrides.header` > inline header given to `addColumn` >
|
|
311
|
+
* `manager.fields[name].label` > humanized field name. Pure read — it
|
|
312
|
+
* never registers into columnsMap, so calling it during render is safe.
|
|
313
|
+
*/
|
|
314
|
+
function column(name: string, overrides: Partial<ColumnConfig> = {}): ColumnConfig {
|
|
315
|
+
// Depend on the locale so headers re-render on locale change.
|
|
316
|
+
void i18nLocale.value
|
|
317
|
+
|
|
318
|
+
let header: string | undefined
|
|
319
|
+
if (entity && kernelI18n) {
|
|
320
|
+
const trace = kernelI18n.resolve(`entities.${entity}.fields.${name}`)
|
|
321
|
+
if (trace.hit) header = trace.result
|
|
322
|
+
}
|
|
323
|
+
const fieldConfig = manager.getFieldConfig?.(name) as { label?: string } | null | undefined
|
|
324
|
+
header ??=
|
|
325
|
+
overrides.header ??
|
|
326
|
+
columnInlineHeaders.get(name) ??
|
|
327
|
+
fieldConfig?.label ??
|
|
328
|
+
humanizeFieldName(name)
|
|
329
|
+
|
|
330
|
+
const registered = columnsMap.value.get(name)
|
|
331
|
+
return { ...registered, ...overrides, field: name, header }
|
|
332
|
+
}
|
|
333
|
+
|
|
297
334
|
function removeColumn(field: string): void {
|
|
298
335
|
columnsMap.value.delete(field)
|
|
299
336
|
}
|
|
@@ -1111,6 +1148,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1111
1148
|
|
|
1112
1149
|
// Columns
|
|
1113
1150
|
columns,
|
|
1151
|
+
column,
|
|
1114
1152
|
addColumn,
|
|
1115
1153
|
removeColumn,
|
|
1116
1154
|
updateColumn,
|
|
@@ -347,6 +347,8 @@ export interface UseListPageReturn<T = unknown> {
|
|
|
347
347
|
|
|
348
348
|
// Columns
|
|
349
349
|
columns: ComputedRef<ColumnConfig[]>
|
|
350
|
+
/** v-bind column binding helper (#1255): field + header from one source */
|
|
351
|
+
column: (name: string, overrides?: Partial<ColumnConfig>) => ColumnConfig
|
|
350
352
|
addColumn: (field: string, config?: Partial<ColumnConfig>) => void
|
|
351
353
|
removeColumn: (field: string) => void
|
|
352
354
|
updateColumn: (field: string, updates: Partial<ColumnConfig>) => void
|
package/src/editors/index.ts
CHANGED
|
@@ -10,3 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
export { default as VanillaJsonEditor } from '../components/editors/VanillaJsonEditor.vue'
|
|
12
12
|
export { default as JsonStructuredField } from '../components/editors/JsonStructuredField.vue'
|
|
13
|
+
|
|
14
|
+
// Re-export the mode enum so consumers who want it don't need to depend on
|
|
15
|
+
// vanilla-jsoneditor directly (#1253). String literals ('tree' | 'text' |
|
|
16
|
+
// 'table') are accepted by VanillaJsonEditor's `mode` prop anyway.
|
|
17
|
+
export { Mode } from 'vanilla-jsoneditor'
|
|
@@ -60,6 +60,8 @@ export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, En
|
|
|
60
60
|
request: (method: string, path: string, options?: { data?: unknown }) => Promise<unknown>
|
|
61
61
|
invalidateCache?: () => void
|
|
62
62
|
localFilterThreshold?: number
|
|
63
|
+
/** Field config lookup — read-side too since list column binding (#1255) */
|
|
64
|
+
getFieldConfig?: (name: string) => unknown | null
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { OpenAPIConnector } from './OpenAPIConnector'
|
|
3
3
|
import sampleSpec from './__fixtures__/sample-openapi.json'
|
|
4
4
|
|
|
@@ -584,6 +584,258 @@ describe('OpenAPIConnector', () => {
|
|
|
584
584
|
|
|
585
585
|
expect(result.warnings.some(w => w.code === 'UNRESOLVED_REF')).toBe(true)
|
|
586
586
|
})
|
|
587
|
+
|
|
588
|
+
describe('EMPTY_OBJECT_SCHEMA detection (#1240)', () => {
|
|
589
|
+
// Build a spec with a single tasks entity whose `payload` field uses
|
|
590
|
+
// the given schema node.
|
|
591
|
+
function specWithPayload(payloadSchema) {
|
|
592
|
+
return {
|
|
593
|
+
openapi: '3.0.0',
|
|
594
|
+
paths: {
|
|
595
|
+
'/api/tasks/{id}': {
|
|
596
|
+
get: {
|
|
597
|
+
responses: {
|
|
598
|
+
'200': {
|
|
599
|
+
content: {
|
|
600
|
+
'application/json': {
|
|
601
|
+
schema: {
|
|
602
|
+
type: 'object',
|
|
603
|
+
properties: {
|
|
604
|
+
data: {
|
|
605
|
+
type: 'object',
|
|
606
|
+
properties: {
|
|
607
|
+
id: { type: 'string' },
|
|
608
|
+
payload: payloadSchema
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function warningsFor(payloadSchema) {
|
|
624
|
+
const connector = new OpenAPIConnector()
|
|
625
|
+
const result = connector.parseWithWarnings(specWithPayload(payloadSchema))
|
|
626
|
+
return result.warnings.filter(w => w.code === 'EMPTY_OBJECT_SCHEMA')
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
it('warns on a bare object schema (no properties, no additionalProperties)', () => {
|
|
630
|
+
const warnings = warningsFor({ type: 'object' })
|
|
631
|
+
expect(warnings).toHaveLength(1)
|
|
632
|
+
expect(warnings[0].path).toBe('/api/tasks/{id}#payload')
|
|
633
|
+
expect(warnings[0].message).toContain('additionalProperties: true')
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it('warns when properties is present but empty', () => {
|
|
637
|
+
expect(warningsFor({ type: 'object', properties: {} })).toHaveLength(1)
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('does not warn when additionalProperties is true', () => {
|
|
641
|
+
expect(warningsFor({ type: 'object', additionalProperties: true })).toHaveLength(0)
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it('does not warn when additionalProperties is a sub-schema', () => {
|
|
645
|
+
expect(
|
|
646
|
+
warningsFor({ type: 'object', additionalProperties: { type: 'string' } })
|
|
647
|
+
).toHaveLength(0)
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it('does not warn when the object declares properties', () => {
|
|
651
|
+
expect(
|
|
652
|
+
warningsFor({ type: 'object', properties: { targets: { type: 'array' } } })
|
|
653
|
+
).toHaveLength(0)
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('does not warn on composition schemas (oneOf/anyOf/allOf)', () => {
|
|
657
|
+
expect(
|
|
658
|
+
warningsFor({ type: 'object', oneOf: [{ type: 'object', properties: { a: { type: 'string' } } }] })
|
|
659
|
+
).toHaveLength(0)
|
|
660
|
+
expect(
|
|
661
|
+
warningsFor({ type: 'object', allOf: [{ type: 'object', properties: { a: { type: 'string' } } }] })
|
|
662
|
+
).toHaveLength(0)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('does not warn on non-object types', () => {
|
|
666
|
+
expect(warningsFor({ type: 'string' })).toHaveLength(0)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('still generates the field as a usable object type', () => {
|
|
670
|
+
const connector = new OpenAPIConnector()
|
|
671
|
+
const { schemas } = connector.parseWithWarnings(specWithPayload({ type: 'object' }))
|
|
672
|
+
const tasks = schemas.find(e => e.name === 'tasks')
|
|
673
|
+
expect(tasks.fields.payload).toBeDefined()
|
|
674
|
+
expect(tasks.fields.payload.type).toBe('object')
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('parse() logs collected warnings to console.warn', () => {
|
|
678
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
679
|
+
try {
|
|
680
|
+
const connector = new OpenAPIConnector()
|
|
681
|
+
connector.parse(specWithPayload({ type: 'object' }))
|
|
682
|
+
const calls = warnSpy.mock.calls.map(args => args.join(' '))
|
|
683
|
+
expect(calls.some(line => line.includes('EMPTY') || line.includes('additionalProperties'))).toBe(true)
|
|
684
|
+
expect(calls.some(line => line.includes('/api/tasks/{id}#payload'))).toBe(true)
|
|
685
|
+
} finally {
|
|
686
|
+
warnSpy.mockRestore()
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
describe('inference options (#1255)', () => {
|
|
693
|
+
// /api/bots: GET item + POST with a partial body; lastSeen/uuid are
|
|
694
|
+
// response-only, name is writable, status declares readOnly in-schema.
|
|
695
|
+
const botsSpec = {
|
|
696
|
+
openapi: '3.0.0',
|
|
697
|
+
paths: {
|
|
698
|
+
'/api/bots/{id}': {
|
|
699
|
+
get: {
|
|
700
|
+
responses: {
|
|
701
|
+
'200': {
|
|
702
|
+
content: {
|
|
703
|
+
'application/json': {
|
|
704
|
+
schema: {
|
|
705
|
+
type: 'object',
|
|
706
|
+
properties: {
|
|
707
|
+
data: {
|
|
708
|
+
type: 'object',
|
|
709
|
+
properties: {
|
|
710
|
+
uuid: { type: 'string', format: 'uuid' },
|
|
711
|
+
name: { type: 'string' },
|
|
712
|
+
lastSeen: { type: 'string', format: 'date-time' },
|
|
713
|
+
status: { type: 'string', readOnly: true },
|
|
714
|
+
notes: { type: 'string', description: 'Operator notes' }
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
'/api/bots': {
|
|
726
|
+
post: {
|
|
727
|
+
requestBody: {
|
|
728
|
+
content: {
|
|
729
|
+
'application/json': {
|
|
730
|
+
schema: {
|
|
731
|
+
type: 'object',
|
|
732
|
+
properties: {
|
|
733
|
+
name: { type: 'string' },
|
|
734
|
+
notes: { type: 'string', description: 'Operator notes' }
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
responses: {
|
|
741
|
+
'200': {
|
|
742
|
+
content: {
|
|
743
|
+
'application/json': {
|
|
744
|
+
schema: { type: 'object', properties: { data: { type: 'object', properties: { uuid: { type: 'string' } } } } }
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Read-only entity: no write operation at all
|
|
755
|
+
const logsSpec = {
|
|
756
|
+
openapi: '3.0.0',
|
|
757
|
+
paths: {
|
|
758
|
+
'/api/logs': {
|
|
759
|
+
get: {
|
|
760
|
+
responses: {
|
|
761
|
+
'200': {
|
|
762
|
+
content: {
|
|
763
|
+
'application/json': {
|
|
764
|
+
schema: {
|
|
765
|
+
type: 'object',
|
|
766
|
+
properties: {
|
|
767
|
+
data: {
|
|
768
|
+
type: 'array',
|
|
769
|
+
items: {
|
|
770
|
+
type: 'object',
|
|
771
|
+
properties: {
|
|
772
|
+
id: { type: 'string' },
|
|
773
|
+
message: { type: 'string' }
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
describe('inferLabels', () => {
|
|
789
|
+
it('is off by default (label only from description)', () => {
|
|
790
|
+
const connector = new OpenAPIConnector()
|
|
791
|
+
const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
|
|
792
|
+
|
|
793
|
+
expect(bots.fields.lastSeen.label).toBeUndefined()
|
|
794
|
+
expect(bots.fields.notes.label).toBe('Operator notes')
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('humanizes field names when enabled and description is absent', () => {
|
|
798
|
+
const connector = new OpenAPIConnector({ inferLabels: 'humanize' })
|
|
799
|
+
const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
|
|
800
|
+
|
|
801
|
+
expect(bots.fields.lastSeen.label).toBe('Last Seen')
|
|
802
|
+
expect(bots.fields.uuid.label).toBe('Uuid')
|
|
803
|
+
// description still wins
|
|
804
|
+
expect(bots.fields.notes.label).toBe('Operator notes')
|
|
805
|
+
})
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
describe('inferReadOnly', () => {
|
|
809
|
+
it('is off by default (readOnly only from schema)', () => {
|
|
810
|
+
const connector = new OpenAPIConnector()
|
|
811
|
+
const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
|
|
812
|
+
|
|
813
|
+
expect(bots.fields.lastSeen.readOnly).toBe(false)
|
|
814
|
+
expect(bots.fields.status.readOnly).toBe(true)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('marks response-only fields readOnly when enabled', () => {
|
|
818
|
+
const connector = new OpenAPIConnector({ inferReadOnly: true })
|
|
819
|
+
const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
|
|
820
|
+
|
|
821
|
+
// never in a request body → inferred readOnly
|
|
822
|
+
expect(bots.fields.uuid.readOnly).toBe(true)
|
|
823
|
+
expect(bots.fields.lastSeen.readOnly).toBe(true)
|
|
824
|
+
// present in the POST body → stays writable
|
|
825
|
+
expect(bots.fields.name.readOnly).toBe(false)
|
|
826
|
+
expect(bots.fields.notes.readOnly).toBe(false)
|
|
827
|
+
// schema-declared readOnly preserved
|
|
828
|
+
expect(bots.fields.status.readOnly).toBe(true)
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('marks every field readOnly for entities without write operations', () => {
|
|
832
|
+
const connector = new OpenAPIConnector({ inferReadOnly: true })
|
|
833
|
+
const logs = connector.parse(logsSpec).find(e => e.name === 'logs')
|
|
834
|
+
|
|
835
|
+
expect(logs.fields.id.readOnly).toBe(true)
|
|
836
|
+
expect(logs.fields.message.readOnly).toBe(true)
|
|
837
|
+
})
|
|
838
|
+
})
|
|
587
839
|
})
|
|
588
840
|
|
|
589
841
|
describe('extensions option', () => {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { BaseConnector, type ConnectorOptions, type ParseResult, type ParseWarning } from './BaseConnector'
|
|
11
11
|
import { getDefaultType, type CustomMappings } from '../FieldMapper'
|
|
12
|
+
import { humanizeFieldName } from '../../utils/humanize'
|
|
12
13
|
import type { UnifiedEntitySchema, UnifiedFieldSchema, UnifiedFieldType } from '../schema'
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -48,6 +49,19 @@ export interface OpenAPIConnectorOptions extends ConnectorOptions {
|
|
|
48
49
|
operationFilter?: OperationFilter | null
|
|
49
50
|
/** Custom type mappings for FieldMapper */
|
|
50
51
|
customMappings?: CustomMappings
|
|
52
|
+
/**
|
|
53
|
+
* Infer a label from the field name when the schema has no `description`
|
|
54
|
+
* (#1255): 'humanize' turns `botUuid` into "Bot Uuid". Opt-in — it changes
|
|
55
|
+
* generated output. Default: off (label only from `description`).
|
|
56
|
+
*/
|
|
57
|
+
inferLabels?: 'humanize' | false
|
|
58
|
+
/**
|
|
59
|
+
* Infer `readOnly` from operation shape (#1255): a field that appears in
|
|
60
|
+
* responses but in no request-body schema is marked `readOnly: true`
|
|
61
|
+
* (an entity with no write operations gets all fields readOnly). Opt-in —
|
|
62
|
+
* it changes generated output. Schema-declared `readOnly` always wins.
|
|
63
|
+
*/
|
|
64
|
+
inferReadOnly?: boolean
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
/**
|
|
@@ -64,6 +78,8 @@ interface EntityWorkingData {
|
|
|
64
78
|
rawSchema: OpenAPISchema | null
|
|
65
79
|
/** Extracted fields */
|
|
66
80
|
fields: Map<string, UnifiedFieldSchema>
|
|
81
|
+
/** Field names seen in request-body schemas (for readOnly inference, #1255) */
|
|
82
|
+
requestFields: Set<string>
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
/**
|
|
@@ -173,6 +189,12 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
173
189
|
/** Custom type mappings */
|
|
174
190
|
customMappings: CustomMappings
|
|
175
191
|
|
|
192
|
+
/** Label inference mode (#1255) */
|
|
193
|
+
inferLabels: 'humanize' | false
|
|
194
|
+
|
|
195
|
+
/** Whether to infer readOnly from operation shape (#1255) */
|
|
196
|
+
inferReadOnly: boolean
|
|
197
|
+
|
|
176
198
|
/**
|
|
177
199
|
* Create a new OpenAPI connector
|
|
178
200
|
*
|
|
@@ -185,6 +207,8 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
185
207
|
this.dataWrapper = options.dataWrapper ?? 'data'
|
|
186
208
|
this.operationFilter = options.operationFilter || null
|
|
187
209
|
this.customMappings = options.customMappings || {}
|
|
210
|
+
this.inferLabels = options.inferLabels ?? false
|
|
211
|
+
this.inferReadOnly = options.inferReadOnly ?? false
|
|
188
212
|
}
|
|
189
213
|
|
|
190
214
|
/**
|
|
@@ -201,10 +225,11 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
201
225
|
* })
|
|
202
226
|
*/
|
|
203
227
|
parse(source: unknown): UnifiedEntitySchema[] {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
228
|
+
const { schemas, warnings } = this.parseWithWarnings(source)
|
|
229
|
+
for (const warning of warnings) {
|
|
230
|
+
console.warn(`⚠ [${this.name}] ${warning.path}: ${warning.message}`)
|
|
231
|
+
}
|
|
232
|
+
return schemas
|
|
208
233
|
}
|
|
209
234
|
|
|
210
235
|
/**
|
|
@@ -267,6 +292,7 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
267
292
|
idField: null,
|
|
268
293
|
rawSchema: null,
|
|
269
294
|
fields: new Map(),
|
|
295
|
+
requestFields: new Set(),
|
|
270
296
|
})
|
|
271
297
|
}
|
|
272
298
|
|
|
@@ -371,6 +397,8 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
371
397
|
const requestSchema = this._extractRequestSchema(op, spec)
|
|
372
398
|
if (requestSchema) {
|
|
373
399
|
this._extractFields(requestSchema, entity.fields, spec, warnings, path)
|
|
400
|
+
// Track writable field names for readOnly inference (#1255)
|
|
401
|
+
this._collectFieldNames(requestSchema, spec, entity.requestFields)
|
|
374
402
|
}
|
|
375
403
|
}
|
|
376
404
|
}
|
|
@@ -565,6 +593,20 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
565
593
|
continue
|
|
566
594
|
}
|
|
567
595
|
|
|
596
|
+
// Bare object schema: no contract at all. fast-json-stringify (Fastify)
|
|
597
|
+
// serializes such a field to `{}` at runtime, stripping every key (#1240).
|
|
598
|
+
if (this._isBareObjectSchema(resolvedSchema)) {
|
|
599
|
+
warnings.push({
|
|
600
|
+
path: `${sourcePath}#${fieldName}`,
|
|
601
|
+
message:
|
|
602
|
+
`object schema has no \`properties\` and \`additionalProperties\` is not set. ` +
|
|
603
|
+
`Under Fastify (fast-json-stringify) this serializes to \`{}\` at runtime ` +
|
|
604
|
+
`and the generated field type is unusable. ` +
|
|
605
|
+
`For a free-form JSON blob, declare \`additionalProperties: true\`.`,
|
|
606
|
+
code: 'EMPTY_OBJECT_SCHEMA',
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
|
|
568
610
|
// Use FieldMapper for type conversion
|
|
569
611
|
const type = getDefaultType(resolvedSchema, this.customMappings)
|
|
570
612
|
|
|
@@ -578,6 +620,8 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
578
620
|
// Add optional properties
|
|
579
621
|
if (resolvedSchema.description) {
|
|
580
622
|
field.label = resolvedSchema.description
|
|
623
|
+
} else if (this.inferLabels === 'humanize') {
|
|
624
|
+
field.label = humanizeFieldName(fieldName)
|
|
581
625
|
}
|
|
582
626
|
if (resolvedSchema.format) {
|
|
583
627
|
field.format = resolvedSchema.format
|
|
@@ -604,6 +648,57 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
604
648
|
}
|
|
605
649
|
}
|
|
606
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Collect field names from a schema the same way _extractFields names them
|
|
653
|
+
* (top-level + one-level-nested dotted keys). Used to track which fields
|
|
654
|
+
* appear in request bodies, for readOnly inference (#1255).
|
|
655
|
+
*
|
|
656
|
+
* @private
|
|
657
|
+
* @param schema - JSON Schema object
|
|
658
|
+
* @param spec - Full spec for ref resolution
|
|
659
|
+
* @param into - Set collecting the names
|
|
660
|
+
* @param prefix - Prefix for nested field names
|
|
661
|
+
*/
|
|
662
|
+
private _collectFieldNames(
|
|
663
|
+
schema: OpenAPISchema,
|
|
664
|
+
spec: OpenAPISpec,
|
|
665
|
+
into: Set<string>,
|
|
666
|
+
prefix: string = ''
|
|
667
|
+
): void {
|
|
668
|
+
if (!schema || !schema.properties) return
|
|
669
|
+
|
|
670
|
+
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
|
671
|
+
const fieldName = prefix ? `${prefix}.${name}` : name
|
|
672
|
+
into.add(fieldName)
|
|
673
|
+
|
|
674
|
+
const resolvedSchema = propSchema.$ref ? this._resolveRef(propSchema.$ref, spec) : propSchema
|
|
675
|
+
if (resolvedSchema?.type === 'object' && resolvedSchema.properties && !prefix) {
|
|
676
|
+
this._collectFieldNames(resolvedSchema, spec, into, fieldName)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Detect a "bare" object schema: type object with no properties, no
|
|
683
|
+
* additionalProperties contract, and no composition/reference that could
|
|
684
|
+
* supply one. Such a schema is the quasi-always-footgun case (#1240) —
|
|
685
|
+
* objects with `properties`, a sub-schema `additionalProperties`, `$ref`
|
|
686
|
+
* or oneOf/anyOf/allOf are deliberately excluded.
|
|
687
|
+
*
|
|
688
|
+
* @private
|
|
689
|
+
* @param schema - Resolved property schema
|
|
690
|
+
* @returns True if the schema is a contract-less object
|
|
691
|
+
*/
|
|
692
|
+
private _isBareObjectSchema(schema: OpenAPISchema): boolean {
|
|
693
|
+
if (schema.type !== 'object') return false
|
|
694
|
+
if (schema.properties && Object.keys(schema.properties).length > 0) return false
|
|
695
|
+
const additionalProperties = schema.additionalProperties
|
|
696
|
+
if (additionalProperties === true) return false
|
|
697
|
+
if (additionalProperties && typeof additionalProperties === 'object') return false
|
|
698
|
+
if (schema.$ref || schema.oneOf || schema.anyOf || schema.allOf) return false
|
|
699
|
+
return true
|
|
700
|
+
}
|
|
701
|
+
|
|
607
702
|
/**
|
|
608
703
|
* Merge field info from secondary source into primary
|
|
609
704
|
*
|
|
@@ -667,6 +762,17 @@ export class OpenAPIConnector extends BaseConnector {
|
|
|
667
762
|
const schemas: Map<string, UnifiedEntitySchema> = new Map()
|
|
668
763
|
|
|
669
764
|
for (const [name, data] of entityData) {
|
|
765
|
+
// readOnly inference (#1255): a field never seen in a request body is
|
|
766
|
+
// not writable through this API. Entities without write operations end
|
|
767
|
+
// up with every field readOnly. Schema-declared readOnly is preserved.
|
|
768
|
+
if (this.inferReadOnly) {
|
|
769
|
+
for (const field of data.fields.values()) {
|
|
770
|
+
if (!field.readOnly && !data.requestFields.has(field.name)) {
|
|
771
|
+
field.readOnly = true
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
670
776
|
const schema: UnifiedEntitySchema = {
|
|
671
777
|
name: data.name,
|
|
672
778
|
endpoint: data.endpoint,
|
|
@@ -436,6 +436,84 @@ describe('generateManagers', () => {
|
|
|
436
436
|
})
|
|
437
437
|
})
|
|
438
438
|
|
|
439
|
+
describe('baseClass option (#1253)', () => {
|
|
440
|
+
function entityConfig(extra = {}) {
|
|
441
|
+
return {
|
|
442
|
+
schema: {
|
|
443
|
+
name: 'users',
|
|
444
|
+
endpoint: '/users',
|
|
445
|
+
fields: {
|
|
446
|
+
id: { name: 'id', type: 'number' },
|
|
447
|
+
name: { name: 'name', type: 'text', required: true }
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
endpoint: '/users',
|
|
451
|
+
storageImport: '@quazardous/qdadm',
|
|
452
|
+
storageClass: 'ApiStorage',
|
|
453
|
+
...extra
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
it('class mode extends the global baseClass and imports it', async () => {
|
|
458
|
+
await generateManagers({
|
|
459
|
+
output: testOutputDir,
|
|
460
|
+
classMode: true,
|
|
461
|
+
baseClass: { import: '@/managers/SkyEntityManager', name: 'SkyEntityManager' },
|
|
462
|
+
entities: { users: entityConfig() }
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
const content = await readFile(join(testOutputDir, 'users.ts'), 'utf-8')
|
|
466
|
+
expect(content).toContain("import { SkyEntityManager } from '@/managers/SkyEntityManager'")
|
|
467
|
+
expect(content).toContain('extends SkyEntityManager<UsersEntity>')
|
|
468
|
+
expect(content).not.toContain("import { EntityManager } from '@quazardous/qdadm'")
|
|
469
|
+
// Options/record types still come from qdadm
|
|
470
|
+
expect(content).toContain("import type { EntityRecord, EntityManagerOptions } from '@quazardous/qdadm'")
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('instance mode instantiates the baseClass', async () => {
|
|
474
|
+
await generateManagers({
|
|
475
|
+
output: testOutputDir,
|
|
476
|
+
baseClass: { import: './base', name: 'MyBase' },
|
|
477
|
+
entities: { users: entityConfig() }
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
const content = await readFile(join(testOutputDir, 'usersManager.ts'), 'utf-8')
|
|
481
|
+
expect(content).toContain("import { MyBase } from './base'")
|
|
482
|
+
expect(content).toContain('new MyBase<UsersEntity>(')
|
|
483
|
+
expect(content).not.toContain('new EntityManager<UsersEntity>(')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('per-entity baseClass overrides the global one', async () => {
|
|
487
|
+
await generateManagers({
|
|
488
|
+
output: testOutputDir,
|
|
489
|
+
classMode: true,
|
|
490
|
+
baseClass: { import: './globalBase', name: 'GlobalBase' },
|
|
491
|
+
entities: {
|
|
492
|
+
users: entityConfig({
|
|
493
|
+
baseClass: { import: './localBase', name: 'LocalBase' }
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
const content = await readFile(join(testOutputDir, 'users.ts'), 'utf-8')
|
|
499
|
+
expect(content).toContain("import { LocalBase } from './localBase'")
|
|
500
|
+
expect(content).toContain('extends LocalBase<UsersEntity>')
|
|
501
|
+
expect(content).not.toContain('GlobalBase')
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('defaults to EntityManager when no baseClass is given', async () => {
|
|
505
|
+
await generateManagers({
|
|
506
|
+
output: testOutputDir,
|
|
507
|
+
classMode: true,
|
|
508
|
+
entities: { users: entityConfig() }
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
const content = await readFile(join(testOutputDir, 'users.ts'), 'utf-8')
|
|
512
|
+
expect(content).toContain("import { EntityManager } from '@quazardous/qdadm'")
|
|
513
|
+
expect(content).toContain('extends EntityManager<UsersEntity>')
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
439
517
|
describe('default output directory', () => {
|
|
440
518
|
it('uses default output when not specified', async () => {
|
|
441
519
|
// This test would write to actual src/generated/managers/
|
|
@@ -22,6 +22,21 @@ import type { EntityDecorator } from './decorators'
|
|
|
22
22
|
*/
|
|
23
23
|
const DEFAULT_OUTPUT_DIR = 'src/generated/managers/'
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Custom base class for generated managers (#1253).
|
|
27
|
+
*
|
|
28
|
+
* The class MUST extend EntityManager, stay generic over the entity type
|
|
29
|
+
* and pass constructor options through — the generated code does
|
|
30
|
+
* `class Generated<X>Manager extends <name><XEntity>` and calls
|
|
31
|
+
* `super({ ...schema options, storage, ...options })`.
|
|
32
|
+
*/
|
|
33
|
+
export interface GenerateManagersBaseClass {
|
|
34
|
+
/** Import specifier (e.g. '@/managers/SkyEntityManager') */
|
|
35
|
+
import: string
|
|
36
|
+
/** Exported class name (e.g. 'SkyEntityManager') */
|
|
37
|
+
name: string
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Entity configuration for generation
|
|
27
42
|
*/
|
|
@@ -40,6 +55,8 @@ export interface GenerateManagersEntityConfig {
|
|
|
40
55
|
decorators?: EntityDecorator
|
|
41
56
|
/** Generate class instead of instance (for extension) */
|
|
42
57
|
classMode?: boolean
|
|
58
|
+
/** Base class for this entity's generated manager (overrides global) */
|
|
59
|
+
baseClass?: GenerateManagersBaseClass
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
/**
|
|
@@ -50,6 +67,8 @@ export interface GenerateManagersConfig {
|
|
|
50
67
|
output?: string
|
|
51
68
|
/** Generate classes instead of instances (for extension pattern) */
|
|
52
69
|
classMode?: boolean
|
|
70
|
+
/** Base class for all generated managers (default: EntityManager) */
|
|
71
|
+
baseClass?: GenerateManagersBaseClass
|
|
53
72
|
/** Entity configurations keyed by entity name */
|
|
54
73
|
entities: Record<string, GenerateManagersEntityConfig>
|
|
55
74
|
}
|
|
@@ -175,12 +194,19 @@ function serializeValue(value: unknown, indent: number = 0): string {
|
|
|
175
194
|
function generateManagerSource(
|
|
176
195
|
entityName: string,
|
|
177
196
|
entityConfig: GenerateManagersEntityConfig,
|
|
178
|
-
globalOptions: { classMode?: boolean } = {}
|
|
197
|
+
globalOptions: { classMode?: boolean; baseClass?: GenerateManagersBaseClass } = {}
|
|
179
198
|
): string {
|
|
180
199
|
const { schema, endpoint, storageImport, storageClass, storageOptions = {}, decorators } = entityConfig
|
|
181
200
|
const classMode = entityConfig.classMode ?? globalOptions.classMode ?? false
|
|
182
201
|
const className = toPascalCase(entityName)
|
|
183
202
|
|
|
203
|
+
// Custom base class (#1253): per-entity wins over global, default EntityManager.
|
|
204
|
+
const baseClass = entityConfig.baseClass ?? globalOptions.baseClass
|
|
205
|
+
const baseName = baseClass?.name ?? 'EntityManager'
|
|
206
|
+
const baseImport = baseClass
|
|
207
|
+
? `import { ${baseClass.name} } from '${baseClass.import}'`
|
|
208
|
+
: `import { EntityManager } from '@quazardous/qdadm'`
|
|
209
|
+
|
|
184
210
|
// Build storage options object
|
|
185
211
|
const fullStorageOptions: Record<string, unknown> = {
|
|
186
212
|
endpoint,
|
|
@@ -240,7 +266,7 @@ function generateManagerSource(
|
|
|
240
266
|
* Endpoint: ${endpoint}
|
|
241
267
|
*/
|
|
242
268
|
|
|
243
|
-
|
|
269
|
+
${baseImport}
|
|
244
270
|
import type { EntityRecord, EntityManagerOptions } from '@quazardous/qdadm'
|
|
245
271
|
import { ${storageClass} } from '${storageImport}'
|
|
246
272
|
|
|
@@ -249,9 +275,9 @@ ${entityInterface}
|
|
|
249
275
|
/**
|
|
250
276
|
* Generated base manager for ${entityName}
|
|
251
277
|
*
|
|
252
|
-
* @extends
|
|
278
|
+
* @extends ${baseName}
|
|
253
279
|
*/
|
|
254
|
-
export class Generated${className}Manager extends
|
|
280
|
+
export class Generated${className}Manager extends ${baseName}<${className}Entity> {
|
|
255
281
|
constructor(options: Partial<EntityManagerOptions<${className}Entity>> = {}) {
|
|
256
282
|
super({
|
|
257
283
|
...${serializeValue(managerOptions, 2)},
|
|
@@ -274,7 +300,7 @@ export class Generated${className}Manager extends EntityManager<${className}Enti
|
|
|
274
300
|
* Endpoint: ${endpoint}
|
|
275
301
|
*/
|
|
276
302
|
|
|
277
|
-
|
|
303
|
+
${baseImport}
|
|
278
304
|
import type { EntityRecord } from '@quazardous/qdadm'
|
|
279
305
|
import { ${storageClass} } from '${storageImport}'
|
|
280
306
|
|
|
@@ -295,7 +321,7 @@ const storageOptions = ${serializeValue(fullStorageOptions, 0)}
|
|
|
295
321
|
*
|
|
296
322
|
* Provides CRUD operations for ${entityName} entity.
|
|
297
323
|
*/
|
|
298
|
-
export const ${entityName}Manager = new
|
|
324
|
+
export const ${entityName}Manager = new ${baseName}<${className}Entity>({
|
|
299
325
|
...${serializeValue(managerOptions, 0)},
|
|
300
326
|
storage: new ${storageClass}<${className}Entity>(storageOptions)
|
|
301
327
|
})
|
|
@@ -349,7 +375,7 @@ export async function generateManagers(config: GenerateManagersConfig): Promise<
|
|
|
349
375
|
}
|
|
350
376
|
|
|
351
377
|
const outputDir = config.output || DEFAULT_OUTPUT_DIR
|
|
352
|
-
const globalOptions = { classMode: config.classMode ?? false }
|
|
378
|
+
const globalOptions = { classMode: config.classMode ?? false, baseClass: config.baseClass }
|
|
353
379
|
const generatedFiles: string[] = []
|
|
354
380
|
|
|
355
381
|
// Validate each entity config
|
package/src/gen/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -96,6 +96,8 @@ export {
|
|
|
96
96
|
type SeverityMapValue,
|
|
97
97
|
type SeverityMap,
|
|
98
98
|
type RoutingContext,
|
|
99
|
+
type StorageResolution,
|
|
100
|
+
type ResolvedStorage,
|
|
99
101
|
type PresaveContext,
|
|
100
102
|
type PostsaveContext,
|
|
101
103
|
type PredeleteContext,
|
|
@@ -150,6 +152,30 @@ export type {
|
|
|
150
152
|
// ════════════════════════════════════════════════════════════════════════════
|
|
151
153
|
export * from './orchestrator/index'
|
|
152
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Typed manager registry (#1253) — augment it from your app to make
|
|
157
|
+
* `getManager()` / `useEntity()` return your concrete EntityManager
|
|
158
|
+
* subclasses instead of `EntityManager<EntityRecord>`:
|
|
159
|
+
*
|
|
160
|
+
* ```ts
|
|
161
|
+
* declare module '@quazardous/qdadm' {
|
|
162
|
+
* interface QdadmManagerRegistry {
|
|
163
|
+
* bots: BotsManager
|
|
164
|
+
* botPools: BotPoolsManager
|
|
165
|
+
* }
|
|
166
|
+
* }
|
|
167
|
+
* ```
|
|
168
|
+
*
|
|
169
|
+
* Entity names not declared here keep the current fallback behavior.
|
|
170
|
+
*
|
|
171
|
+
* NOTE: deliberately declared in this file (not re-exported from a
|
|
172
|
+
* sub-module) — TypeScript module augmentation does not merge through
|
|
173
|
+
* re-export aliases, and consumers augment '@quazardous/qdadm', which
|
|
174
|
+
* resolves here.
|
|
175
|
+
*/
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
177
|
+
export interface QdadmManagerRegistry {}
|
|
178
|
+
|
|
153
179
|
// ════════════════════════════════════════════════════════════════════════════
|
|
154
180
|
// COMPOSABLES (excluding types that conflict with other modules)
|
|
155
181
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -16,13 +16,24 @@ import { inject } from 'vue'
|
|
|
16
16
|
import type { Orchestrator } from './Orchestrator'
|
|
17
17
|
import type { EntityManager } from '../entity/EntityManager'
|
|
18
18
|
import type { EntityRecord } from '../types'
|
|
19
|
+
// Type-only circular import: QdadmManagerRegistry must live in the package
|
|
20
|
+
// entry (module augmentation does not merge through re-export aliases).
|
|
21
|
+
import type { QdadmManagerRegistry } from '../index'
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Return type for useOrchestrator
|
|
25
|
+
*
|
|
26
|
+
* `getManager` resolves through the consumer-augmentable
|
|
27
|
+
* QdadmManagerRegistry first (#1253): a declared entity name returns its
|
|
28
|
+
* concrete manager subclass; anything else falls back to the historical
|
|
29
|
+
* `EntityManager<T>` signature.
|
|
22
30
|
*/
|
|
23
31
|
export interface UseOrchestratorReturn {
|
|
24
32
|
orchestrator: Orchestrator
|
|
25
|
-
getManager:
|
|
33
|
+
getManager: {
|
|
34
|
+
<K extends keyof QdadmManagerRegistry>(name: K): QdadmManagerRegistry[K]
|
|
35
|
+
<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T>
|
|
36
|
+
}
|
|
26
37
|
hasManager: (name: string) => boolean
|
|
27
38
|
}
|
|
28
39
|
|
|
@@ -46,8 +57,10 @@ export function useOrchestrator(): UseOrchestratorReturn {
|
|
|
46
57
|
* Get an EntityManager by name
|
|
47
58
|
* @param name - Entity name
|
|
48
59
|
*/
|
|
49
|
-
function getManager<
|
|
50
|
-
|
|
60
|
+
function getManager<K extends keyof QdadmManagerRegistry>(name: K): QdadmManagerRegistry[K]
|
|
61
|
+
function getManager<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T>
|
|
62
|
+
function getManager(name: string): unknown {
|
|
63
|
+
return orchestrator.get(name)
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
/**
|
|
@@ -74,7 +87,9 @@ export function useOrchestrator(): UseOrchestratorReturn {
|
|
|
74
87
|
* const { items } = await users.list()
|
|
75
88
|
* ```
|
|
76
89
|
*/
|
|
77
|
-
export function useEntity<
|
|
90
|
+
export function useEntity<K extends keyof QdadmManagerRegistry>(name: K): QdadmManagerRegistry[K]
|
|
91
|
+
export function useEntity<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T>
|
|
92
|
+
export function useEntity(name: string): unknown {
|
|
78
93
|
const { getManager } = useOrchestrator()
|
|
79
|
-
return getManager
|
|
94
|
+
return getManager(name)
|
|
80
95
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-name humanization (#1255)
|
|
3
|
+
*
|
|
4
|
+
* Turns machine field names into presentable labels:
|
|
5
|
+
* 'botUuid' → 'Bot Uuid'
|
|
6
|
+
* 'created_at' → 'Created At'
|
|
7
|
+
* 'filter.botId' → 'Filter Bot Id'
|
|
8
|
+
*
|
|
9
|
+
* Shared by the list column binding helper (useListPage.column()) and the
|
|
10
|
+
* OpenAPI connector's opt-in label inference — dependency-free so the gen
|
|
11
|
+
* side (node) can import it too.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Humanize a field name: split camelCase, snake_case, kebab-case and dotted
|
|
16
|
+
* segments into words, then capitalize each word.
|
|
17
|
+
*/
|
|
18
|
+
export function humanizeFieldName(field: string): string {
|
|
19
|
+
return field
|
|
20
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
21
|
+
.replace(/[_\-.]+/g, ' ')
|
|
22
|
+
.trim()
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
25
|
+
.join(' ')
|
|
26
|
+
}
|
package/src/utils/index.ts
CHANGED