@conduction/nextcloud-vue 0.1.0-beta.14 → 0.1.0-beta.16
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/dist/nextcloud-vue.cjs.js +7282 -3443
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.css +719 -100
- package/dist/nextcloud-vue.esm.js +7120 -3300
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/package.json +3 -2
- package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +36 -3
- package/src/components/CnAdvancedFormDialog/CnMetadataTab.vue +34 -19
- package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +312 -36
- package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +983 -64
- package/src/components/CnAdvancedFormDialog/index.js +3 -0
- package/src/components/CnAppLoading/CnAppLoading.vue +93 -0
- package/src/components/CnAppLoading/index.js +3 -0
- package/src/components/CnAppNav/CnAppNav.vue +269 -0
- package/src/components/CnAppNav/index.js +3 -0
- package/src/components/CnAppRoot/CnAppRoot.vue +201 -0
- package/src/components/CnAppRoot/index.js +3 -0
- package/src/components/CnColorPicker/CnColorPicker.vue +251 -0
- package/src/components/CnColorPicker/index.js +1 -0
- package/src/components/CnContextMenu/CnContextMenu.vue +41 -4
- package/src/components/CnDashboardPage/CnDashboardPage.vue +8 -0
- package/src/components/CnDependencyMissing/CnDependencyMissing.vue +152 -0
- package/src/components/CnDependencyMissing/index.js +3 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +27 -16
- package/src/components/CnIndexPage/CnIndexPage.vue +36 -6
- package/src/components/CnPageRenderer/CnPageRenderer.vue +278 -0
- package/src/components/CnPageRenderer/index.js +4 -0
- package/src/components/CnPageRenderer/pageTypes.js +37 -0
- package/src/components/CnRowActions/CnRowActions.vue +44 -3
- package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +4 -0
- package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +103 -74
- package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +30 -2
- package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +16 -12
- package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +9 -4
- package/src/components/index.js +7 -1
- package/src/composables/index.js +2 -0
- package/src/composables/useAppManifest.js +115 -0
- package/src/composables/useAppStatus.js +107 -0
- package/src/css/CnSchemaFormDialog.css +22 -0
- package/src/index.js +24 -2
- package/src/schemas/app-manifest.schema.json +153 -0
- package/src/types/index.d.ts +9 -0
- package/src/types/manifest.d.ts +88 -0
- package/src/utils/index.js +1 -1
- package/src/utils/schema.js +157 -2
- package/src/utils/validateManifest.js +113 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json",
|
|
4
|
+
"title": "Conduction App Manifest",
|
|
5
|
+
"description": "Schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Each Conduction Nextcloud app declares its routes, menu, page types, widget configuration, and required app dependencies in a single src/manifest.json validated against this schema.",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"type": "object",
|
|
8
|
+
"required": ["version", "menu", "pages"],
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"$schema": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"format": "uri",
|
|
14
|
+
"description": "Optional URL of the schema this manifest validates against. Enables editor auto-validation."
|
|
15
|
+
},
|
|
16
|
+
"version": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$",
|
|
19
|
+
"description": "Semver of the manifest content. Bump when the manifest changes meaningfully. Used for cache busting and app-builder migration tracking. Distinct from the schema's own version."
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"default": [],
|
|
24
|
+
"items": { "type": "string" },
|
|
25
|
+
"description": "Nextcloud app IDs that MUST be installed and enabled for this app to function. CnAppRoot checks each via useAppStatus(appId)."
|
|
26
|
+
},
|
|
27
|
+
"menu": {
|
|
28
|
+
"type": "array",
|
|
29
|
+
"items": { "$ref": "#/$defs/menuItem" },
|
|
30
|
+
"description": "Top-level navigation entries rendered by CnAppNav."
|
|
31
|
+
},
|
|
32
|
+
"pages": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": { "$ref": "#/$defs/page" },
|
|
35
|
+
"description": "Page definitions dispatched by CnPageRenderer. Each page's id is also its vue-router route name. Ids MUST be unique across the array; uniqueness is enforced by useAppManifest at validation time."
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"$defs": {
|
|
39
|
+
"menuItem": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"required": ["id", "label"],
|
|
42
|
+
"additionalProperties": false,
|
|
43
|
+
"description": "A top-level navigation entry. May contain one level of nested children.",
|
|
44
|
+
"properties": {
|
|
45
|
+
"id": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Unique identifier for this menu entry."
|
|
48
|
+
},
|
|
49
|
+
"label": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "i18n translation key resolved by the consuming app's t() function at render time."
|
|
52
|
+
},
|
|
53
|
+
"icon": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "CSS class for the icon (e.g. 'icon-checkmark')."
|
|
56
|
+
},
|
|
57
|
+
"route": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "Vue-router route name (matches a pages[].id) that this entry navigates to."
|
|
60
|
+
},
|
|
61
|
+
"order": {
|
|
62
|
+
"type": "integer",
|
|
63
|
+
"description": "Display order in the menu. Items without an order render last."
|
|
64
|
+
},
|
|
65
|
+
"permission": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "Permission string the user must hold for this entry to render. CnAppNav filters items whose permission is not present in its permissions prop."
|
|
68
|
+
},
|
|
69
|
+
"section": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"enum": ["main", "settings"],
|
|
72
|
+
"default": "main",
|
|
73
|
+
"description": "Where the entry renders inside CnAppNav. \"main\" (default) places it in the top list. \"settings\" places it inside the NcAppNavigationSettings footer group, below the separator. Use \"settings\" for items that act on app configuration, help/docs links, or anything that belongs near Settings."
|
|
74
|
+
},
|
|
75
|
+
"href": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "External URL. When set, the entry opens the URL in a new tab via window.open() instead of dispatching a vue-router navigation. `route` is ignored when `href` is present. Useful for documentation / help links."
|
|
78
|
+
},
|
|
79
|
+
"children": {
|
|
80
|
+
"type": "array",
|
|
81
|
+
"items": { "$ref": "#/$defs/menuItemLeaf" },
|
|
82
|
+
"description": "Nested entries. Only one level of nesting is supported (children cannot themselves have children)."
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"menuItemLeaf": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"required": ["id", "label"],
|
|
89
|
+
"additionalProperties": false,
|
|
90
|
+
"description": "A nested menu entry. Has no further children.",
|
|
91
|
+
"properties": {
|
|
92
|
+
"id": { "type": "string" },
|
|
93
|
+
"label": { "type": "string" },
|
|
94
|
+
"icon": { "type": "string" },
|
|
95
|
+
"route": { "type": "string" },
|
|
96
|
+
"order": { "type": "integer" },
|
|
97
|
+
"permission": { "type": "string" },
|
|
98
|
+
"section": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"enum": ["main", "settings"],
|
|
101
|
+
"default": "main"
|
|
102
|
+
},
|
|
103
|
+
"href": { "type": "string" }
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"page": {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"required": ["id", "route", "type", "title"],
|
|
109
|
+
"additionalProperties": false,
|
|
110
|
+
"description": "A page definition. CnPageRenderer dispatches by `type` and matches by $route.name === page.id.",
|
|
111
|
+
"properties": {
|
|
112
|
+
"id": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"description": "Vue-router route name. MUST be unique across pages[]. CnPageRenderer matches the current route by id only (not by path)."
|
|
115
|
+
},
|
|
116
|
+
"route": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"description": "Path pattern (e.g. '/decisions', '/decisions/:id'). Used by the consuming app when generating its vue-router config from the manifest."
|
|
119
|
+
},
|
|
120
|
+
"type": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"description": "Page type. Must match a key in the renderer's `pageTypes` registry (library defaults: \"index\", \"detail\", \"dashboard\") OR be \"custom\" — in which case `component` resolves against the customComponents registry. Library extensions add their built-in types to `defaultPageTypes`; consumer apps pass a merged map via the `pageTypes` prop on CnAppRoot / CnPageRenderer."
|
|
123
|
+
},
|
|
124
|
+
"title": {
|
|
125
|
+
"type": "string",
|
|
126
|
+
"description": "i18n translation key for the page title."
|
|
127
|
+
},
|
|
128
|
+
"config": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"description": "Type-specific configuration. For type='index': { register, schema, columns, actions }. For type='detail': { register, schema, tabs }. For type='dashboard': { widgets, layout }. For type='custom': any shape the custom component expects.",
|
|
131
|
+
"additionalProperties": true
|
|
132
|
+
},
|
|
133
|
+
"component": {
|
|
134
|
+
"type": "string",
|
|
135
|
+
"description": "For type='custom': name resolved against the app-provided customComponents registry that is passed to CnAppRoot at boot."
|
|
136
|
+
},
|
|
137
|
+
"headerComponent": {
|
|
138
|
+
"type": "string",
|
|
139
|
+
"description": "Optional registry name for a component injected into the page's #header slot. Enables partial bailout without going full type='custom'."
|
|
140
|
+
},
|
|
141
|
+
"actionsComponent": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"description": "Optional registry name for a component injected into the page's #actions slot. Enables partial bailout without going full type='custom'. Equivalent to `slots.actions`; takes precedence when both are set."
|
|
144
|
+
},
|
|
145
|
+
"slots": {
|
|
146
|
+
"type": "object",
|
|
147
|
+
"description": "Generic slot-override map. Each key is the name of a scoped slot exposed by the dispatched page component (e.g. 'create-dialog', 'form-fields', 'row-actions', 'empty'); each value is a registry component name resolved against the customComponents registry passed to CnAppRoot. CnPageRenderer creates a scoped-slot template for each entry and forwards all slot-scope props to the resolved component. Use this to preserve every slot override the underlying Cn*Page supports without writing a wrapper component.",
|
|
148
|
+
"additionalProperties": { "type": "string" }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/types/index.d.ts
CHANGED
|
@@ -34,6 +34,15 @@ export type { TFile } from './file'
|
|
|
34
34
|
export type { TTask, TTaskPriority, TTaskStatus } from './task'
|
|
35
35
|
export type { TNotification, TNotificationType, TNotificationPriority } from './notification'
|
|
36
36
|
|
|
37
|
+
// Manifest types (json-manifest-renderer capability)
|
|
38
|
+
export type {
|
|
39
|
+
TManifest,
|
|
40
|
+
TManifestMenuItem,
|
|
41
|
+
TManifestMenuItemLeaf,
|
|
42
|
+
TManifestPage,
|
|
43
|
+
TPageType,
|
|
44
|
+
} from './manifest'
|
|
45
|
+
|
|
37
46
|
// Runtime exports from the store factory. The implementation is in
|
|
38
47
|
// `../store/createCrudStore.js`; its companion `createCrudStore.d.ts`
|
|
39
48
|
// provides full generic types (entity inference, feature-flag gating,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript type definitions for the Conduction app manifest.
|
|
3
|
+
*
|
|
4
|
+
* The shape mirrors `src/schemas/app-manifest.schema.json` (JSON Schema
|
|
5
|
+
* draft 2020-12). Apps consume these types when authoring their
|
|
6
|
+
* `src/manifest.json` and when interacting with `useAppManifest`.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import type { TManifest, TManifestPage } from '@conduction/nextcloud-vue'
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Built-in page types shipped by the library. The `type` field of a
|
|
14
|
+
* manifest page is a string that should match a key in the resolved
|
|
15
|
+
* `pageTypes` registry (library defaults plus any consumer extensions)
|
|
16
|
+
* OR be `"custom"`, in which case `component` is resolved against the
|
|
17
|
+
* customComponents registry.
|
|
18
|
+
*
|
|
19
|
+
* Apps with custom built-in types declare those keys in their own
|
|
20
|
+
* pageTypes map and may extend this string union locally for type
|
|
21
|
+
* safety.
|
|
22
|
+
*/
|
|
23
|
+
export type TPageType = 'index' | 'detail' | 'dashboard' | 'custom' | (string & {})
|
|
24
|
+
|
|
25
|
+
/** Where a menu entry renders inside CnAppNav. */
|
|
26
|
+
export type TManifestMenuSection = 'main' | 'settings'
|
|
27
|
+
|
|
28
|
+
/** A nested menu entry. Cannot have further children. */
|
|
29
|
+
export interface TManifestMenuItemLeaf {
|
|
30
|
+
id: string
|
|
31
|
+
label: string
|
|
32
|
+
icon?: string
|
|
33
|
+
route?: string
|
|
34
|
+
order?: number
|
|
35
|
+
permission?: string
|
|
36
|
+
/**
|
|
37
|
+
* Placement within CnAppNav. `"main"` (default) renders in the top
|
|
38
|
+
* list; `"settings"` renders inside the NcAppNavigationSettings
|
|
39
|
+
* footer group.
|
|
40
|
+
*/
|
|
41
|
+
section?: TManifestMenuSection
|
|
42
|
+
/**
|
|
43
|
+
* External URL. When set, the item opens this URL in a new tab and
|
|
44
|
+
* `route` is ignored.
|
|
45
|
+
*/
|
|
46
|
+
href?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A top-level menu entry. May contain one level of nested children. */
|
|
50
|
+
export interface TManifestMenuItem extends TManifestMenuItemLeaf {
|
|
51
|
+
children?: TManifestMenuItemLeaf[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A page definition. `id` doubles as the vue-router route name; the
|
|
56
|
+
* renderer matches by `$route.name === page.id`. `route` is the path
|
|
57
|
+
* pattern, used when the consuming app builds its router config.
|
|
58
|
+
*/
|
|
59
|
+
export interface TManifestPage {
|
|
60
|
+
id: string
|
|
61
|
+
route: string
|
|
62
|
+
type: TPageType
|
|
63
|
+
title: string
|
|
64
|
+
config?: Record<string, unknown>
|
|
65
|
+
component?: string
|
|
66
|
+
headerComponent?: string
|
|
67
|
+
actionsComponent?: string
|
|
68
|
+
/**
|
|
69
|
+
* Generic slot-override map: slot name → registry component name.
|
|
70
|
+
* Forwarded by CnPageRenderer to the dispatched page component as
|
|
71
|
+
* scoped slots, preserving every override the underlying Cn*Page
|
|
72
|
+
* exposes (`#create-dialog`, `#form-fields`, `#row-actions`, etc.).
|
|
73
|
+
*/
|
|
74
|
+
slots?: Record<string, string>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Top-level manifest shape. `version` is the semver of the manifest
|
|
79
|
+
* content (distinct from the schema's own version). `dependencies`
|
|
80
|
+
* lists Nextcloud app IDs that must be installed and enabled.
|
|
81
|
+
*/
|
|
82
|
+
export interface TManifest {
|
|
83
|
+
$schema?: string
|
|
84
|
+
version: string
|
|
85
|
+
dependencies?: string[]
|
|
86
|
+
menu: TManifestMenuItem[]
|
|
87
|
+
pages: TManifestPage[]
|
|
88
|
+
}
|
package/src/utils/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { buildHeaders, buildQueryString } from './headers.js'
|
|
2
2
|
export { parseResponseError, networkError, genericError } from './errors.js'
|
|
3
|
-
export { columnsFromSchema, formatValue, filtersFromSchema, fieldsFromSchema } from './schema.js'
|
|
3
|
+
export { columnsFromSchema, formatValue, filtersFromSchema, fieldsFromSchema, validateValue } from './schema.js'
|
|
4
4
|
export { filterWidgetsByVisibility, isWidgetVisible, getCurrentUserId, getCurrentUserGroups, resetVisibilityCache } from './widgetVisibility.js'
|
package/src/utils/schema.js
CHANGED
|
@@ -152,9 +152,13 @@ export function formatValue(value, property = {}, options = {}) {
|
|
|
152
152
|
return `${value.slice(0, 3).join(', ')} +${value.length - 3}`
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
// Object
|
|
155
|
+
// Object — render as JSON; tables truncate, multi-line value cells wrap with `<pre>`.
|
|
156
156
|
if (type === 'object' || (typeof value === 'object' && value !== null)) {
|
|
157
|
-
|
|
157
|
+
try {
|
|
158
|
+
return truncateString(JSON.stringify(value, null, 2), truncate)
|
|
159
|
+
} catch {
|
|
160
|
+
return '[Object]'
|
|
161
|
+
}
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
// String types
|
|
@@ -421,3 +425,154 @@ export function filtersFromSchema(schema) {
|
|
|
421
425
|
return filter
|
|
422
426
|
})
|
|
423
427
|
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* URL-like string formats — values must parse as a `URL` to be considered valid.
|
|
431
|
+
*/
|
|
432
|
+
const URL_FORMATS = new Set([
|
|
433
|
+
'url', 'uri', 'uri-reference', 'iri', 'iri-reference', 'uri-template',
|
|
434
|
+
'accessUrl', 'shareUrl', 'downloadUrl',
|
|
435
|
+
])
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Regex-based validators for additional standard string formats.
|
|
439
|
+
*/
|
|
440
|
+
const FORMAT_PATTERNS = {
|
|
441
|
+
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/i,
|
|
442
|
+
'idn-email': /^[^\s@]+@[^\s@]+\.[^\s@]+$/i,
|
|
443
|
+
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
444
|
+
ipv4: /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/,
|
|
445
|
+
ipv6: /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/i,
|
|
446
|
+
hostname: /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i,
|
|
447
|
+
semver: /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/,
|
|
448
|
+
'color-hex': /^#[0-9a-f]{6}$/i,
|
|
449
|
+
'color-hex-alpha': /^#[0-9a-f]{8}$/i,
|
|
450
|
+
'color-rgb': /^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/i,
|
|
451
|
+
'color-rgba': /^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*[\d.]+\s*\)$/i,
|
|
452
|
+
'color-hsl': /^hsl\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*\)$/i,
|
|
453
|
+
'color-hsla': /^hsla\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*,\s*[\d.]+\s*\)$/i,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Validate a single value against a JSON-Schema-style property definition.
|
|
458
|
+
*
|
|
459
|
+
* Returns null when the value is valid, otherwise a short English error
|
|
460
|
+
* message describing the violation (caller is responsible for translation).
|
|
461
|
+
* An empty value (`null`/`undefined`/`''`) is considered valid here unless
|
|
462
|
+
* `options.required` is set; required-ness is typically enforced separately
|
|
463
|
+
* by the form so an empty input doesn't show a redundant inline error.
|
|
464
|
+
*
|
|
465
|
+
* @param {*} value The value to validate.
|
|
466
|
+
* @param {object} [property] The schema property definition.
|
|
467
|
+
* @param {object} [options] Extra checks.
|
|
468
|
+
* @param {boolean} [options.required] When true, an empty value is reported.
|
|
469
|
+
* @return {string|null}
|
|
470
|
+
*/
|
|
471
|
+
export function validateValue(value, property = {}, options = {}) {
|
|
472
|
+
const { required = false } = options
|
|
473
|
+
const empty = value === null || value === undefined || value === ''
|
|
474
|
+
|| (Array.isArray(value) && value.length === 0)
|
|
475
|
+
if (empty) {
|
|
476
|
+
return required ? 'This field is required.' : null
|
|
477
|
+
}
|
|
478
|
+
const type = property.type || 'string'
|
|
479
|
+
if (type === 'integer') {
|
|
480
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) return 'Value must be an integer.'
|
|
481
|
+
} else if (type === 'number') {
|
|
482
|
+
if (typeof value !== 'number' || Number.isNaN(value)) return 'Value must be a number.'
|
|
483
|
+
}
|
|
484
|
+
if (type === 'integer' || type === 'number') {
|
|
485
|
+
if (typeof property.minimum === 'number' && value < property.minimum) {
|
|
486
|
+
return `Value must be at least ${property.minimum}.`
|
|
487
|
+
}
|
|
488
|
+
if (typeof property.maximum === 'number' && value > property.maximum) {
|
|
489
|
+
return `Value must be at most ${property.maximum}.`
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (type === 'string') {
|
|
493
|
+
if (typeof value !== 'string') return 'Value must be a string.'
|
|
494
|
+
if (typeof property.minLength === 'number' && value.length < property.minLength) {
|
|
495
|
+
return `Must be at least ${property.minLength} characters.`
|
|
496
|
+
}
|
|
497
|
+
if (typeof property.maxLength === 'number' && value.length > property.maxLength) {
|
|
498
|
+
return `Must be at most ${property.maxLength} characters.`
|
|
499
|
+
}
|
|
500
|
+
if (property.pattern) {
|
|
501
|
+
try {
|
|
502
|
+
if (!new RegExp(property.pattern).test(value)) {
|
|
503
|
+
return 'Value does not match the required pattern.'
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
// Ignore broken schema patterns.
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (property.const !== undefined && value !== property.const) {
|
|
510
|
+
return `Value must be '${property.const}'.`
|
|
511
|
+
}
|
|
512
|
+
const fmtErr = validateStringFormat(property.format, value)
|
|
513
|
+
if (fmtErr) return fmtErr
|
|
514
|
+
}
|
|
515
|
+
if (type === 'array') {
|
|
516
|
+
if (!Array.isArray(value)) return 'Value must be a list.'
|
|
517
|
+
if (typeof property.minItems === 'number' && value.length < property.minItems) {
|
|
518
|
+
return `Select at least ${property.minItems} items.`
|
|
519
|
+
}
|
|
520
|
+
if (typeof property.maxItems === 'number' && value.length > property.maxItems) {
|
|
521
|
+
return `Select at most ${property.maxItems} items.`
|
|
522
|
+
}
|
|
523
|
+
if (property.items && typeof property.items === 'object') {
|
|
524
|
+
for (let i = 0; i < value.length; i++) {
|
|
525
|
+
const itemErr = validateValue(value[i], property.items)
|
|
526
|
+
if (itemErr) return `Item ${i + 1}: ${itemErr}`
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (type === 'boolean' && typeof value !== 'boolean') return 'Value must be a boolean.'
|
|
531
|
+
if (Array.isArray(property.enum) && property.enum.length > 0 && !property.enum.includes(value)) {
|
|
532
|
+
return 'Value must be one of the allowed options.'
|
|
533
|
+
}
|
|
534
|
+
return null
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Validate a string value against a JSON-Schema `format`.
|
|
539
|
+
* @param {string} format Schema format identifier.
|
|
540
|
+
* @param {string} value String value to validate.
|
|
541
|
+
* @return {string|null} Error message or null when valid.
|
|
542
|
+
*/
|
|
543
|
+
function validateStringFormat(format, value) {
|
|
544
|
+
if (!format) return null
|
|
545
|
+
if (format === 'time') {
|
|
546
|
+
// HTML5 `<input type="time">` produces `HH:MM` or `HH:MM:SS[.sss]`.
|
|
547
|
+
// `new Date()` won't parse a bare time, so check the shape directly.
|
|
548
|
+
return /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?$/.test(value)
|
|
549
|
+
? null
|
|
550
|
+
: 'Value must be a valid time.'
|
|
551
|
+
}
|
|
552
|
+
if (format === 'date' || format === 'date-time') {
|
|
553
|
+
return Number.isNaN(new Date(value).getTime()) ? `Value must be a valid ${format}.` : null
|
|
554
|
+
}
|
|
555
|
+
if (URL_FORMATS.has(format)) {
|
|
556
|
+
// Accept fully-qualified URLs (`https://example.com`) and protocol-less
|
|
557
|
+
// shorthand (`example.com/path`) by retrying with an `https://` prefix.
|
|
558
|
+
// Reject obviously non-URL inputs (whitespace, missing dots / authority).
|
|
559
|
+
if (/\s/.test(value)) return 'Value must be a valid URL.'
|
|
560
|
+
try {
|
|
561
|
+
/* eslint-disable-next-line no-new */
|
|
562
|
+
new URL(value)
|
|
563
|
+
return null
|
|
564
|
+
} catch {
|
|
565
|
+
// Fall through to the prefix retry.
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const parsed = new URL('https://' + value)
|
|
569
|
+
if (parsed.hostname && parsed.hostname.includes('.')) return null
|
|
570
|
+
} catch {
|
|
571
|
+
// Fall through to the rejection below.
|
|
572
|
+
}
|
|
573
|
+
return 'Value must be a valid URL.'
|
|
574
|
+
}
|
|
575
|
+
const re = FORMAT_PATTERNS[format]
|
|
576
|
+
if (re && !re.test(value)) return `Value must be a valid '${format}'.`
|
|
577
|
+
return null
|
|
578
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate a manifest object against the manifest JSON Schema.
|
|
3
|
+
*
|
|
4
|
+
* Hand-rolled minimal validator covering the rules required by
|
|
5
|
+
* REQ-JMR-001 of the json-manifest-renderer spec:
|
|
6
|
+
* - Top-level `version`, `menu`, `pages` are required.
|
|
7
|
+
* - `version` matches the semver pattern.
|
|
8
|
+
* - `pages[].type` is a non-empty string. Whether the type resolves
|
|
9
|
+
* to a real component is checked by CnPageRenderer at render time
|
|
10
|
+
* against the consumer-resolved `pageTypes` map; the validator
|
|
11
|
+
* cannot enforce that without knowing the runtime registry.
|
|
12
|
+
* - `pages[].id` is unique across the array.
|
|
13
|
+
* - Required fields on menu items and pages are present.
|
|
14
|
+
* - `dependencies` (when present) is an array of strings.
|
|
15
|
+
* - `pages[].component` is required when `type` is "custom".
|
|
16
|
+
*
|
|
17
|
+
* The richer schema constraints (`additionalProperties: false`, `format`
|
|
18
|
+
* URI, etc.) are enforced by the BE / hydra CI validators that consume
|
|
19
|
+
* the same schema file with Ajv. The FE validator is intentionally
|
|
20
|
+
* narrow so a FE-only failure produces tight, actionable messages.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} manifest The manifest object to validate.
|
|
23
|
+
* @param {object} [options] Optional validation options.
|
|
24
|
+
* @param {Array<string>} [options.allowedTypes] When provided, restrict
|
|
25
|
+
* the allowed `pages[].type` values to this list (plus `"custom"`).
|
|
26
|
+
* Useful in CI / build-time checks where the consumer's full
|
|
27
|
+
* `pageTypes` registry is known up-front. When omitted, any
|
|
28
|
+
* non-empty string is accepted; the runtime renderer logs a warning
|
|
29
|
+
* for unknown types.
|
|
30
|
+
* @return {{ valid: boolean, errors: string[] }}
|
|
31
|
+
*/
|
|
32
|
+
export function validateManifest(manifest, options = {}) {
|
|
33
|
+
const errors = []
|
|
34
|
+
|
|
35
|
+
if (!isPlainObject(manifest)) {
|
|
36
|
+
return { valid: false, errors: ['manifest must be an object'] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
40
|
+
|
|
41
|
+
if (typeof manifest.version !== 'string') {
|
|
42
|
+
errors.push('/version must be a string')
|
|
43
|
+
} else if (!versionPattern.test(manifest.version)) {
|
|
44
|
+
errors.push(`/version "${manifest.version}" must match semver pattern`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!Array.isArray(manifest.menu)) {
|
|
48
|
+
errors.push('/menu must be an array')
|
|
49
|
+
} else {
|
|
50
|
+
manifest.menu.forEach((item, index) => {
|
|
51
|
+
if (!isPlainObject(item)) {
|
|
52
|
+
errors.push(`/menu/${index} must be an object`)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (typeof item.id !== 'string') errors.push(`/menu/${index}/id must be a string`)
|
|
56
|
+
if (typeof item.label !== 'string') errors.push(`/menu/${index}/label must be a string`)
|
|
57
|
+
if (item.children !== undefined && !Array.isArray(item.children)) {
|
|
58
|
+
errors.push(`/menu/${index}/children must be an array`)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const allowedTypes = Array.isArray(options.allowedTypes)
|
|
64
|
+
? Array.from(new Set([...options.allowedTypes, 'custom']))
|
|
65
|
+
: null
|
|
66
|
+
|
|
67
|
+
if (!Array.isArray(manifest.pages)) {
|
|
68
|
+
errors.push('/pages must be an array')
|
|
69
|
+
} else {
|
|
70
|
+
const seenIds = new Set()
|
|
71
|
+
manifest.pages.forEach((page, index) => {
|
|
72
|
+
if (!isPlainObject(page)) {
|
|
73
|
+
errors.push(`/pages/${index} must be an object`)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
if (typeof page.id !== 'string') {
|
|
77
|
+
errors.push(`/pages/${index}/id must be a string`)
|
|
78
|
+
} else if (seenIds.has(page.id)) {
|
|
79
|
+
errors.push(`/pages/${index}/id "${page.id}" must be unique within pages[]`)
|
|
80
|
+
} else {
|
|
81
|
+
seenIds.add(page.id)
|
|
82
|
+
}
|
|
83
|
+
if (typeof page.route !== 'string') errors.push(`/pages/${index}/route must be a string`)
|
|
84
|
+
if (typeof page.title !== 'string') errors.push(`/pages/${index}/title must be a string`)
|
|
85
|
+
if (typeof page.type !== 'string' || page.type.length === 0) {
|
|
86
|
+
errors.push(`/pages/${index}/type must be a non-empty string`)
|
|
87
|
+
} else if (allowedTypes && !allowedTypes.includes(page.type)) {
|
|
88
|
+
errors.push(`/pages/${index}/type "${page.type}" must be one of: ${allowedTypes.join(', ')}`)
|
|
89
|
+
}
|
|
90
|
+
if (page.type === 'custom' && typeof page.component !== 'string') {
|
|
91
|
+
errors.push(`/pages/${index}/component is required when type is "custom"`)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (manifest.dependencies !== undefined) {
|
|
97
|
+
if (!Array.isArray(manifest.dependencies)) {
|
|
98
|
+
errors.push('/dependencies must be an array of strings')
|
|
99
|
+
} else {
|
|
100
|
+
manifest.dependencies.forEach((dep, index) => {
|
|
101
|
+
if (typeof dep !== 'string') {
|
|
102
|
+
errors.push(`/dependencies/${index} must be a string`)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { valid: errors.length === 0, errors }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isPlainObject(value) {
|
|
112
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
113
|
+
}
|