@quazardous/qdadm 2.4.4 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.4.4",
3
+ "version": "2.5.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -22,7 +22,13 @@ type JsonValue = Record<string, unknown> | unknown[] | string | null
22
22
 
23
23
  interface Props {
24
24
  modelValue?: JsonValue
25
- mode?: Mode
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' as Mode,
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: Mode | undefined) => {
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
@@ -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 ────────────────────────────────────────────────────────────────────
@@ -34,6 +34,8 @@ import type { NullSortMode } from '../query/clientFilter'
34
34
 
35
35
  // Re-export public types from types file
36
36
  export type {
37
+ StorageResolution,
38
+ ResolvedStorage,
37
39
  RoutingContext,
38
40
  PresaveContext,
39
41
  PostsaveContext,
@@ -501,10 +503,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
501
503
  * Check if SecurityChecker is configured via authAdapter
502
504
  */
503
505
  protected _hasSecurityChecker(): boolean {
504
- return (
505
- (this.authAdapter as unknown as { _securityChecker?: unknown })
506
- ?._securityChecker != null
507
- )
506
+ return this.authAdapter?.hasSecurityChecker?.() ?? false
508
507
  }
509
508
 
510
509
  /**
@@ -41,6 +41,16 @@ export class EntityAuthAdapter {
41
41
  this._securityChecker = checker
42
42
  }
43
43
 
44
+ /**
45
+ * Whether a SecurityChecker is configured (#1196).
46
+ *
47
+ * Public capability probe — EntityManager used to reach into the
48
+ * private _securityChecker field through a cast.
49
+ */
50
+ hasSecurityChecker(): boolean {
51
+ return this._securityChecker != null
52
+ }
53
+
44
54
  /**
45
55
  * Check if current user is granted an attribute (Symfony-like contract)
46
56
  *
@@ -52,28 +52,24 @@ export interface SecurityCheckerConfig {
52
52
  */
53
53
  export class SecurityChecker {
54
54
  protected _rolesProvider: RoleProvider
55
- protected _legacyRoleHierarchy: RoleHierarchy | null
56
55
  readonly getCurrentUser: () => AuthUser | null
57
56
 
58
57
  constructor({ rolesProvider, roleHierarchy, rolePermissions, getCurrentUser }: SecurityCheckerOptions) {
59
- // Support both new rolesProvider and legacy rolePermissions
60
58
  if (rolesProvider) {
61
59
  this._rolesProvider = rolesProvider
62
- this._legacyRoleHierarchy = null
63
60
  } else {
64
- // Legacy: create static provider from rolePermissions
61
+ // Legacy options (roleHierarchy/rolePermissions) are normalized ONCE
62
+ // into a static provider (#1196) — the parallel _legacyRoleHierarchy
63
+ // branch is gone; only the provider path remains.
65
64
  const hierarchyConfig =
66
- roleHierarchy instanceof RoleHierarchy ? {} : (roleHierarchy as RoleHierarchyConfig) || {}
65
+ roleHierarchy instanceof RoleHierarchy
66
+ ? roleHierarchy.map
67
+ : (roleHierarchy as RoleHierarchyConfig) || {}
67
68
 
68
69
  this._rolesProvider = new StaticRoleProvider({
69
70
  role_hierarchy: hierarchyConfig,
70
71
  role_permissions: rolePermissions || {},
71
72
  })
72
-
73
- this._legacyRoleHierarchy =
74
- roleHierarchy instanceof RoleHierarchy
75
- ? roleHierarchy
76
- : new RoleHierarchy(hierarchyConfig)
77
73
  }
78
74
 
79
75
  this.getCurrentUser = getCurrentUser
@@ -83,9 +79,6 @@ export class SecurityChecker {
83
79
  * Get role hierarchy (dynamically resolved from rolesProvider)
84
80
  */
85
81
  get roleHierarchy(): RoleHierarchy {
86
- if (this._legacyRoleHierarchy) {
87
- return this._legacyRoleHierarchy
88
- }
89
82
  return new RoleHierarchy(this._rolesProvider.getHierarchy())
90
83
  }
91
84
 
@@ -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', () => {