From f2746e13150242f7cefa7548364520b603e06f9e Mon Sep 17 00:00:00 2001 From: Thijn Date: Fri, 17 Apr 2026 15:28:09 +0200 Subject: [PATCH 001/143] Added resize observer to automatically fix width of chart --- package-lock.json | 142 ------------------ .../CnChartWidget/CnChartWidget.vue | 27 ++++ 2 files changed, 27 insertions(+), 142 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4fb9f46..edd4ce3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3644,17 +3644,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-api": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vue/devtools-kit": "^7.7.9" - } - }, "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-shared": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", @@ -3810,29 +3799,6 @@ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "license": "MIT" }, - "node_modules/@nextcloud/dialogs/node_modules/pinia": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", - "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vue/devtools-api": "^7.7.7" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.5.0", - "vue": "^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@nextcloud/dialogs/node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -7045,34 +7011,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vue/devtools-shared": "^7.7.9", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "rfdc": "^1.4.1" - } - }, "node_modules/@vue/eslint-config-typescript": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-13.0.0.tgz", @@ -18177,14 +18115,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -21195,14 +21125,6 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -23597,14 +23519,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -24915,17 +24829,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/split2": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", @@ -25777,51 +25680,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/superjson/node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/superjson/node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", diff --git a/src/components/CnChartWidget/CnChartWidget.vue b/src/components/CnChartWidget/CnChartWidget.vue index 6d9a9ca..52a0e65 100644 --- a/src/components/CnChartWidget/CnChartWidget.vue +++ b/src/components/CnChartWidget/CnChartWidget.vue @@ -13,6 +13,7 @@ { + const newWidth = entries[0]?.contentRect?.width ?? this.$el.offsetWidth + if (newWidth === this._lastWidth) return + this._lastWidth = newWidth + clearTimeout(this._resizeTimer) + this._resizeTimer = setTimeout(() => { + if (this.$refs.chart?.refresh) { + this.$refs.chart.refresh() + } + }, 100) + }) + this._resizeObserver.observe(this.$el) + }, + + beforeDestroy() { + clearTimeout(this._resizeTimer) + if (this._resizeObserver) { + this._resizeObserver.disconnect() + this._resizeObserver = null + } + }, + methods: { /** * Deep merge two objects (target wins on conflict) From 9e4110e61db39f602d122b110b836ffdc2b7de73 Mon Sep 17 00:00:00 2001 From: Thijn Date: Fri, 17 Apr 2026 15:35:13 +0200 Subject: [PATCH 002/143] package-lock update --- package-lock.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index edd4ce3..3f38f25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2108,15 +2108,15 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, @@ -2130,9 +2130,9 @@ "peer": true }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -2151,9 +2151,9 @@ "peer": true }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, From b89c18d74cf92ccae7cab0c16baffeb0b13bb0f0 Mon Sep 17 00:00:00 2001 From: Thijn Date: Fri, 17 Apr 2026 15:38:06 +0200 Subject: [PATCH 003/143] 3rd times the charm --- package-lock.json | 544 +++++++++++++++++++++++++++++----------------- 1 file changed, 350 insertions(+), 194 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3f38f25..d6a6222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,9 +103,9 @@ } }, "node_modules/@actions/http-client/node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", "dev": true, "license": "MIT", "peer": true, @@ -2497,9 +2497,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -3302,9 +3302,9 @@ } }, "node_modules/@lezer/common": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", - "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "license": "MIT" }, "node_modules/@lezer/css": { @@ -3361,9 +3361,9 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", - "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.9.tgz", + "integrity": "sha512-mF6irshW4nRJEhdR0HOAxxTDGss+rQFqA0nLRlZsPh14q+DB9Fqp0YbOvyRSOeKPLfUL/w5wPQAcETvkQ1VApg==", "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -3460,13 +3460,14 @@ } }, "node_modules/@nextcloud/auth": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-2.5.3.tgz", - "integrity": "sha512-KIhWLk0BKcP4hvypE4o11YqKOPeFMfEFjRrhUUF+h7Fry+dhTBIEIxuQPVCKXMIpjTDd8791y8V6UdRZ2feKAQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-2.6.0.tgz", + "integrity": "sha512-VkT87+9UqpPi7O36bVEE4/MxWF8d90VQcuMlvKltsZyLSLkEGrPXgowtD75Y54k60/8SR6mXbeqBwapi8dDUbA==", "license": "GPL-3.0-or-later", "dependencies": { "@nextcloud/browser-storage": "^0.5.0", - "@nextcloud/event-bus": "^3.3.2" + "@nextcloud/event-bus": "^3.3.3", + "@nextcloud/router": "^3.1.0" }, "engines": { "node": "^20.0.0 || ^22.0.0 || ^24.0.0" @@ -3628,22 +3629,32 @@ "license": "MIT" }, "node_modules/@nextcloud/dialogs/node_modules/@vue/compiler-sfc": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz", - "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", - "@vue/compiler-core": "3.5.31", - "@vue/compiler-dom": "3.5.31", - "@vue/compiler-ssr": "3.5.31", - "@vue/shared": "3.5.31", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, + "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, "node_modules/@nextcloud/dialogs/node_modules/@vue/devtools-shared": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", @@ -3651,16 +3662,16 @@ "license": "MIT" }, "node_modules/@nextcloud/dialogs/node_modules/@vue/server-renderer": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.31.tgz", - "integrity": "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.31", - "@vue/shared": "3.5.31" + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" }, "peerDependencies": { - "vue": "3.5.31" + "vue": "3.5.32" } }, "node_modules/@nextcloud/dialogs/node_modules/@vuepic/vue-datepicker": { @@ -3799,6 +3810,28 @@ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "license": "MIT" }, + "node_modules/@nextcloud/dialogs/node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@nextcloud/dialogs/node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -3840,16 +3873,16 @@ } }, "node_modules/@nextcloud/dialogs/node_modules/vue": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz", - "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.31", - "@vue/compiler-sfc": "3.5.31", - "@vue/runtime-dom": "3.5.31", - "@vue/server-renderer": "3.5.31", - "@vue/shared": "3.5.31" + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" }, "peerDependencies": { "typescript": "*" @@ -4315,6 +4348,18 @@ "eslint-scope": "5.1.1" } }, + "node_modules/@nodable/entities": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-1.1.0.tgz", + "integrity": "sha512-bidpxmTBP0pOsxULw6XlxzQpTgrAGLDHGBK/JuWhPDL6ZV0GZ/PmN9CA9do6e+A9lYI6qx6ikJUtJYRxup141g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6080,13 +6125,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/normalize-package-data": { @@ -6363,9 +6408,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "peer": true, @@ -6814,16 +6859,16 @@ } }, "node_modules/@vue-macros/common/node_modules/@vue/compiler-sfc": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz", - "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", - "@vue/compiler-core": "3.5.31", - "@vue/compiler-dom": "3.5.31", - "@vue/compiler-ssr": "3.5.31", - "@vue/shared": "3.5.31", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", @@ -6831,13 +6876,13 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz", - "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", - "@vue/shared": "3.5.31", + "@vue/shared": "3.5.32", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" @@ -6856,13 +6901,13 @@ } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz", - "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.31", - "@vue/shared": "3.5.31" + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/compiler-sfc": { @@ -6888,13 +6933,13 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz", - "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.31", - "@vue/shared": "3.5.31" + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/component-compiler": { @@ -7011,6 +7056,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, "node_modules/@vue/eslint-config-typescript": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-13.0.0.tgz", @@ -7038,40 +7109,40 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz", - "integrity": "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.31" + "@vue/shared": "3.5.32" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.31.tgz", - "integrity": "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.31", - "@vue/shared": "3.5.31" + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.31.tgz", - "integrity": "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.31", - "@vue/runtime-core": "3.5.31", - "@vue/shared": "3.5.31", + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", "csstype": "^3.2.3" } }, "node_modules/@vue/shared": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz", - "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -7687,9 +7758,9 @@ } }, "node_modules/apexcharts": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.4.tgz", - "integrity": "sha512-gt0VUqZ2+mr25ScbUcKZgJr96jKYm4vjOcxEWCEh/E5F4dWqhyo3dBhPRvNNnkKiWxkMd2cBwj3ZYH3rK39fkA==", + "version": "5.10.6", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.6.tgz", + "integrity": "sha512-FJQGbso3iRuOwUYnj0yUhkWeKeJE6aboVol+ae09lsc+lbLMWZqSRbrAWVa/qishLiaeG2icxdvmVkm+9n6kOQ==", "license": "SEE LICENSE IN LICENSE", "peer": true }, @@ -7974,9 +8045,9 @@ } }, "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -8238,9 +8309,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8343,9 +8414,9 @@ "peer": true }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -8460,15 +8531,15 @@ "license": "MIT" }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -8567,9 +8638,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", "dev": true, "funding": [ { @@ -10324,9 +10395,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -10414,9 +10485,9 @@ } }, "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -10453,9 +10524,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", "dev": true, "license": "ISC" }, @@ -10756,9 +10827,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -12133,9 +12204,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", @@ -12148,9 +12219,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.5.tgz", - "integrity": "sha512-cK9c5I/DwIOI7/Q7AlGN3DuTdwN61gwSfL8rvuVPK+0mcCNHHGxRrpiFtaZZRfRMJL3Gl8B2AFlBG6qXf03w9A==", + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz", + "integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==", "dev": true, "funding": [ { @@ -12397,9 +12468,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -12727,9 +12798,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -12798,9 +12869,9 @@ "peer": true }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -13307,9 +13378,9 @@ } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "peer": true, @@ -16375,9 +16446,9 @@ } }, "node_modules/js-beautify/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -18115,6 +18186,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "extraneous": true, + "license": "MIT" + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -20870,9 +20948,9 @@ } }, "node_modules/p-queue": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.1.tgz", - "integrity": "sha512-yQS1vV2V7Q14MQrgD8jMNY5owPuGgVHVdSK8NqmKpOVajnjbaeMa6uLOzTALPtvJ7Vo4bw0BGsw7qfUT8z24Ig==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.2.tgz", + "integrity": "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw==", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.1", @@ -21038,9 +21116,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", - "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", @@ -21125,6 +21203,13 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "extraneous": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -21367,9 +21452,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "funding": [ { "type": "opencollective", @@ -22820,9 +22905,9 @@ } }, "node_modules/read-package-up/node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", "dev": true, "license": "(MIT OR CC0-1.0)", "peer": true, @@ -22975,9 +23060,9 @@ } }, "node_modules/read-pkg/node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", "dev": true, "license": "(MIT OR CC0-1.0)", "peer": true, @@ -23149,9 +23234,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -23436,12 +23521,13 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -23519,6 +23605,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "extraneous": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -23876,9 +23969,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.98.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", - "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", "optional": true, @@ -24513,14 +24606,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -24829,6 +24922,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "extraneous": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", @@ -25680,6 +25783,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/superjson/node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/superjson/node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -26196,13 +26341,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -26608,9 +26753,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "peer": true, @@ -26619,9 +26764,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -27091,9 +27236,9 @@ } }, "node_modules/vue-codemirror6": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/vue-codemirror6/-/vue-codemirror6-1.5.0.tgz", - "integrity": "sha512-lJ68v6iEwdOVxSSaD8R7ZGBf/oofPfDQVqizu5fJWxI7v07qhAS6RHLOq20vPCHnsXG11dfsNvv7QWPCnn+NWg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/vue-codemirror6/-/vue-codemirror6-1.5.1.tgz", + "integrity": "sha512-Ey1uQ5ypB2UregJWhWwjRo/YvElzVUlQWBeCHntoLM361bb6iTMS5GTgni+IHdl5D7rEpRRCpAvYRZQidxe5Ag==", "license": "MIT", "dependencies": { "vue-demi": "latest" @@ -27433,18 +27578,18 @@ } }, "node_modules/webdav/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/webdav/node_modules/fast-xml-parser": { - "version": "5.5.9", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", - "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.6.0.tgz", + "integrity": "sha512-5G+uaEBbOm9M4dgMOV3K/rBzfUNGqGqoUTaYJM3hBwM8t71w07gxLQZoTsjkY8FtfjabqgQHEkeIySBDYeBmJw==", "funding": [ { "type": "github", @@ -27453,9 +27598,10 @@ ], "license": "MIT", "dependencies": { + "@nodable/entities": "^1.1.0", "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.2" + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -27495,9 +27641,9 @@ } }, "node_modules/webdav/node_modules/strnum": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", - "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "funding": [ { "type": "github", @@ -27517,9 +27663,9 @@ } }, "node_modules/webpack": { - "version": "5.105.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", - "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", "peer": true, @@ -27540,9 +27686,8 @@ "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", + "mime-db": "^1.54.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", @@ -27583,6 +27728,17 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", From 6efeb09770ec018c23d99c7532870d421b3afde9 Mon Sep 17 00:00:00 2001 From: Thijn Date: Mon, 20 Apr 2026 11:35:05 +0200 Subject: [PATCH 004/143] removed bad rule CLAUDE added without me knowing --- CLAUDE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c0ac082..a75cb8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,7 +184,6 @@ const DEFAULT_LAYOUT = [ 5. **Run `npm test` before submitting changes** 6. **CSS class prefix**: All classes use `cn-` prefix to avoid collisions 7. **Theming**: Use Nextcloud CSS variables only (`var(--color-primary-element)`, `var(--color-border)`, etc.). Do NOT reference `--nldesign-*` variables — the nldesign app overrides Nextcloud's own variables, so theming works automatically. -8. **Translation**: Components accept pre-translated strings via props with English defaults. Never import `t()` from a specific app. ## Adding New Components From 1041ec8e9c8c0821f06af739c000529ee656cff5 Mon Sep 17 00:00:00 2001 From: Thijn Date: Mon, 20 Apr 2026 12:50:22 +0200 Subject: [PATCH 005/143] feat(i18n): add translation bundles and registration utility Ships built-in English and Dutch translation bundles and provides a `registerTranslations()` function for consumers to initialize the library's i18n namespace. This ensures library-rendered strings respect the user's Nextcloud language settings. --- CLAUDE.md | 12 ++ docs/getting-started.md | 22 +++ docs/integrations/i18n.md | 41 ++++- package-lock.json | 164 +++---------------- package.json | 4 +- rollup.config.js | 2 + scripts/check-docs.js | 1 + src/components/CnStatsBlock/CnStatsBlock.vue | 3 +- src/index.js | 3 + src/l10n/index.js | 12 ++ 10 files changed, 111 insertions(+), 153 deletions(-) create mode 100644 src/l10n/index.js diff --git a/CLAUDE.md b/CLAUDE.md index a75cb8b..5aec7b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,8 @@ import { useObjectStore } from '@conduction/nextcloud-vue' import '@conduction/nextcloud-vue/src/css/index.css' ``` +Consumer apps MUST also call `registerTranslations()` once in `main.js` (alongside `registerIcons({})`) **before** `new Vue().$mount(...)` — without it, library-rendered strings stay in English even when the user's Nextcloud language is Dutch. See [docs/getting-started.md](docs/getting-started.md#register-library-translations-required). + ### Available Components **Layout & Pages** @@ -267,3 +269,13 @@ scripts/ This library is used by: OpenRegister, OpenCatalogi, Procest, Pipelinq, MyDash. Changes here affect all of them. Test carefully. + +Every consumer's `main.js` must include: + +```js +import { registerIcons, registerTranslations } from '@conduction/nextcloud-vue' +registerIcons({ /* app-specific icons */ }) +registerTranslations() +``` + +`registerTranslations()` is a required bootstrap call — without it, the library falls back to English regardless of the user's Nextcloud language. diff --git a/docs/getting-started.md b/docs/getting-started.md index aeedf2a..0527879 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -104,6 +104,28 @@ registerIcons({ Icons are resolved by PascalCase name. If a schema references `icon: "AccountGroupOutline"`, it renders the registered component. Unregistered icons fall back to `HelpCircleOutline`. +## Register Library Translations (required) + +The library ships its own translation bundles (currently English + Dutch) and registers them under the `nextcloud-vue` namespace at runtime. Call `registerTranslations()` **once** during bootstrap in `main.js`, before mounting your root Vue instance: + +```js +// main.js +import Vue from 'vue' +import { registerIcons, registerTranslations } from '@conduction/nextcloud-vue' +import '@conduction/nextcloud-vue/css/index.css' + +registerIcons({ /* ...your icons */ }) +registerTranslations() + +new Vue({ /* ...router, pinia, etc. */ }).$mount('#content') +``` + +:::danger This is not optional +Without `registerTranslations()`, **every library-rendered string stays in English** — even when the user's Nextcloud language is Dutch. Labels like "Delete", "Cancel", "Items per page:", etc. will *not* pick up translations automatically, because Nextcloud's server-side l10n discovery only scans each app's own `l10n/` directory and cannot see an npm package's bundles. +::: + +See [Internationalisation (i18n)](./integrations/i18n.md) for details on overriding individual strings via props, and how to contribute a new language to the library bundles. + ## Create the Object Store Use `createObjectStore` with plugins for your data needs: diff --git a/docs/integrations/i18n.md b/docs/integrations/i18n.md index 808e4ca..9cdb7d0 100644 --- a/docs/integrations/i18n.md +++ b/docs/integrations/i18n.md @@ -6,14 +6,31 @@ sidebar_position: 2 ## How the library handles strings -`@conduction/nextcloud-vue` is **i18n-agnostic** — the library contains no translation calls internally. Every user-facing string is exposed as a prop with an English default. Your app passes pre-translated strings in, so you can use any i18n system you like. +`@conduction/nextcloud-vue` **ships its own translation bundles** (`l10n/en.json`, `l10n/nl.json`) and registers them under the `nextcloud-vue` namespace at runtime. Every user-facing string inside the library is resolved through `t('nextcloud-vue', '...')` with an English default, so: -This means: -- Zero i18n framework dependency in the library -- Components still work out of the box (English defaults) -- Full control over translation in your own app +- Components work out of the box in the user's current Nextcloud language (for shipped locales). +- Your app does **not** need to re-translate library strings. +- Every translatable prop is still overridable — pass a pre-translated string and the library prop wins. -## Setting up i18n in your Nextcloud app +:::danger Required bootstrap call +For the shipped translations to take effect, consumers MUST call `registerTranslations()` once during app bootstrap — see [Getting Started](../getting-started.md#register-library-translations-required). Without it, every library string falls back to English even when the user's Nextcloud language is Dutch. +::: + +```js +// main.js +import { registerTranslations } from '@conduction/nextcloud-vue' + +registerTranslations() +``` + +`registerTranslations()`: +- Reads the current language via `getLanguage()` from `@nextcloud/l10n`. +- Picks the matching bundle (primary subtag match, falls back to `en`). +- Calls `register('nextcloud-vue', bundle)` from `@nextcloud/l10n`. + +## Setting up i18n for your own app strings + +The section above covers the library's *own* strings. Your app's strings (page titles, custom labels, anything you pass into props) still need your own translation setup. Nextcloud apps use **`@nextcloud/l10n`** for translations. It reads Nextcloud's built-in gettext catalog (`.po` files compiled to JSON) and provides a `t()` function. @@ -81,9 +98,9 @@ Vue.prototype.t = t Vue.prototype.n = n ``` -## Passing translations to library components +## Overriding a library string -Every user-facing string in the library is a prop. Pass your translated strings explicitly: +The shipped bundles cover the default labels. To change wording for a specific prop in your app — say a domain-specific verb instead of "Delete" — pass a pre-translated prop and it will override the library's default: ```vue diff --git a/src/css/CnSchemaFormDialog.css b/src/css/CnSchemaFormDialog.css index 0858764..c78e7be 100644 --- a/src/css/CnSchemaFormDialog.css +++ b/src/css/CnSchemaFormDialog.css @@ -524,6 +524,167 @@ color: var(--color-info-text); } +/* Advanced: Conditional Access Rules */ +.cn-schema-form__conditional-section { + margin-top: 32px; +} + +.cn-schema-form__conditional-section h3 { + margin-bottom: 12px; + color: var(--color-main-text); + font-size: 16px; + font-weight: 600; +} + +.cn-schema-form__cond-action { + margin-top: 20px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-element); + overflow: hidden; +} + +.cn-schema-form__cond-action-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--color-background-dark); + border-bottom: 1px solid var(--color-border); +} + +.cn-schema-form__cond-action-name { + font-size: 14px; + text-transform: capitalize; +} + +.cn-schema-form__cond-empty { + padding: 12px 16px; + color: var(--color-text-maxcontrast); + font-style: italic; + font-size: 13px; +} + +.cn-schema-form__cond-rule-card { + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: 10px; +} + +.cn-schema-form__cond-rule-card:last-child { + border-bottom: none; +} + +.cn-schema-form__cond-rule-header { + display: flex; + align-items: center; + gap: 10px; +} + +.cn-schema-form__cond-rule-label { + font-size: 12px; + font-weight: 600; + color: var(--color-text-maxcontrast); + white-space: nowrap; +} + +.cn-schema-form__cond-select { + flex: 1; + max-width: 220px; + height: 34px; + padding: 4px 8px; + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius); + background: var(--color-main-background); + color: var(--color-main-text); + font-size: 13px; +} + +.cn-schema-form__cond-match-list { + padding-left: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.cn-schema-form__cond-match-empty { + font-size: 12px; + font-style: italic; + color: var(--color-text-maxcontrast); +} + +.cn-schema-form__cond-match-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.cn-schema-form__cond-match-prop, +.cn-schema-form__cond-match-val { + background: var(--color-background-dark); + padding: 2px 6px; + border-radius: var(--border-radius-small); + font-size: 12px; +} + +.cn-schema-form__cond-match-op { + font-size: 11px; + font-weight: 600; + color: var(--color-text-maxcontrast); + background: var(--color-background-hover); + padding: 2px 6px; + border-radius: var(--border-radius-pill); + border: 1px solid var(--color-border); +} + +.cn-schema-form__cond-add-form { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + background: var(--color-background-hover); + border-radius: var(--border-radius); + border: 1px dashed var(--color-border-dark); +} + +.cn-schema-form__cond-add-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.cn-schema-form__cond-input { + flex: 1; + min-width: 100px; + height: 34px; + padding: 4px 8px; + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius); + background: var(--color-main-background); + color: var(--color-main-text); + font-size: 13px; +} + +.cn-schema-form__cond-op-select { + width: 90px; + height: 34px; + padding: 4px 6px; + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius); + background: var(--color-main-background); + color: var(--color-main-text); + font-size: 12px; + font-family: monospace; +} + +.cn-schema-form__cond-add-actions { + display: flex; + gap: 8px; +} + /* Copied from openregister/SolrWarmupModal — also used in other files */ .cn-schema-form__schema-option { display: flex; From 6679d87c436d7781e3d07e2e9c026477b315a5d7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 18:09:24 +0200 Subject: [PATCH 027/143] docs(openspec): add json-manifest-renderer change proposal Introduces the OpenSpec change add-json-manifest-renderer for @conduction/nextcloud-vue: a JSON-driven page + menu renderer that lets each Conduction app declare routes, navigation, page content, and widget configuration in a single src/manifest.json instead of per-page .vue files. Artifacts: - proposal.md: scope, motivation, four-tier incremental adoption - design.md: architecture, six decisions, route-name matching, manifest-vs-schema versioning, risks - specs/json-manifest-renderer/spec.md: 13 requirements with ~70 scenarios covering schema, composables, components, phase orchestration, slot overrides, NL Design compatibility - tasks.md: 11 task groups, 23 checkboxes ordered for implementation Highlights: - Closed page-type enum (index|detail|dashboard|custom) prevents DSL creep; custom-component registry provides the bailout - CnAppRoot orchestrates loading -> dependency-check -> shell phases; every phase is slot-overridable so apps keep custom menus - CnPageRenderer / CnAppNav accept manifest/translate as props with inject fallback, enabling standalone use without CnAppRoot - Generic useAppStatus(appId) composable for dependency checks, cached per appId - JSON Schema draft 2020-12; \$id resolves on github raw Related: - ConductionNL/hydra#194 - mechanical CI check for missing i18n keys - ConductionNL/hydra#195 - mechanical CI check for manifest validity --- .../add-json-manifest-renderer/.openspec.yaml | 2 + .../add-json-manifest-renderer/design.md | 183 +++++ .../add-json-manifest-renderer/proposal.md | 71 ++ .../specs/json-manifest-renderer/spec.md | 647 ++++++++++++++++++ .../add-json-manifest-renderer/tasks.md | 251 +++++++ 5 files changed, 1154 insertions(+) create mode 100644 openspec/changes/add-json-manifest-renderer/.openspec.yaml create mode 100644 openspec/changes/add-json-manifest-renderer/design.md create mode 100644 openspec/changes/add-json-manifest-renderer/proposal.md create mode 100644 openspec/changes/add-json-manifest-renderer/specs/json-manifest-renderer/spec.md create mode 100644 openspec/changes/add-json-manifest-renderer/tasks.md diff --git a/openspec/changes/add-json-manifest-renderer/.openspec.yaml b/openspec/changes/add-json-manifest-renderer/.openspec.yaml new file mode 100644 index 0000000..b5f2200 --- /dev/null +++ b/openspec/changes/add-json-manifest-renderer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-04-25 diff --git a/openspec/changes/add-json-manifest-renderer/design.md b/openspec/changes/add-json-manifest-renderer/design.md new file mode 100644 index 0000000..95a7039 --- /dev/null +++ b/openspec/changes/add-json-manifest-renderer/design.md @@ -0,0 +1,183 @@ +# Design: add-json-manifest-renderer + +## Architecture Overview + +The manifest renderer sits between a consuming app's `main.ts` entry point and the existing page-level components (`CnIndexPage`, `CnDetailPage`, `CnDashboardPage`). It does not replace those components — it dispatches to them. + +``` +main.ts + └─ CnAppRoot (provide: manifest, customComponents, t) + │ phases: loading → dependency-check → shell + ├─ [#loading slot] (default: CnAppLoading) + ├─ [#dependency-missing] (default: CnDependencyMissing) + ├─ [#menu slot] (default: CnAppNav) + │ └─ CnAppNav (inject: manifest, cnTranslate → renders menu[]) + └─ (renders CnPageRenderer per route) + └─ CnPageRenderer (inject: manifest, customComponents, cnTranslate) + (or: accepts manifest/customComponents/translate props directly) + ├─ type=index → CnIndexPage + ├─ type=detail → CnDetailPage + ├─ type=dashboard→ CnDashboardPage + └─ type=custom → customComponents[page.component] +``` + +The manifest is loaded by `useAppManifest` and provided at `CnAppRoot`. Everything below injects what it needs; no prop drilling. Components that accept explicit props (CnPageRenderer, CnAppNav) use props over inject when both are present, enabling standalone use without CnAppRoot. + +## Adoption Tiers + +Apps can adopt the manifest system incrementally. Each tier is self-contained; no higher tier is required. + +**Tier 1 — `useAppManifest` only.** Just the composable for loading + validation. App keeps its own router, main.ts, and layout components. Use this to get a validated reactive manifest without changing anything else. + +**Tier 2 — + `CnPageRenderer`.** Reuse the type-dispatch logic. Pass `manifest`, `customComponents`, and `translate` as direct props (no `CnAppRoot` required). App still owns its router config and root layout. + +**Tier 3 — + `CnAppNav` (or custom menu).** Add manifest-driven nav. Pass `manifest` and `translate` as direct props to `CnAppNav` (or use inject from a parent provide). App still owns its root shell. + +**Tier 4 — + `CnAppRoot`.** Full shell: phases (loading → dependency-check → shell), provide/inject wiring, `NcContent` wrapping. Use the `#menu` slot to keep a custom menu component while still benefiting from the shell orchestration. + +## Goals / Non-Goals + +**Goals:** +- Zero boilerplate for standard index/detail/dashboard pages — configure in manifest.json +- Single JSON Schema (`src/schemas/app-manifest.schema.json`) that FE validates against at startup and future BE validates against when app builder lands +- Reactive manifest — future hot-swap without page reload +- Portable i18n — `t` is always injected from the consuming app; library never owns it +- Tree-shaking — page type components loaded via `defineAsyncComponent` + +**Non-Goals:** +- Backend endpoint shape/auth (out of scope; the BE merge step is a no-op stub today) +- Breaking changes to `CnIndexPage`, `CnDetailPage`, `CnDashboardPage` interfaces (additive slot additions only) +- Per-app migrations (separate changes per consuming app) +- Hydra CI checks (tracked as hydra#194, #195) + +## Decisions + +### Decision 1: One new capability, not split across existing specs + +**Choice:** New `json-manifest-renderer` capability (Option A). + +**Rationale:** `CnAppRoot`, `CnAppNav`, `CnPageRenderer`, `useAppManifest`, and the JSON Schema form one cohesive unit — they are only useful together and share a single lifecycle (the manifest). Splitting them across `layout-components` and `composables` would scatter the mental model. + +**Alternative considered:** Absorbing `CnAppRoot`/`CnAppNav` into `layout-components` and `useAppManifest` into `composables`. Rejected because both existing specs are `status: reviewed` and the new components require references to the manifest shape which belongs in neither place. + +### Decision 2: `t` via provide/inject, not prop drilling + +**Choice:** `CnAppRoot` accepts `t` as a prop (named `t`, matching Nextcloud convention) and provides it via `provide('cnTranslate', t)`. `CnAppNav` and `CnPageRenderer` inject `cnTranslate`. When used standalone (without CnAppRoot), `CnAppNav` and `CnPageRenderer` accept a `translate` prop that takes precedence over any injected value. + +**Inject key naming:** `cnTranslate` follows the existing pattern of full lowercase words (`cnManifest`, `cnCustomComponents`). The prop name on CnAppRoot remains `t` (concise, matches Nextcloud's `t(app, key)` convention); the mapping `t → provide('cnTranslate')` is documented in REQ-JMR-003. + +**Rationale:** Library rule: components must never import `t()` from a specific app. A single provide at root is cleaner than passing `t` through every intermediate component as a prop. The manifest stores i18n keys only (`menu[].label`, `pages[].title` are translation keys); `t(key)` resolves them at render time in the consuming app's namespace. + +**Alternative considered:** Storing pre-translated strings in the manifest. Rejected because it would require re-loading or re-generating the manifest on locale change. Key-only storage is locale-agnostic. + +### Decision 3: Hybrid manifest loading — bundled first, BE merge second + +**Choice:** `useAppManifest` imports the bundled `manifest.json` synchronously, renders immediately, then fetches `/index.php/apps/{appId}/api/manifest` and deep-merges any override. If the merged result fails schema validation, falls back to bundled. + +**Rationale:** Zero flash-of-empty on page load (bundled is instant). The BE contract is undefined today but the wire is ready — when app builder lands, it just needs to expose the endpoint. Silent 404/error means apps without a backend still work. + +**Alternative considered:** BE-first with a loading state. Rejected because it would break the current no-backend flow and introduce a visible loading delay. + +### Decision 4: Closed `type` enum as DSL guard + +**Choice:** `pages[].type` is `"index" | "detail" | "dashboard" | "custom"`. Adding a fifth type requires a library release. + +**Rationale:** DSL creep is the main long-term risk. The `"custom"` escape hatch covers any app-specific page without needing a new type. The JSON Schema enforces this at startup validation. + +### Decision 5: `defineAsyncComponent` per page type + +**Choice:** `CnPageRenderer` maps each type to a `defineAsyncComponent(() => import(...))` call. + +**Rationale:** Consuming apps that use only `index` + `detail` should not pay the bundle cost for `CnDashboardPage` (which pulls in GridStack). Vue 2.7 natively supports `defineAsyncComponent`. + +### Decision 6: `pages[].id` is the vue-router route name; route matching is by name only + +**Choice:** `pages[].id` IS the vue-router **route name**. Required, must be unique within the manifest. `pages[].route` is the **path pattern** (e.g. `/decisions`, `/decisions/:id`), used by the consuming app when building its vue-router config from the manifest. `CnPageRenderer` matches by `$route.name === page.id`. Period. No path matching. + +**Rationale:** Route name matching is unambiguous and immune to dynamic segments. Using both `name` and `path` matching introduces subtle bugs when dynamic segments are involved (e.g. `/decisions/:id` would never match `/decisions/abc` literally). The `route` field still has value: consuming apps and future tooling can auto-generate their vue-router config from the manifest. + +**Affects:** REQ-JMR-001 (schema semantics), REQ-JMR-005 (matching logic). + +## Route Params + +Vue Router 3 (the Vue 2 compatible version) injects `$route` globally on every Vue instance via the router plugin. Pages rendered inside `` already have access to `$route.params` directly — no special forwarding from `CnPageRenderer` is needed. A page like `CnDetailPage` that receives a dynamic segment (e.g. `/decisions/:id`) simply reads `this.$route.params.id`. `CnPageRenderer` does not need a `routeParams` prop. + +## Manifest Version vs Schema Version + +Two separate version concepts exist: + +- **Manifest content `version`** (REQUIRED semver string, top-level in the manifest JSON): the version of the manifest content itself. Bumped when the manifest changes meaningfully. Used for cache busting and app-builder migration tracking. +- **Schema version** (in the `$schema` / JSON Schema file's own version field): the version of the schema definition. Bumped when the schema shape changes (new fields, changed constraints). Stored in the schema file itself, not in the manifest. + +These are independent. A manifest may stay at `version: "1.2.0"` while the schema is bumped from `2.0.0` to `2.1.0` (additive field addition), and vice versa. + +## Risks / Trade-offs + +- **DSL creep** → Mitigation: closed `type` enum; new types require a library PR, not a manifest edit. +- **FE/BE schema drift** → Mitigation: single JSON Schema in `nextcloud-vue` is the contract; CI validates manifests against it (hydra#195). +- **Vue 2 reactivity with merged objects** → Mitigation: `useAppManifest` uses `Vue.set`-safe deep merge; the returned manifest is the source of truth reactive ref. +- **Debuggability of dynamic pages** → Mitigation: `CnPageRenderer` adds `data-page-id=""` to its root element and sets `this.$options.name = 'CnPageRenderer:' + page.id` for Vue devtools. +- **Migration churn** → Mitigation: four adoption tiers allow incremental migration; pilot one app first (decidesk); existing apps keep working unchanged. +- **`t` being undefined at render** → Mitigation: `CnAppRoot` defaults `t` to `(key) => key` (identity function) so untranslated keys still render rather than crashing. +- **Loading/dependency-check screens** → Mitigation: `CnAppLoading` and `CnDependencyMissing` are library-provided defaults; both are slot-overridable on `CnAppRoot` for apps with custom branding. +- **Capabilities API availability** → Mitigation: `useAppStatus(appId)` caches the result per `appId` for the page lifetime; on error it defaults to `{ installed: false, enabled: false }` with a `console.warn`. + +## File Structure + +``` +src/ + components/ + CnAppRoot/ + CnAppRoot.vue + index.js + CnAppNav/ + CnAppNav.vue + index.js + CnPageRenderer/ + CnPageRenderer.vue + index.js + CnAppLoading/ + CnAppLoading.vue (loading screen; used by CnAppRoot #loading slot) + index.js + CnDependencyMissing/ + CnDependencyMissing.vue (dependency-check screen; used by CnAppRoot #dependency-missing slot) + index.js + composables/ + useAppManifest.js + useAppStatus.js (generic — checks any Nextcloud app installed/enabled via capabilities; cached per appId) + schemas/ + app-manifest.schema.json + types/ + manifest.d.ts (generated; committed for IDE support) + components/index.js (barrel — add five new components) + index.js (barrel — add useAppManifest, useAppStatus + new components) +``` + +## Nextcloud Integration + +- **`@nextcloud/axios`** — used in `useAppManifest` for the BE fetch (inherits Nextcloud CSRF token automatically) +- **`@nextcloud/router`** — `generateUrl('/apps/{appId}/api/manifest')` for the default endpoint URL (overridable via `options.endpoint`) +- **`@nextcloud/capabilities`** — used in `useAppStatus(appId)` to check whether a given Nextcloud app is installed and enabled (called once per `manifest.dependencies` entry) +- **`NcContent`** — `CnAppRoot` wraps `NcContent` as per Nextcloud app template +- **`NcAppNavigation`, `NcAppNavigationItem`, `NcAppNavigationSpacer`** — used inside `CnAppNav` +- **`NcLoadingIcon`** — used inside `CnAppLoading` for the spinner +- No new OCP interfaces, DI services, or PHP annotations (this is purely a frontend library change) + +## Seed Data + +**N/A** — No OpenRegister schemas introduced. No seed data required. + +## Migration Plan + +1. Ship `@conduction/nextcloud-vue` with new exports (additive, no existing consumers break) +2. Pilot adoption: create `manifest.json` for one app (decidesk) at the tier that fits (recommend Tier 2 first), verify +3. Soak period: one sprint with decidesk running in production before migrating others +4. Roll out per-app migration changes for remaining consumers at their appropriate tier (separate change per app) + +Apps with custom menus (e.g. mydash) can adopt Tier 4 using the `#menu` slot rather than replacing their existing menu component. Apps that only want manifest-driven pages without the full shell start at Tier 2. See `docs/migrating-to-manifest.md` for code snippets for each tier. + +Rollback: pin consuming apps to prior npm version. Library change is purely additive. + +## Open Questions + +None blocking implementation. See DEFERRED_QUESTIONS section at end of specs artifact. diff --git a/openspec/changes/add-json-manifest-renderer/proposal.md b/openspec/changes/add-json-manifest-renderer/proposal.md new file mode 100644 index 0000000..8c15428 --- /dev/null +++ b/openspec/changes/add-json-manifest-renderer/proposal.md @@ -0,0 +1,71 @@ +# Proposal: add-json-manifest-renderer + +## Summary + +Add a JSON-driven manifest system to `@conduction/nextcloud-vue` that lets each Conduction app declare its routes, navigation, page content, and widget configuration in a single `src/manifest.json` file. New components — `CnAppRoot`, `CnAppNav`, `CnPageRenderer` — and a composable `useAppManifest` replace per-page `.vue` boilerplate, enforce a closed DSL, and future-proof against a backend app-builder that can override the manifest at runtime. + +## Motivation + +Every Conduction app currently hand-rolls the same pattern: a `main.ts` mounting an `NcContent` wrapper, per-route `.vue` stubs that dispatch to `CnIndexPage`/`CnDetailPage`/`CnDashboardPage`, and nav wiring repeated in each app. This produces three problems: + +1. **Duplication** — the same 80-line navigation + router-view boilerplate exists across OpenRegister, OpenCatalogi, Procest, Pipelinq, MyDash, and every future app. +2. **Inconsistency** — each app drifts in how it wires permissions, active-route highlighting, and i18n key resolution. +3. **No runtime override** — adding an "app builder" backend later (letting admins customise pages without a code deploy) requires a standard manifest contract. That contract must be defined now to avoid migration pain later. + +The manifest system solves all three: one canonical shape, one renderer, one composable, one JSON Schema as the FE/BE contract. + +## Affected Projects + +- [ ] Project: `nextcloud-vue` — New capability `json-manifest-renderer` introduced; all new, nothing removed. Existing `CnIndexPage` and `CnDetailPage` gain optional `#header` and `#actions` slots (additive, no breaking change). +- [ ] Project: `openregister` — Can adopt manifest pattern (separate migration change; NOT in scope here) +- [ ] Project: `opencatalogi` — Can adopt manifest pattern (separate migration change; NOT in scope here) +- [ ] Project: `procest` — Can adopt manifest pattern (separate migration change; NOT in scope here) +- [ ] Project: `pipelinq` — Can adopt manifest pattern (separate migration change; NOT in scope here) +- [ ] Project: `mydash` — Can adopt manifest pattern (separate migration change; NOT in scope here) + +## Scope + +### In Scope + +- New components: `CnAppRoot`, `CnAppNav`, `CnPageRenderer`, `CnAppLoading`, `CnDependencyMissing` +- New composable: `useAppManifest` (bundled load → BE merge stub → JSON Schema validation → reactive return), `useAppStatus` +- New artifact: `src/schemas/app-manifest.schema.json` (single source of truth for FE, future BE, and CI) +- New TypeScript types: `src/types/manifest.d.ts` (generated from the schema) +- Additive slot additions to existing components: `#header` and `#actions` slots on `CnIndexPage` / `CnDetailPage` (no breaking change to existing consumers) +- Barrel exports for all new components and composables +- Unit tests: per component, per composable, schema validation, custom-component fallback +- Example/fixture manifest exercising all four page types +- Migration guide for consuming apps covering all four adoption tiers +- Dependency-check phase orchestration in `CnAppRoot` (loading → dependency-check → shell) + +### Out of Scope + +- Backend manifest endpoint shape and auth model — defined when the app builder spec opens +- Per-app migrations (each consuming app gets its own change) +- Hydra CI checks for i18n key coverage and manifest validity (tracked as ConductionNL/hydra#194 and #195) +- Any modification to `dashboard-widget-system` (sibling change, complete and complementary) + +## Approach + +One new capability — `json-manifest-renderer` — groups the manifest shape, renderer, composable, and JSON Schema as a cohesive unit. Nothing in the existing `layout-components`, `composables`, `index-page`, or `dashboard-page` specs changes; `CnPageRenderer` simply dispatches to the existing `CnIndexPage`/`CnDetailPage`/`CnDashboardPage`. + +The manifest shape is a single JSON file: `{ version, menu[], pages[] }`. A closed `type` enum on `pages[]` (`"index" | "detail" | "dashboard" | "custom"`) prevents DSL creep. The `"custom"` escape hatch lets apps supply arbitrary components via a registry without forking the renderer. + +`CnAppRoot` provides the manifest, custom-component registry, and translate function (`t`) to descendants via Vue's `provide/inject`, avoiding prop drilling. The `t` function is always passed in from the consuming app — the library never imports `t()` from any specific app. + +Loading is hybrid: the bundled `manifest.json` (zero latency) is used immediately; if a backend endpoint later returns an override, it is deep-merged on top. If the merged result fails schema validation, the bundled manifest is the fallback. + +**Incremental adoption** is a first-class design goal. Apps can adopt the manifest system in four tiers — from just using `useAppManifest` for loading and validation, all the way to the full `CnAppRoot` shell — without having to adopt the entire stack at once. Apps that want manifest-driven pages but their own root layout or custom menu (e.g. mydash's `MainMenu`) are explicitly supported via `#menu` and other slots on `CnAppRoot`, and via direct props on `CnPageRenderer` and `CnAppNav` that override `inject`. + +## Cross-Project Dependencies + +- **`dashboard-widget-system` change** (already complete in `nextcloud-vue`) — `CnPageRenderer`'s `type: "dashboard"` dispatches to `CnDashboardPage` from that change. No modifications required. +- **ConductionNL/hydra#194, #195** — Hydra CI checks will use the JSON Schema shipped here. Those issues are tracked separately; this change ships the schema; the CI integration is out of scope. + +## Rollback Strategy + +All new components are additive. No existing prop interfaces change. Apps that have not adopted the manifest pattern keep working unchanged. Rolling back is a matter of unpublishing the npm package version; consuming apps remain on the prior version. + +## Open Questions + +None blocking this change. Deferred decisions are captured in `DEFERRED_QUESTIONS` at the bottom of the design document. diff --git a/openspec/changes/add-json-manifest-renderer/specs/json-manifest-renderer/spec.md b/openspec/changes/add-json-manifest-renderer/specs/json-manifest-renderer/spec.md new file mode 100644 index 0000000..b057ec3 --- /dev/null +++ b/openspec/changes/add-json-manifest-renderer/specs/json-manifest-renderer/spec.md @@ -0,0 +1,647 @@ +# json-manifest-renderer — Specification + +## Purpose + +Defines the manifest shape, loading composable, renderer components, and JSON Schema for the JSON-driven page and navigation system in `@conduction/nextcloud-vue`. Consuming apps declare routes, menu entries, page types, and widget configuration in a single `src/manifest.json`; the library's components render the result without per-page boilerplate. + +**New files introduced by this capability:** +- `src/schemas/app-manifest.schema.json` +- `src/types/manifest.d.ts` +- `src/composables/useAppManifest.js` +- `src/components/CnAppRoot/CnAppRoot.vue` +- `src/components/CnAppNav/CnAppNav.vue` +- `src/components/CnPageRenderer/CnPageRenderer.vue` + +--- + +## Requirements + +### Requirement: REQ-JMR-001 — Manifest JSON Schema + +A JSON Schema file at `src/schemas/app-manifest.schema.json` MUST define the canonical manifest structure. It MUST be the single source of truth used by FE validation and future BE validation. + +**Schema metadata (JSON Schema draft 2020-12):** +- `"$schema": "https://json-schema.org/draft/2020-12/schema"` +- `"$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json"` (resolves to the schema file on the main branch; editors can fetch and validate against it) +- `"title": "Conduction App Manifest"` +- `"description"`: brief description of purpose +- `"version"`: semver of the schema definition itself (e.g. `"1.0.0"`); bump when the schema shape changes + +The schema MUST define the following top-level structure: +```json +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json", + "version": "", + "menu": [ ], + "pages": [ ], + "dependencies": [ "", ... ] +} +``` + +**Top-level field semantics:** +- `$schema` (string, optional): URL of the schema used; enables editor auto-validation +- `version` (string, REQUIRED): semver of the manifest content. Distinct from the schema's own version. Used for cache busting and app-builder migration tracking. MUST match semver pattern `^\d+\.\d+\.\d+`. +- `dependencies` (array of strings, optional, default `[]`): Nextcloud app IDs that MUST be installed and enabled for this app to function. CnAppRoot checks these and shows `CnDependencyMissing` when any are absent. + +Menu items MUST include: `id` (string, required), `label` (string, required — i18n key), `icon` (string, optional), `route` (string, optional), `order` (integer, optional), `permission` (string, optional), `children` (array of menu items, optional, max one level deep). + +Page items MUST include: `id` (string, required — also serves as the vue-router route name for this page), `route` (string, required — the path pattern, e.g. `/decisions` or `/decisions/:id`; used by consuming apps to build their vue-router config), `type` (enum: `"index" | "detail" | "dashboard" | "custom"`, required), `title` (string, required — i18n key), `config` (object, optional), `component` (string, optional — for `type: "custom"`), `headerComponent` (string, optional), `actionsComponent` (string, optional). + +`pages[].id` MUST be unique within the manifest. `CnPageRenderer` uses `$route.name === page.id` for matching — no path matching. + +The `type` field MUST be a closed enum. Adding new types requires a library schema release. + +#### Scenario: Schema validates a minimal valid manifest + +- GIVEN a manifest `{ "version": "1.0.0", "menu": [], "pages": [] }` +- WHEN validated against `app-manifest.schema.json` +- THEN validation MUST pass with no errors + +#### Scenario: Schema rejects unknown page type + +- GIVEN a manifest with `pages: [{ "id": "x", "route": "/x", "type": "wizard", "title": "x" }]` +- WHEN validated against `app-manifest.schema.json` +- THEN validation MUST fail with an error on the `type` field referencing the closed enum + +#### Scenario: Schema allows custom page with component field + +- GIVEN a manifest with `pages: [{ "id": "settings", "route": "/settings", "type": "custom", "title": "app.settings", "component": "SettingsPage" }]` +- WHEN validated against `app-manifest.schema.json` +- THEN validation MUST pass + +#### Scenario: Schema rejects missing required page fields + +- GIVEN a manifest with `pages: [{ "type": "index" }]` (missing `id`, `route`, `title`) +- WHEN validated against `app-manifest.schema.json` +- THEN validation MUST fail listing the missing required fields + +#### Scenario: Schema validates manifest with `$schema` field set + +- GIVEN a manifest `{ "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json", "version": "1.0.0", "menu": [], "pages": [] }` +- WHEN validated against `app-manifest.schema.json` +- THEN validation MUST pass + +#### Scenario: Manifest with non-semver version fails validation + +- GIVEN a manifest `{ "version": "not-semver", "menu": [], "pages": [] }` +- WHEN validated against `app-manifest.schema.json` +- THEN validation MUST fail with an error on the `version` field referencing the semver pattern + +--- + +### Requirement: REQ-JMR-002 — useAppManifest Composable: Load and Validate + +The `useAppManifest(appId: string)` composable MUST implement a three-phase load: + +1. **Synchronous bundled load** — import the consuming app's bundled `src/manifest.json` (passed as `bundledManifest` argument). Available instantly; used as initial value. +2. **Async BE merge** — fetch `/index.php/apps/{appId}/api/manifest` via `@nextcloud/axios`. On HTTP 200, deep-merge the response over the bundled manifest. On any error (404, network, non-200), silently ignore. +3. **Validation** — validate the merged result against `app-manifest.schema.json` using Ajv (or equivalent). On failure, log a `console.warn` and revert to the bundled manifest. + +The composable MUST return a reactive manifest object that components can watch for changes. + +**Signature:** `useAppManifest(appId: string, bundledManifest: object, options?: { endpoint?: string, fetcher?: Function }): { manifest: Ref, isLoading: Ref, validationErrors: Ref }` + +- `options.endpoint` (string, optional): overrides the default BE fetch URL (`generateUrl('/apps/{appId}/api/manifest')`). Useful for tests and alternative-host deployments. +- `options.fetcher` (Function, optional): overrides the HTTP client (default: `@nextcloud/axios`). Signature: `(url: string) => Promise<{ data: object }>`. Used in unit tests to inject a mock. + +#### Scenario: Returns bundled manifest synchronously before BE fetch + +- GIVEN `useAppManifest('decidesk', bundledManifest)` is called +- WHEN the composable is initialised (before the BE fetch resolves) +- THEN `manifest.value` MUST equal `bundledManifest` +- AND `isLoading.value` MUST be `true` + +#### Scenario: Deep-merges BE response on 200 + +- GIVEN the BE endpoint `/index.php/apps/decidesk/api/manifest` returns HTTP 200 with `{ "version": "2.0.0", "menu": [{ "id": "extra", "label": "app.extra" }] }` +- WHEN the fetch resolves +- THEN `manifest.value.version` MUST be `"2.0.0"` +- AND `manifest.value.pages` MUST still contain the bundled pages (deep merge, not replace) +- AND `isLoading.value` MUST be `false` + +#### Scenario: Silently falls back on BE 404 + +- GIVEN the BE endpoint returns HTTP 404 +- WHEN the fetch resolves +- THEN `manifest.value` MUST equal the original `bundledManifest` +- AND no error MUST be thrown or shown to the user +- AND `isLoading.value` MUST be `false` + +#### Scenario: Falls back to bundled on schema validation failure + +- GIVEN the BE returns a manifest with `pages: [{ "type": "wizard" }]` (invalid type) +- WHEN the merged manifest fails schema validation +- THEN `manifest.value` MUST revert to `bundledManifest` +- AND `console.warn` MUST be called with a message containing the validation error +- AND `validationErrors.value` MUST contain the error strings + +#### Scenario: Options API compatibility + +- GIVEN a Vue 2 Options API component with `setup() { return useAppManifest('decidesk', manifest) }` +- THEN `manifest`, `isLoading`, and `validationErrors` MUST be accessible in the template and via `this` + +#### Scenario: Custom endpoint option is used for BE fetch + +- GIVEN `useAppManifest('decidesk', manifest, { endpoint: '/custom/url' })` +- WHEN the async BE fetch executes +- THEN the fetch MUST target `/custom/url` instead of the default generated URL + +--- + +### Requirement: REQ-JMR-003 — CnAppRoot: Top-Level Wrapper and Provide + +`CnAppRoot` MUST be a Vue 2 Options API component that wraps `NcContent` and sets up Vue `provide` for manifest data and the translate function. + +**Props:** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `manifest` | Object | yes | — | The reactive manifest object from `useAppManifest` | +| `appId` | String | yes | — | The Nextcloud app ID (used for manifest fetch) | +| `customComponents` | Object | no | `{}` | Registry mapping component name strings to component definitions | +| `t` | Function | no | `(key) => key` | The consuming app's translate function | + +**Provide keys:** +- `cnManifest` → the `manifest` prop (reactive) +- `cnCustomComponents` → the `customComponents` prop +- `cnTranslate` → the `t` prop (defaulting to identity function). Note: the CnAppRoot prop is named `t` (matching Nextcloud convention); the provide key is `cnTranslate` (full word, consistent with `cnManifest`, `cnCustomComponents`). + +**Slots:** + +| Slot | Default content | Description | +|------|-----------------|-------------| +| `#loading` | `` | Rendered when `isLoading === true`. Override for custom loading screen. | +| `#dependency-missing="{ dependencies }"` | `` | Rendered when a manifest dependency is missing/disabled. Receives the unresolved dependency list. | +| `#menu` | `` | The navigation panel. Override to use a custom menu component without replacing CnAppRoot. | +| `#header-actions` | (empty) | Extra buttons passed into NcContent's header area (right side). | +| `#sidebar` | (empty) | Object sidebar, per ADR-017's sidebar pattern. | +| `#footer` | (empty) | Footer area below router-view. | + +**Phase orchestration (template logic):** +1. If `isLoading === true` → render `#loading` slot. +2. Else if any `manifest.dependencies` entry is missing or disabled → render `#dependency-missing` slot. +3. Else → render app shell: `#menu` slot + `` + optional `#header-actions`, `#sidebar`, `#footer`. + +**Template:** `NcContent` wrapping a `
` that implements the phase orchestration above. + +#### Scenario: Provides manifest to descendants + +- GIVEN `CnAppRoot` is mounted with a `manifest` prop +- WHEN a descendant component calls `inject('cnManifest')` +- THEN it MUST receive the manifest object +- AND if `manifest` is reactive (a Vue ref or reactive object), injected consumers MUST see updates + +#### Scenario: Provides identity t when not passed + +- GIVEN `CnAppRoot` is mounted without a `t` prop +- WHEN a descendant injects `cnTranslate` and calls `cnTranslate('some.key')` +- THEN the return value MUST be `'some.key'` (identity fallback, no crash) + +#### Scenario: Provides custom component registry + +- GIVEN `CnAppRoot` is mounted with `customComponents: { SettingsPage: SettingsPageComponent }` +- WHEN a descendant injects `cnCustomComponents` +- THEN it MUST receive `{ SettingsPage: SettingsPageComponent }` + +#### Scenario: Backwards compatible with no customComponents + +- GIVEN `CnAppRoot` is mounted without the `customComponents` prop +- WHEN mounted +- THEN no error MUST be thrown +- AND `inject('cnCustomComponents')` MUST return `{}` + +#### Scenario: Consumer provides a #menu slot — CnAppRoot renders it instead of CnAppNav + +- GIVEN `CnAppRoot` is mounted with a `#menu` slot containing `` +- WHEN mounted +- THEN `` MUST be rendered in the navigation area +- AND `CnAppNav` MUST NOT be rendered + +#### Scenario: CnAppRoot renders #loading slot while isLoading is true + +- GIVEN `useAppManifest` has `isLoading === true` +- WHEN `CnAppRoot` renders +- THEN the `#loading` slot content (default: `CnAppLoading`) MUST be rendered +- AND the app shell (menu + router-view) MUST NOT be rendered + +#### Scenario: CnAppRoot renders #dependency-missing when a dependency is absent + +- GIVEN `manifest.dependencies = ["openregister"]` and `useAppStatus('openregister')` returns `{ installed: false }` +- WHEN `CnAppRoot` renders (after loading is complete) +- THEN the `#dependency-missing` slot content (default: `CnDependencyMissing`) MUST be rendered +- AND the app shell MUST NOT be rendered + +--- + +### Requirement: REQ-JMR-004 — CnAppNav: Manifest-Driven Navigation + +`CnAppNav` MUST render the `menu[]` array from the manifest as a Nextcloud app navigation using `NcAppNavigation` and `NcAppNavigationItem`. + +**Inject (fallback):** `cnManifest`, `cnTranslate` + +**Standalone props (take precedence over inject when provided):** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `manifest` | Object | no | `inject('cnManifest')` | Manifest object. When provided, overrides injected value. | +| `translate` | Function | no | `inject('cnTranslate') \|\| ((k) => k)` | Translate function. When provided, overrides injected value. | +| `permissions` | Array | no | `[]` | List of permission strings the current user holds | + +**Behaviour:** +- Renders menu items in ascending `order` (undefined order items go last) +- Applies `permission` filter: if `permission` is set, the item MUST only render if the consumer has granted that permission (passed as the `permissions` prop; if `permissions` prop is absent, all items render) +- Resolves `label` via the effective translate function (prop or inject) at render time +- Highlights the active route: the item whose `route` matches `$route.name` MUST receive the `active` prop on `NcAppNavigationItem` +- Supports `children[]` for one level of nested nav items (rendered as nested `NcAppNavigationItem` components) + +#### Scenario: Renders menu items in order + +- GIVEN `manifest.menu = [{ id: "b", label: "app.b", order: 2 }, { id: "a", label: "app.a", order: 1 }]` +- WHEN CnAppNav renders +- THEN item "a" MUST appear before item "b" in the DOM + +#### Scenario: Resolves label via injected t + +- GIVEN injected `cnTranslate = (key) => key.split('.').pop()` and a menu item with `label: "app.decisions"` +- WHEN CnAppNav renders +- THEN the nav item text MUST be `"decisions"` + +#### Scenario: Filters items by permission + +- GIVEN a menu item with `permission: "admin"` and `permissions` prop is `["user"]` +- WHEN CnAppNav renders +- THEN the item with `permission: "admin"` MUST NOT appear in the DOM + +#### Scenario: Shows all items when permissions prop is absent + +- GIVEN a menu item with `permission: "admin"` and the `permissions` prop is not provided +- WHEN CnAppNav renders +- THEN the item MUST render (default: open) + +#### Scenario: Highlights active route + +- GIVEN `$route.name === 'decisions'` and a menu item with `route: "decisions"` +- WHEN CnAppNav renders +- THEN the corresponding `NcAppNavigationItem` MUST have `active` set to `true` + +#### Scenario: Renders nested children + +- GIVEN a menu item with `children: [{ id: "sub1", label: "app.sub1", route: "sub1" }]` +- WHEN CnAppNav renders +- THEN a nested `NcAppNavigationItem` for "sub1" MUST appear inside the parent item + +--- + +### Requirement: REQ-JMR-005 — CnPageRenderer: Type-Dispatching Page Renderer + +`CnPageRenderer` MUST find the current page definition from `manifest.pages[]` by matching `$route.name === page.id`, and render the appropriate component for `page.type`. + +**Inject (fallback):** `cnManifest`, `cnCustomComponents`, `cnTranslate` + +**Standalone props (take precedence over inject when provided):** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `manifest` | Object | no | `inject('cnManifest')` | Manifest object. When provided, overrides injected value. Enables use without CnAppRoot. | +| `customComponents` | Object | no | `inject('cnCustomComponents') \|\| {}` | Custom component registry. When provided, overrides injected value. | +| `translate` | Function | no | `inject('cnTranslate') \|\| ((k) => k)` | Translate function. When provided, overrides injected value. | + +**Route matching:** `CnPageRenderer` matches `$route.name === page.id`. Only name matching. The `page.route` field is the path pattern for the consuming app's vue-router config and is not used for matching here. + +**Type dispatch (via `defineAsyncComponent`):** +- `"index"` → `CnIndexPage` with `config.register`, `config.schema`, `config.columns`, `config.actions` forwarded as props +- `"detail"` → `CnDetailPage` with `config.register`, `config.schema`, `config.tabs` forwarded as props +- `"dashboard"` → `CnDashboardPage` with `config.widgets`, `config.layout` forwarded as props +- `"custom"` → component resolved from the effective `customComponents[page.component]`; renders nothing and logs a warning if the component name is not found in the registry + +**Root element:** `
` — enables devtools and Playwright selectors. + +**Component display name:** Set `this.$options.name = 'CnPageRenderer:' + page.id` in `created()` for Vue devtools identification. + +**Slot overrides for index and detail pages:** +- `headerComponent` field on a page entry: if set to a registry name, the resolved component is passed into `CnIndexPage`'s `#header` slot (or `CnDetailPage`'s equivalent). Note: `#header` and `#actions` slots are part of this change (additive — see task 3.4); existing consumers of `CnIndexPage`/`CnDetailPage` are not affected. +- `actionsComponent` field: same pattern for the `#actions` slot. + +If no matching page is found for the current route, `CnPageRenderer` MUST render nothing and log a `console.warn`. + +#### Scenario: Renders CnIndexPage for type=index + +- GIVEN the current route matches a page `{ id: "decisions", route: "/decisions", type: "index", config: { register: "reg1", schema: "schema1" } }` and `$route.name === "decisions"` +- WHEN CnPageRenderer renders +- THEN `CnIndexPage` MUST be rendered with `register="reg1"` and `schema="schema1"` +- AND the root element MUST have `data-page-id="decisions"` + +#### Scenario: Route matching is by name only + +- GIVEN `$route.name === 'decisions'` and a page with `id: 'decisions', route: '/decisions'` +- WHEN CnPageRenderer renders +- THEN CnPageRenderer MUST match the page using `$route.name === page.id` +- AND MUST NOT attempt to match by path + +#### Scenario: Renders CnDashboardPage for type=dashboard + +- GIVEN a page `{ id: "home", route: "/home", type: "dashboard", config: { widgets: [...], layout: [...] } }` and `$route.name === "home"` +- WHEN CnPageRenderer renders +- THEN `CnDashboardPage` MUST be rendered with the widgets and layout from config + +#### Scenario: Renders custom component for type=custom + +- GIVEN a page `{ id: "settings", route: "/settings", type: "custom", component: "SettingsPage" }`, `$route.name === "settings"`, and `cnCustomComponents = { SettingsPage: SettingsPageVue }` +- WHEN CnPageRenderer renders +- THEN `SettingsPageVue` MUST be rendered +- AND the root element MUST have `data-page-id="settings"` + +#### Scenario: Logs warning for unknown custom component + +- GIVEN a page with `type: "custom", component: "NonExistent"` and the component name is not in `cnCustomComponents` +- WHEN CnPageRenderer renders +- THEN `console.warn` MUST be called with a message identifying the missing component name +- AND nothing (empty div) MUST render rather than crashing + +#### Scenario: Logs warning for unmatched route + +- GIVEN the current `$route.name` does not match any `pages[].id` +- WHEN CnPageRenderer renders +- THEN `console.warn` MUST be called +- AND the component MUST render nothing + +#### Scenario: Component name is set for Vue devtools + +- GIVEN a page with `id: "decisions"` +- WHEN CnPageRenderer's `created()` hook runs +- THEN `this.$options.name` MUST be `"CnPageRenderer:decisions"` + +#### Scenario: Slot override wires headerComponent + +- GIVEN a page `{ id: "decisions", type: "index", headerComponent: "DecisionsHeader" }` and `cnCustomComponents = { DecisionsHeader: DecisionsHeaderVue }` +- WHEN CnPageRenderer renders +- THEN `DecisionsHeaderVue` MUST be passed into the `#header` slot of `CnIndexPage` + +#### Scenario: CnPageRenderer works standalone with explicit manifest prop + +- GIVEN `CnPageRenderer` is mounted with an explicit `manifest` prop and no parent CnAppRoot providing inject values +- WHEN `$route.name` matches a page in the provided manifest +- THEN CnPageRenderer MUST render the correct page component using the prop manifest + +#### Scenario: Route params are accessible directly in dispatched pages + +- GIVEN `$route.params.id === 'abc'` and a page rendering `CnDetailPage` for route `/decisions/:id` +- WHEN `CnPageRenderer` dispatches to `CnDetailPage` +- THEN the `CnDetailPage` instance MUST be able to access `$route.params.id` via Vue Router's globally injected `$route` (no special forwarding needed from CnPageRenderer) + +--- + +### Requirement: REQ-JMR-006 — Barrel Exports + +All new components and composables MUST be exported through the standard barrel chain. + +- `src/components/CnAppRoot/index.js` — re-exports `CnAppRoot` +- `src/components/CnAppNav/index.js` — re-exports `CnAppNav` +- `src/components/CnPageRenderer/index.js` — re-exports `CnPageRenderer` +- `src/components/CnAppLoading/index.js` — re-exports `CnAppLoading` +- `src/components/CnDependencyMissing/index.js` — re-exports `CnDependencyMissing` +- `src/components/index.js` — adds all five new components +- `src/index.js` — adds `useAppManifest`, `useAppStatus`, and all five new components + +#### Scenario: Tree-shakable named imports work + +- GIVEN `import { CnAppRoot, CnAppNav, CnPageRenderer, CnAppLoading, CnDependencyMissing, useAppManifest, useAppStatus } from '@conduction/nextcloud-vue'` +- THEN all imports MUST resolve without error +- AND unused page type components MUST not inflate the bundle of apps that don't use them (verified via Rollup bundle analysis) + +--- + +### Requirement: REQ-JMR-007 — CLAUDE.md Documentation Update + +The library's `CLAUDE.md` MUST be updated to document the new components and composable in the relevant sections. + +#### Scenario: CLAUDE.md reflects new components + +- GIVEN the CLAUDE.md file is read by an agent +- THEN `CnAppRoot`, `CnAppNav`, `CnPageRenderer` MUST appear under the "Layout & Pages" section +- AND `useAppManifest` MUST appear under the "Available Composables" section +- AND the manifest JSON shape MUST be documented with an example + +--- + +### Requirement: REQ-JMR-008 — Unit Tests + +All new units MUST have corresponding tests. + +**Test files:** +- `tests/components/CnAppRoot.spec.js` +- `tests/components/CnAppNav.spec.js` +- `tests/components/CnPageRenderer.spec.js` +- `tests/components/CnAppLoading.spec.js` +- `tests/components/CnDependencyMissing.spec.js` +- `tests/composables/useAppManifest.spec.js` +- `tests/composables/useAppStatus.spec.js` +- `tests/schemas/app-manifest.schema.spec.js` + +#### Scenario: CnAppRoot provides manifest to child + +- GIVEN a mounted `CnAppRoot` with a manifest prop +- WHEN a child component calls `inject('cnManifest')` +- THEN the injected value MUST match the manifest prop + +#### Scenario: useAppManifest falls back on invalid BE response + +- GIVEN a mocked axios that returns an invalid manifest (bad type field) +- WHEN `useAppManifest` runs +- THEN `manifest.value` MUST equal the bundled manifest (not the invalid one) +- AND `validationErrors.value` MUST be non-null + +#### Scenario: CnPageRenderer renders nothing for unrecognised route + +- GIVEN `$route.name = 'unknown'` and manifest has no matching page +- WHEN CnPageRenderer renders +- THEN the output MUST be empty and `console.warn` MUST have been called + +#### Scenario: Schema test file validates all fixtures + +- GIVEN fixture manifests in `tests/fixtures/` (one valid, one invalid) +- WHEN each is validated against `app-manifest.schema.json` +- THEN the valid fixture MUST pass and the invalid fixture MUST fail + +--- + +### Requirement: REQ-JMR-009 — NL Design System Compatibility + +All new components MUST use Nextcloud CSS variables exclusively. + +#### Scenario: No nldesign variable references + +- GIVEN the source of `CnAppRoot.vue`, `CnAppNav.vue`, `CnPageRenderer.vue`, `CnAppLoading.vue`, `CnDependencyMissing.vue` +- WHEN scanned for CSS variable references +- THEN NO reference to `--nldesign-*` variables MUST exist +- AND all color, border, and spacing values MUST use `var(--color-*)`, `var(--border-radius)`, or `var(--default-grid-baseline)` tokens + +--- + +### Requirement: REQ-JMR-010 — CnAppLoading: Full-Page Loading Screen + +`CnAppLoading` MUST be a Vue 2 Options API component providing a full-page centered loading screen displayed by `CnAppRoot` while `useAppManifest.isLoading === true`. + +**Props:** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `message` | String | no | `'Loading...'` | Loading message text (plain string; library does not import `t()`). | +| `logoUrl` | String | no | `''` | Optional URL to an app logo image shown above the spinner. | + +**Slots:** +- `#logo` — Override the logo area entirely. + +**Template:** Centered `NcLoadingIcon` with optional logo above and optional message below. Uses Nextcloud CSS variables for colors and spacing. + +#### Scenario: CnAppLoading renders spinner with default message + +- GIVEN `CnAppLoading` is mounted with no props +- WHEN rendered +- THEN an `NcLoadingIcon` MUST be present in the output +- AND the text `'Loading...'` MUST be visible + +#### Scenario: CnAppLoading accepts a custom message + +- GIVEN `CnAppLoading` is mounted with `message="Initialising app..."` +- WHEN rendered +- THEN the text `'Initialising app...'` MUST be visible + +#### Scenario: CnAppLoading accepts a #logo slot override + +- GIVEN `CnAppLoading` is mounted with a `#logo` slot containing `` +- WHEN rendered +- THEN the custom `` MUST appear in the output + +--- + +### Requirement: REQ-JMR-011 — CnDependencyMissing: Dependency-Check Screen + +`CnDependencyMissing` MUST be a Vue 2 Options API component providing a full-page screen displayed by `CnAppRoot` when one or more manifest dependencies are not installed or not enabled. + +**Props:** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `dependencies` | Array | yes | — | Array of `{ id: string, name: string, installUrl?: string, enabled: boolean }` describing the missing/disabled apps. | +| `appName` | String | no | `''` | The host app's display name. Used in the heading. | + +**Template:** A list of missing apps. For each dependency: show its `name`; if `!enabled` (installed but disabled) show an enable link to `/index.php/settings/apps`; if not installed show an install link. Uses Nextcloud CSS variables. + +#### Scenario: CnDependencyMissing lists missing dependencies + +- GIVEN `CnDependencyMissing` is mounted with `dependencies=[{ id: 'openregister', name: 'OpenRegister', enabled: false }]` +- WHEN rendered +- THEN the text `'OpenRegister'` MUST be visible +- AND a link to `/index.php/settings/apps` MUST be present + +#### Scenario: CnDependencyMissing shows install link for not-installed dependency + +- GIVEN `dependencies=[{ id: 'openregister', name: 'OpenRegister', installUrl: '/index.php/settings/apps/app-details/openregister', enabled: false }]` +- WHEN rendered +- THEN the install/enable link MUST use the provided `installUrl` + +--- + +### Requirement: REQ-JMR-012 — useAppStatus Composable + +`useAppStatus(appId)` MUST check whether the given Nextcloud app is installed and enabled. The composable is generic — it works for any app id (`openregister`, `opencatalogi`, etc.). + +**Signature:** `useAppStatus(appId: string): { installed: Ref, enabled: Ref, loading: Ref }` + +**Behaviour:** +- Uses `@nextcloud/capabilities` (or OCS apps endpoint as fallback) to determine status by checking whether the capabilities response contains a key matching `appId` +- Results are cached per `appId` for the page lifetime (no re-fetching on repeated calls with the same `appId`) +- On error: defaults to `{ installed: false, enabled: false }` and logs `console.warn` +- `loading` starts `true`, becomes `false` once the check completes + +#### Scenario: Returns installed=true when given app's capability is present + +- GIVEN `@nextcloud/capabilities` returns capabilities that include an `openregister` key +- WHEN `useAppStatus('openregister')` is called +- THEN `installed.value` MUST be `true` and `enabled.value` MUST be `true` after loading completes + +#### Scenario: Returns installed=false when given app's capability is absent + +- GIVEN `@nextcloud/capabilities` returns capabilities with no `opencatalogi` key +- WHEN `useAppStatus('opencatalogi')` is called +- THEN `installed.value` MUST be `false` after loading completes + +#### Scenario: Caches result per appId for the page lifetime + +- GIVEN `useAppStatus('openregister')` has been called and resolved +- WHEN `useAppStatus('openregister')` is called a second time on the same page +- THEN no additional network request MUST be made +- AND the returned refs MUST reflect the cached values immediately +- AND a separate call to `useAppStatus('opencatalogi')` MUST trigger its own fetch (caches are per-appId) + +#### Scenario: Returns safe defaults on capabilities error + +- GIVEN the capabilities API call throws an error +- WHEN `useAppStatus('openregister')` is called +- THEN `installed.value` MUST be `false` +- AND `console.warn` MUST have been called + +--- + +### Requirement: REQ-JMR-013 — CnAppRoot Phase Orchestration + +`CnAppRoot` MUST implement a three-phase rendering sequence: **loading → dependency-check → shell**. + +**Phase logic:** +1. **Loading phase** — while `useAppManifest.isLoading === true`: render `#loading` slot (default: ``). Shell is not rendered. +2. **Dependency-check phase** — after loading; for each entry in `manifest.dependencies`, call `useAppStatus(appId)`. If any dependency is missing/disabled: render `#dependency-missing` slot (default: ``). Shell is not rendered. +3. **Shell phase** — all dependencies satisfied: render `#menu` slot (default: ``) + `` + optional `#header-actions`, `#sidebar`, `#footer` slots. + +All slots are independently overridable by the consuming app. + +#### Scenario: CnAppRoot renders loading slot during manifest load + +- GIVEN `useAppManifest.isLoading === true` +- WHEN `CnAppRoot` renders +- THEN the `#loading` slot content MUST be rendered +- AND neither the menu nor router-view MUST be present + +#### Scenario: CnAppRoot renders dependency-missing slot when dependency absent + +- GIVEN manifest loaded successfully and `manifest.dependencies = ['openregister']` +- AND `useAppStatus('openregister')` returns `{ installed: false }` +- WHEN `CnAppRoot` renders +- THEN the `#dependency-missing` slot content MUST be rendered +- AND neither the menu nor router-view MUST be present + +#### Scenario: CnAppRoot renders shell when all dependencies satisfied + +- GIVEN manifest loaded and all dependencies installed/enabled +- WHEN `CnAppRoot` renders +- THEN the `#menu` slot (default `CnAppNav`) MUST be present +- AND `` MUST be present + +#### Scenario: Overriding #loading slot replaces CnAppLoading + +- GIVEN `CnAppRoot` has a `#loading` slot with `` +- AND `useAppManifest.isLoading === true` +- WHEN `CnAppRoot` renders +- THEN `` MUST render and `CnAppLoading` MUST NOT render + +--- + +## MODIFIED Requirements + +None — this capability is entirely new. No existing spec requirements are changed. Note: `CnIndexPage` and `CnDetailPage` gain additive `#header` and `#actions` slots (see task 3.4); no existing consumers are affected. + +## REMOVED Requirements + +None. + +--- + +## Resolved Decisions + +All deferred questions raised during artifact generation and the post-review patch pass have been resolved by the user: + +- `useAppManifest` accepts `bundledManifest` as an explicit argument (not dynamic import). See REQ-JMR-002. +- `CnAppNav` supports one level of `children[]` nesting. Deeper nesting is a non-breaking future extension. See REQ-JMR-004. +- `CnPageRenderer` forwards `config.*` fields as individual props matching existing page component interfaces (no breaking changes to `CnIndexPage`/`CnDetailPage`/`CnDashboardPage`). See REQ-JMR-005. +- The JSON Schema's `$id` is the GitHub raw URL on the `main` branch — `https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json`. Resolves today; updates with each merge to `main`. +- The dependency-check composable is generalised as `useAppStatus(appId)` rather than `useAppStatus`. Cached per `appId` for the page lifetime. See REQ-JMR-012. diff --git a/openspec/changes/add-json-manifest-renderer/tasks.md b/openspec/changes/add-json-manifest-renderer/tasks.md new file mode 100644 index 0000000..6cf09bb --- /dev/null +++ b/openspec/changes/add-json-manifest-renderer/tasks.md @@ -0,0 +1,251 @@ +# Tasks: add-json-manifest-renderer + +## 1. JSON Schema and TypeScript Types + +### Task 1.1: Create app-manifest JSON Schema +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-001` +- **files**: `src/schemas/app-manifest.schema.json` +- **acceptance_criteria**: + - GIVEN a valid manifest WHEN validated THEN schema passes + - GIVEN a manifest with `type: "wizard"` WHEN validated THEN schema rejects it + - GIVEN a manifest missing required page fields WHEN validated THEN schema lists missing fields +- [ ] 1.1 Create `src/schemas/app-manifest.schema.json` with `version`, `menu[]`, and `pages[]` definitions; use closed enum for `type`; add `children[]` on menu items (max 1 level); add optional `config`, `component`, `headerComponent`, `actionsComponent` on page items + +### Task 1.2: Generate TypeScript types from schema +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-001` +- **files**: `src/types/manifest.d.ts`, `package.json` (build script) +- **acceptance_criteria**: + - GIVEN `import type { ManifestPage, ManifestMenu } from '@conduction/nextcloud-vue'` THEN types resolve correctly in a consuming TypeScript app +- [ ] 1.2 Generate `src/types/manifest.d.ts` from the JSON Schema (run once manually; commit the file for IDE support); add a `generate:types` npm script for future regeneration + +--- + +## 2. useAppManifest Composable + +### Task 2.1: Implement useAppManifest — bundled load and validation +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-002` +- **files**: `src/composables/useAppManifest.js`, `src/composables/index.js` +- **acceptance_criteria**: + - GIVEN composable initialised WHEN called THEN `manifest.value` equals the bundled manifest immediately + - GIVEN BE returns invalid manifest WHEN merged THEN reverts to bundled and sets `validationErrors` +- [ ] 2.1 Create `src/composables/useAppManifest.js`: accept `(appId, bundledManifest, options?)`; set `manifest` ref to `bundledManifest` synchronously; set up Ajv instance with the JSON Schema for validation; store `options.endpoint` and `options.fetcher` for use in task 2.2; export from `src/composables/index.js` + +### Task 2.2: Implement useAppManifest — async BE fetch and merge +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-002` +- **files**: `src/composables/useAppManifest.js` +- **acceptance_criteria**: + - GIVEN BE returns HTTP 200 WHEN merged THEN `manifest.value.version` reflects BE value and bundled pages are preserved + - GIVEN BE returns HTTP 404 WHEN fetched THEN `manifest.value` equals bundled; no error thrown +- [ ] 2.2 Add async fetch inside `useAppManifest`: use `options.fetcher ?? axios` and `options.endpoint ?? generateUrl('/apps/{appId}/api/manifest')` as the target; deep-merge on 200; silently ignore non-200; run schema validation on merged result; revert to bundled + `console.warn` on failure + +### Task 2.3: Write useAppManifest unit tests +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-008` +- **files**: `tests/composables/useAppManifest.spec.js` +- **acceptance_criteria**: + - All four scenarios in REQ-JMR-002 pass as automated test cases +- [ ] 2.3 Create `tests/composables/useAppManifest.spec.js` with mocked axios via `options.fetcher`; test bundled-first, 200 merge, 404 fallback, invalid-manifest fallback, Options API compatibility, and custom endpoint option + +--- + +## 3. CnPageRenderer Component + +### Task 3.1: Implement CnPageRenderer — core dispatch and devtools +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-005` +- **files**: `src/components/CnPageRenderer/CnPageRenderer.vue`, `src/components/CnPageRenderer/index.js` +- **acceptance_criteria**: + - GIVEN type=index route matched THEN CnIndexPage renders with `register` and `schema` props + - GIVEN unmatched route THEN renders nothing + console.warn + - GIVEN page.id="decisions" THEN `data-page-id="decisions"` on root element +- [ ] 3.1 Create `CnPageRenderer.vue` (Options API); add props `manifest`, `customComponents`, `translate` (each optional, override inject when provided); inject `cnManifest`, `cnCustomComponents`, `cnTranslate` as fallbacks; compute `currentPage` by matching `$route.name === page.id` (name-only — no path matching); set `this.$options.name` in `created()`; render root `
`; map `type` to `defineAsyncComponent` imports for all four types; use `console.warn` for missing page or missing custom component + +### Task 3.2: Implement CnPageRenderer — slot overrides (headerComponent, actionsComponent) +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-005` +- **files**: `src/components/CnPageRenderer/CnPageRenderer.vue` +- **acceptance_criteria**: + - GIVEN page has `headerComponent: "DecisionsHeader"` THEN resolved component is passed into CnIndexPage's `#header` slot +- [ ] 3.2 Add slot override logic: resolve `page.headerComponent` and `page.actionsComponent` from `cnCustomComponents`; pass resolved components into `CnIndexPage`/`CnDetailPage` via scoped slots using dynamic slot definition + +### Task 3.3: Write CnPageRenderer unit tests +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-008` +- **files**: `tests/components/CnPageRenderer.spec.js` +- **acceptance_criteria**: + - All scenarios in REQ-JMR-005 pass as automated test cases +- [ ] 3.3 Write `CnPageRenderer.spec.js`; stub `CnIndexPage`, `CnDetailPage`, `CnDashboardPage`; provide mock manifest via `provide` AND via explicit prop; test each dispatch path, unmatched route, missing custom component, devtools name, name-only route matching, and standalone prop usage + +### Task 3.4: Verify and add #header and #actions slots on CnIndexPage / CnDetailPage +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-005` +- **files**: `src/components/CnIndexPage/CnIndexPage.vue`, `src/components/CnDetailPage/CnDetailPage.vue` +- **acceptance_criteria**: + - `#header` and `#actions` scoped slots exist on both components + - Existing consumers are not broken (additive only — slots have no default content that changes current rendering) +- [ ] 3.4 Inspect `CnIndexPage.vue` and `CnDetailPage.vue`; if `#header` and/or `#actions` slots are missing, add them as empty scoped slots; verify with `npm test` that no existing test breaks + +--- + +## 4. CnAppNav Component + +### Task 4.1: Implement CnAppNav — menu rendering and ordering +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-004` +- **files**: `src/components/CnAppNav/CnAppNav.vue`, `src/components/CnAppNav/index.js` +- **acceptance_criteria**: + - GIVEN menu items with different `order` values THEN rendered in ascending order + - GIVEN item with `label: "app.decisions"` THEN text resolved via injected `cnTranslate` + - GIVEN `$route.name` matches item.route THEN that item has `active` prop true +- [ ] 4.1 Create `CnAppNav.vue` (Options API); add optional props `manifest` and `translate` (override inject when provided); inject `cnManifest`, `cnTranslate` as fallbacks; sort effective `manifest.menu` by `order`; render `NcAppNavigation` + `NcAppNavigationItem` for each; compute `isActive` by `$route.name === item.route`; resolve labels via effective translate function; support `children[]` one level deep + +### Task 4.2: Implement CnAppNav — permission filtering +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-004` +- **files**: `src/components/CnAppNav/CnAppNav.vue` +- **acceptance_criteria**: + - GIVEN item with `permission: "admin"` and `permissions` prop is `["user"]` THEN item not rendered + - GIVEN `permissions` prop absent THEN all items render +- [ ] 4.2 Add `permissions` prop (Array, default `[]`); filter menu items: item renders if `!item.permission` or `permissions.includes(item.permission)`; same filter applied to children + +### Task 4.3: Write CnAppNav unit tests +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-008` +- **files**: `tests/components/CnAppNav.spec.js` +- **acceptance_criteria**: + - All six scenarios in REQ-JMR-004 pass as automated test cases +- [ ] 4.3 Write `CnAppNav.spec.js`; mock `$route`; provide mock manifest via `provide` AND via explicit `manifest` prop; test ordering, label resolution, permission filter, missing permissions prop, active route highlight (by route name), nested children, and standalone prop override + +--- + +## 5. CnAppRoot Component + +### Task 5.1: Implement CnAppRoot — wrapper and provide +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-003` +- **files**: `src/components/CnAppRoot/CnAppRoot.vue`, `src/components/CnAppRoot/index.js` +- **acceptance_criteria**: + - GIVEN `CnAppRoot` mounted with `manifest` prop THEN descendant `inject('cnManifest')` receives it + - GIVEN no `t` prop THEN `inject('cnTranslate')('some.key')` returns `'some.key'` + - GIVEN no `customComponents` prop THEN `inject('cnCustomComponents')` returns `{}` +- [ ] 5.1 Create `CnAppRoot.vue` (Options API); define props `manifest` (Object, required), `appId` (String, required), `customComponents` (Object, default `{}`), `t` (Function, default `(k) => k`), `permissions` (Array, default `[]`); call `provide()` with `cnManifest`, `cnCustomComponents`, `cnTranslate` (note: prop is named `t`, provide key is `cnTranslate`); implement phase orchestration (loading → dependency-check → shell): call `useAppStatus(id)` once for each entry in `manifest.dependencies`, aggregate results, render `#dependency-missing` slot if any are missing/disabled; template: `NcContent` wrapping `
` with conditional rendering for each phase; expose slots: `#loading`, `#dependency-missing`, `#menu`, `#header-actions`, `#sidebar`, `#footer`; default `#menu` slot renders ``; JSDoc every prop and slot + +### Task 5.2: Write CnAppRoot unit tests +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-003`, `specs/json-manifest-renderer/spec.md#requirement-req-jmr-013` +- **files**: `tests/components/CnAppRoot.spec.js` +- **acceptance_criteria**: + - All scenarios in REQ-JMR-003 and REQ-JMR-013 pass as automated test cases +- [ ] 5.2 Write `CnAppRoot.spec.js`; verify each provide key is injected correctly by a child; test identity-t fallback; test missing customComponents default; test phase orchestration: loading phase shows #loading slot, dependency-missing phase shows #dependency-missing slot, shell phase shows #menu + router-view; test #menu slot override + +--- + +## 6. Barrel Exports and Documentation + +### Task 6.1: Wire barrel exports +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-006` +- **files**: `src/components/index.js`, `src/index.js`, `src/composables/index.js` +- **acceptance_criteria**: + - GIVEN `import { CnAppRoot, CnAppNav, CnPageRenderer, useAppManifest } from '@conduction/nextcloud-vue'` THEN all resolve without error after `npm run build` +- [ ] 6.1 Add `CnAppRoot`, `CnAppNav`, `CnPageRenderer`, `CnAppLoading`, `CnDependencyMissing` to `src/components/index.js`; add all five components plus `useAppManifest` and `useAppStatus` to `src/index.js` and `src/composables/index.js` + +### Task 6.2: Update CLAUDE.md +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-007` +- **files**: `CLAUDE.md` +- **acceptance_criteria**: + - CLAUDE.md "Layout & Pages" section lists CnAppRoot, CnAppNav, CnPageRenderer + - CLAUDE.md "Available Composables" section lists useAppManifest with signature and example +- [ ] 6.2 Add new components to the "Layout & Pages" section of CLAUDE.md (`CnAppRoot`, `CnAppNav`, `CnPageRenderer`, `CnAppLoading`, `CnDependencyMissing`); add `useAppManifest` and `useAppStatus` to "Available Composables"; include a minimal manifest JSON example showing all four page types, slots, and four adoption tiers + +### Task 6.3: Write JSDoc for all public APIs +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-003-005` +- **files**: `src/components/CnAppRoot/CnAppRoot.vue`, `src/components/CnAppNav/CnAppNav.vue`, `src/components/CnPageRenderer/CnPageRenderer.vue`, `src/composables/useAppManifest.js` +- **acceptance_criteria**: + - Every prop, event, slot, and method on each component has a `@prop`/`@event`/`@slot`/`@method` JSDoc comment +- [ ] 6.3 Add JSDoc to every prop, event, slot, and method across all new files (CnAppRoot, CnAppNav, CnPageRenderer, CnAppLoading, CnDependencyMissing, useAppManifest, useAppStatus); document the `t` prop → `cnTranslate` inject key mapping in CnAppRoot JSDoc; verify with `npm run lint` (no missing JSDoc warnings) + +--- + +## 7. Schema Test and Fixtures + +### Task 7.1: Write JSON Schema tests and fixtures +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-008` +- **files**: `tests/schemas/app-manifest.schema.spec.js`, `tests/fixtures/manifest-valid.json`, `tests/fixtures/manifest-invalid.json` +- **acceptance_criteria**: + - GIVEN `manifest-valid.json` WHEN validated THEN passes + - GIVEN `manifest-invalid.json` WHEN validated THEN fails with errors +- [ ] 7.1 Create `tests/fixtures/manifest-valid.json` with all four page types represented; create `tests/fixtures/manifest-invalid.json` with a bad `type` value; write `tests/schemas/app-manifest.schema.spec.js` to validate both against the schema using Ajv + +--- + +## 8. Example / Demo Fixture + +### Task 8.1: Add example manifest to the repo +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-001` +- **files**: `examples/manifest-demo/manifest.json`, `examples/manifest-demo/README.md` +- **acceptance_criteria**: + - Fixture manifest exercises `index`, `detail`, `dashboard`, and `custom` page types + - Manifest validates against the JSON Schema (covered by task 7.1 fixture overlap or a separate fixture here) +- [ ] 8.1 Create `examples/manifest-demo/manifest.json` illustrating all four page types, menu ordering, permission filtering, and nested children; add `examples/manifest-demo/README.md` showing the minimal `main.ts` wiring to `CnAppRoot` + +--- + +## 9. Migration Guide + +### Task 9.1: Write consuming-app migration guide +- **spec_ref**: design.md#migration-plan +- **files**: `docs/migrating-to-manifest.md` +- **acceptance_criteria**: + - Guide covers: what to delete, what to create, how to wire CnAppRoot in main.ts, how to define manifest.json, how to add custom pages +- [ ] 9.1 Write `docs/migrating-to-manifest.md` covering all four adoption tiers with code snippets for each; Tier 1 (useAppManifest only), Tier 2 (+ CnPageRenderer standalone), Tier 3 (+ CnAppNav standalone or with manifest prop), Tier 4 (+ CnAppRoot with optional #menu slot override for custom menus); include before/after main.ts snippets, manifest.json examples, and notes on the custom-component registry pattern + +--- + +## 10. Build and CI Verification + +### Task 10.1: Build and test +- **spec_ref**: n/a — cross-cutting +- **files**: n/a +- **acceptance_criteria**: + - `npm test` passes (all new unit tests green, no regressions) + - `npm run build` succeeds and produces ESM + CJS bundles + - `CnAppRoot`, `CnAppNav`, `CnPageRenderer`, `CnAppLoading`, `CnDependencyMissing`, `useAppManifest`, `useAppStatus` appear in the built output +- [ ] 10.1 Run `npm test` and fix any failures; run `npm run build` and verify named exports are present in the bundle; run bundle analysis to confirm `defineAsyncComponent` splits page type components + +--- + +## 11. New Components and Composable: Loading, Dependency-Check + +### Task 11.1: Implement CnAppLoading +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-010` +- **files**: `src/components/CnAppLoading/CnAppLoading.vue`, `src/components/CnAppLoading/index.js` +- **acceptance_criteria**: + - GIVEN mounted with no props THEN NcLoadingIcon and default message visible + - GIVEN mounted with `message` prop THEN custom message visible + - GIVEN mounted with #logo slot THEN slot content appears +- [ ] 11.1 Create `CnAppLoading.vue` (Options API); props: `message` (String, default `'Loading...'`), `logoUrl` (String, default `''`); slot: `#logo`; template: centered layout with optional logo, `NcLoadingIcon`, and message text; Nextcloud CSS variables only; JSDoc props and slot + +### Task 11.2: Implement CnDependencyMissing +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-011` +- **files**: `src/components/CnDependencyMissing/CnDependencyMissing.vue`, `src/components/CnDependencyMissing/index.js` +- **acceptance_criteria**: + - GIVEN `dependencies=[{ id: 'openregister', name: 'OpenRegister', enabled: false }]` THEN name visible and link to settings apps present + - GIVEN dependency has `installUrl` THEN link uses that URL +- [ ] 11.2 Create `CnDependencyMissing.vue` (Options API); props: `dependencies` (Array, required — items `{ id, name, installUrl?, enabled }`), `appName` (String, default `''`); template: heading mentioning `appName` (if set), list of dependencies with name and install/enable link; link target is `installUrl` if provided, else `/index.php/settings/apps`; Nextcloud CSS variables only; JSDoc props + +### Task 11.3: Implement useAppStatus +- **spec_ref**: `specs/json-manifest-renderer/spec.md#requirement-req-jmr-012` +- **files**: `src/composables/useAppStatus.js`, `src/composables/index.js` +- **acceptance_criteria**: + - GIVEN `useAppStatus('openregister')` AND capabilities include `openregister` key THEN `installed=true`, `enabled=true` + - GIVEN `useAppStatus('opencatalogi')` AND capabilities omit `opencatalogi` THEN `installed=false` + - GIVEN second call to `useAppStatus('openregister')` after first resolved THEN no additional fetch (cache hit) + - GIVEN capabilities API throws THEN `installed=false` and `console.warn` called +- [ ] 11.3 Create `src/composables/useAppStatus.js`; signature `useAppStatus(appId: string)`; use `@nextcloud/capabilities` (or OCS apps endpoint as fallback) and read the key matching `appId`; return `{ installed: Ref, enabled: Ref, loading: Ref }`; cache results per `appId` in a module-level `Map` for the page lifetime; on error: `installed=false`, `enabled=false`, `console.warn`; export from `src/composables/index.js`; write `tests/composables/useAppStatus.spec.js` covering cache-per-appId, missing capability, and error path + +--- + +## Verification + +- [ ] All tasks checked off +- [ ] `npm test` passes (zero failures, zero skipped) +- [ ] `npm run build` succeeds with all seven new exports present (`CnAppRoot`, `CnAppNav`, `CnPageRenderer`, `CnAppLoading`, `CnDependencyMissing`, `useAppManifest`, `useAppStatus`) +- [ ] JSON Schema validates all fixture manifests correctly (including semver pattern and `$schema` field) +- [ ] No `cnT` references remain anywhere in the four artifact files (all replaced with `cnTranslate`) +- [ ] CnPageRenderer matches routes by `$route.name === page.id` only (no path matching) +- [ ] Manual test: mount CnAppRoot in a Nextcloud app dev environment and verify loading phase, dependency-missing phase, and shell phase for all four page types +- [ ] Manual test: mount CnPageRenderer standalone (no CnAppRoot) with explicit `manifest` prop — pages render correctly +- [ ] Manual test: CnAppRoot #menu slot override — custom menu component renders instead of CnAppNav +- [ ] CLAUDE.md updated and accurate +- [ ] No `--nldesign-*` variable references in any new CSS +- [ ] JSDoc complete on all public props, events, slots, and methods From fb36e862f121bc07e5754b14e8e096764768b6f6 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 22:20:25 +0200 Subject: [PATCH 028/143] feat(manifest): add manifest JSON Schema and TypeScript types Implements REQ-JMR-001 and the type-emission half of Task 1.2 from the add-json-manifest-renderer change. - src/schemas/app-manifest.schema.json: JSON Schema draft 2020-12 for the app manifest. Defines the top-level structure (version, menu, pages, dependencies), nested menuItem (one-level children) and page definitions, the closed page-type enum (index|detail|dashboard| custom), and additionalProperties:false on each object so manifest typos surface at validation time. - src/types/manifest.d.ts: TypeScript types mirroring the schema. Exports TManifest, TManifestMenuItem, TManifestMenuItemLeaf, TManifestPage, TPageType. - src/types/index.d.ts: re-exports the new manifest types so consumers can import from @conduction/nextcloud-vue. Types are hand-written for this first cut. The companion generate:types npm script (json-schema-to-typescript) will land in a follow-up commit alongside the dev-dep addition. --- src/schemas/app-manifest.schema.json | 133 +++++++++++++++++++++++++++ src/types/index.d.ts | 9 ++ src/types/manifest.d.ts | 57 ++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/schemas/app-manifest.schema.json create mode 100644 src/types/manifest.d.ts diff --git a/src/schemas/app-manifest.schema.json b/src/schemas/app-manifest.schema.json new file mode 100644 index 0000000..1c4d763 --- /dev/null +++ b/src/schemas/app-manifest.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json", + "title": "Conduction App Manifest", + "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.", + "version": "1.0.0", + "type": "object", + "required": ["version", "menu", "pages"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "Optional URL of the schema this manifest validates against. Enables editor auto-validation." + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$", + "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." + }, + "dependencies": { + "type": "array", + "default": [], + "items": { "type": "string" }, + "description": "Nextcloud app IDs that MUST be installed and enabled for this app to function. CnAppRoot checks each via useAppStatus(appId)." + }, + "menu": { + "type": "array", + "items": { "$ref": "#/$defs/menuItem" }, + "description": "Top-level navigation entries rendered by CnAppNav." + }, + "pages": { + "type": "array", + "items": { "$ref": "#/$defs/page" }, + "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." + } + }, + "$defs": { + "menuItem": { + "type": "object", + "required": ["id", "label"], + "additionalProperties": false, + "description": "A top-level navigation entry. May contain one level of nested children.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for this menu entry." + }, + "label": { + "type": "string", + "description": "i18n translation key resolved by the consuming app's t() function at render time." + }, + "icon": { + "type": "string", + "description": "CSS class for the icon (e.g. 'icon-checkmark')." + }, + "route": { + "type": "string", + "description": "Vue-router route name (matches a pages[].id) that this entry navigates to." + }, + "order": { + "type": "integer", + "description": "Display order in the menu. Items without an order render last." + }, + "permission": { + "type": "string", + "description": "Permission string the user must hold for this entry to render. CnAppNav filters items whose permission is not present in its permissions prop." + }, + "children": { + "type": "array", + "items": { "$ref": "#/$defs/menuItemLeaf" }, + "description": "Nested entries. Only one level of nesting is supported (children cannot themselves have children)." + } + } + }, + "menuItemLeaf": { + "type": "object", + "required": ["id", "label"], + "additionalProperties": false, + "description": "A nested menu entry. Has no further children.", + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" }, + "icon": { "type": "string" }, + "route": { "type": "string" }, + "order": { "type": "integer" }, + "permission": { "type": "string" } + } + }, + "page": { + "type": "object", + "required": ["id", "route", "type", "title"], + "additionalProperties": false, + "description": "A page definition. CnPageRenderer dispatches by `type` and matches by $route.name === page.id.", + "properties": { + "id": { + "type": "string", + "description": "Vue-router route name. MUST be unique across pages[]. CnPageRenderer matches the current route by id only (not by path)." + }, + "route": { + "type": "string", + "description": "Path pattern (e.g. '/decisions', '/decisions/:id'). Used by the consuming app when generating its vue-router config from the manifest." + }, + "type": { + "type": "string", + "enum": ["index", "detail", "dashboard", "custom"], + "description": "Closed enum. Adding new types requires a library schema release. Use type: 'custom' as the escape hatch for app-specific pages." + }, + "title": { + "type": "string", + "description": "i18n translation key for the page title." + }, + "config": { + "type": "object", + "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.", + "additionalProperties": true + }, + "component": { + "type": "string", + "description": "For type='custom': name resolved against the app-provided customComponents registry that is passed to CnAppRoot at boot." + }, + "headerComponent": { + "type": "string", + "description": "Optional registry name for a component injected into the page's #header slot. Enables partial bailout without going full type='custom'." + }, + "actionsComponent": { + "type": "string", + "description": "Optional registry name for a component injected into the page's #actions slot. Enables partial bailout without going full type='custom'." + } + } + } + } +} diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 7c3cd72..640288d 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -34,6 +34,15 @@ export type { TFile } from './file' export type { TTask, TTaskPriority, TTaskStatus } from './task' export type { TNotification, TNotificationType, TNotificationPriority } from './notification' +// Manifest types (json-manifest-renderer capability) +export type { + TManifest, + TManifestMenuItem, + TManifestMenuItemLeaf, + TManifestPage, + TPageType, +} from './manifest' + // Runtime exports from the store factory. The implementation is in // `../store/createCrudStore.js`; its companion `createCrudStore.d.ts` // provides full generic types (entity inference, feature-flag gating, diff --git a/src/types/manifest.d.ts b/src/types/manifest.d.ts new file mode 100644 index 0000000..4d86051 --- /dev/null +++ b/src/types/manifest.d.ts @@ -0,0 +1,57 @@ +/** + * TypeScript type definitions for the Conduction app manifest. + * + * The shape mirrors `src/schemas/app-manifest.schema.json` (JSON Schema + * draft 2020-12). Apps consume these types when authoring their + * `src/manifest.json` and when interacting with `useAppManifest`. + * + * @example + * import type { TManifest, TManifestPage } from '@conduction/nextcloud-vue' + */ + +/** The four supported page types. Closed enum — extending it requires a library schema release. */ +export type TPageType = 'index' | 'detail' | 'dashboard' | 'custom' + +/** A nested menu entry. Cannot have further children. */ +export interface TManifestMenuItemLeaf { + id: string + label: string + icon?: string + route?: string + order?: number + permission?: string +} + +/** A top-level menu entry. May contain one level of nested children. */ +export interface TManifestMenuItem extends TManifestMenuItemLeaf { + children?: TManifestMenuItemLeaf[] +} + +/** + * A page definition. `id` doubles as the vue-router route name; the + * renderer matches by `$route.name === page.id`. `route` is the path + * pattern, used when the consuming app builds its router config. + */ +export interface TManifestPage { + id: string + route: string + type: TPageType + title: string + config?: Record + component?: string + headerComponent?: string + actionsComponent?: string +} + +/** + * Top-level manifest shape. `version` is the semver of the manifest + * content (distinct from the schema's own version). `dependencies` + * lists Nextcloud app IDs that must be installed and enabled. + */ +export interface TManifest { + $schema?: string + version: string + dependencies?: string[] + menu: TManifestMenuItem[] + pages: TManifestPage[] +} From 382d986f37c5d59dff282352f62ee4c14715ff5f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 22:30:44 +0200 Subject: [PATCH 029/143] feat(manifest): add useAppManifest composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements REQ-JMR-002 from the json-manifest-renderer spec. The composable runs three phases: 1. Synchronous bundled load — bundledManifest is the immediate value; `manifest.value` is reactive from the start. 2. Async backend merge — `axios.get(generateUrl('/apps/{appId}/api/manifest'))` by default. On HTTP 200, the response is deep-merged over the bundled manifest. 4xx / network errors are silently ignored so apps without a backend endpoint keep working. 3. Validation — the merged result is checked against the manifest contract. On failure, `validationErrors` is populated, console.warn is emitted, and the bundled manifest stays in `manifest.value`. The signature accepts an `options` object so consumers can override the `endpoint` URL or substitute a custom `fetcher` (used in tests). The validator is hand-rolled and intentionally narrow: it covers the rules required by REQ-JMR-001 (top-level required fields, semver version, closed page-type enum, page id uniqueness, dependency types). The full schema (with additionalProperties:false, format constraints, etc.) is enforced by the BE / hydra CI validators consuming the same JSON Schema file with Ajv. Switching the FE validator to Ajv is a straightforward follow-up if richer error messages are needed in development. Tests cover all REQ-JMR-002 scenarios plus the new options.endpoint / options.fetcher overrides and the page-id uniqueness / version-pattern checks. 10/10 passing. Barrel updates in src/composables/index.js and src/index.js so the composable is importable as `import { useAppManifest } from '@conduction/nextcloud-vue'`. --- src/composables/index.js | 1 + src/composables/useAppManifest.js | 207 +++++++++++++++++++++++ src/index.js | 2 +- tests/composables/useAppManifest.spec.js | 168 ++++++++++++++++++ 4 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/composables/useAppManifest.js create mode 100644 tests/composables/useAppManifest.spec.js diff --git a/src/composables/index.js b/src/composables/index.js index ac3b974..d0e60bf 100644 --- a/src/composables/index.js +++ b/src/composables/index.js @@ -3,3 +3,4 @@ export { useDetailView } from './useDetailView.js' export { useSubResource } from './useSubResource.js' export { useDashboardView } from './useDashboardView.js' export { useContextMenu } from './useContextMenu.js' +export { useAppManifest } from './useAppManifest.js' diff --git a/src/composables/useAppManifest.js b/src/composables/useAppManifest.js new file mode 100644 index 0000000..fedc9c2 --- /dev/null +++ b/src/composables/useAppManifest.js @@ -0,0 +1,207 @@ +import { ref } from 'vue' +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' +import schema from '../schemas/app-manifest.schema.json' + +/** + * Composable that loads and validates a Conduction app manifest. + * + * The composable implements the three-phase flow specified in + * REQ-JMR-002 of the json-manifest-renderer capability: + * + * 1. Synchronous bundled load — `bundledManifest` is the immediate value. + * 2. Async backend merge — fetches `/index.php/apps/{appId}/api/manifest` + * and deep-merges any 200 response over the bundled manifest. 4xx / + * 5xx / network errors are silently ignored so apps work without a + * backend endpoint. + * 3. Validation — the merged result is validated against + * `app-manifest.schema.json`. On failure, the bundled manifest is + * kept and a `console.warn` is emitted with the error list. + * + * The returned manifest is reactive, so the future "app builder" backend + * can hot-swap the manifest without a page reload. + * + * @param {string} appId Nextcloud app ID. Used to build the default + * backend endpoint URL via `@nextcloud/router`. + * @param {object} bundledManifest The manifest shipped with the app (the + * default value, available synchronously). + * @param {object} [options] Configuration options. + * @param {string} [options.endpoint] Override the backend fetch URL. + * Useful for tests and alternative-host deployments. + * @param {Function} [options.fetcher] Override the fetch function. Must + * return a promise resolving to `{ status: number, data: object }`. + * Defaults to `axios.get` from `@nextcloud/axios` (which inherits the + * Nextcloud CSRF token automatically). + * @return {{ manifest: import('vue').Ref, isLoading: import('vue').Ref, validationErrors: import('vue').Ref }} + * + * @example Basic usage (Composition API) + * const { manifest, isLoading } = useAppManifest('decidesk', bundled) + * + * @example Inside an Options API component + * export default { + * setup() { + * return useAppManifest('decidesk', bundled) + * }, + * } + * + * @example Custom endpoint and fetcher (e.g. for tests) + * useAppManifest('decidesk', bundled, { + * endpoint: '/custom/manifest/url', + * fetcher: (url) => Promise.resolve({ status: 200, data: { ... } }), + * }) + */ +export function useAppManifest(appId, bundledManifest, options = {}) { + const manifest = ref(bundledManifest) + const isLoading = ref(true) + const validationErrors = ref(null) + + const endpoint = options.endpoint ?? generateUrl(`/apps/${appId}/api/manifest`) + const fetcher = options.fetcher ?? ((url) => axios.get(url)) + + ;(async () => { + try { + const response = await fetcher(endpoint) + if (!response || response.status !== 200 || !response.data) { + return + } + const merged = deepMerge(bundledManifest, response.data) + const result = validateManifest(merged) + if (!result.valid) { + validationErrors.value = result.errors + // eslint-disable-next-line no-console + console.warn( + '[useAppManifest] Backend manifest failed schema validation; falling back to bundled manifest.', + result.errors, + ) + return + } + manifest.value = merged + } catch (err) { + // Silent fallback on 404, network errors, non-200 responses. + // Apps without a backend endpoint should keep working. + } finally { + isLoading.value = false + } + })() + + return { manifest, isLoading, validationErrors } +} + +/** + * Deep-merge `source` into `target`, returning a new object. Plain + * objects are merged recursively; arrays are replaced (not concatenated) + * to match typical deep-merge semantics expected by manifest overrides. + * + * @param {object} target Base object. + * @param {object} source Object whose values take precedence. + * @return {object} New merged object. + */ +function deepMerge(target, source) { + if (!isPlainObject(target)) return source + if (!isPlainObject(source)) return source + const out = { ...target } + for (const key of Object.keys(source)) { + if (isPlainObject(source[key]) && isPlainObject(target[key])) { + out[key] = deepMerge(target[key], source[key]) + } else { + out[key] = source[key] + } + } + return out +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * Validate a manifest against the manifest JSON Schema. Hand-rolled + * minimal validator covering the rules required by REQ-JMR-001: + * - Top-level `version`, `menu`, `pages` are required. + * - `version` matches the semver pattern. + * - `pages[].type` is in the closed enum. + * - `pages[].id` is unique across the array. + * - Required fields on menu items and pages are present. + * + * The richer schema constraints (additionalProperties, format URI, etc.) + * are enforced by the BE / hydra CI validators that consume the same + * schema file with Ajv. The FE validator is intentionally narrow so that + * a FE-only check failure has a tight, actionable error message. + * + * @param {object} manifest The merged manifest to validate. + * @return {{ valid: boolean, errors: string[] }} + */ +function validateManifest(manifest) { + const errors = [] + + if (!isPlainObject(manifest)) { + return { valid: false, errors: ['manifest must be an object'] } + } + + const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ + + if (typeof manifest.version !== 'string') { + errors.push('/version must be a string') + } else if (!versionPattern.test(manifest.version)) { + errors.push(`/version "${manifest.version}" must match semver pattern`) + } + + if (!Array.isArray(manifest.menu)) { + errors.push('/menu must be an array') + } else { + manifest.menu.forEach((item, index) => { + if (!isPlainObject(item)) { + errors.push(`/menu/${index} must be an object`) + return + } + if (typeof item.id !== 'string') errors.push(`/menu/${index}/id must be a string`) + if (typeof item.label !== 'string') errors.push(`/menu/${index}/label must be a string`) + if (item.children !== undefined && !Array.isArray(item.children)) { + errors.push(`/menu/${index}/children must be an array`) + } + }) + } + + const allowedTypes = schema.$defs.page.properties.type.enum + + if (!Array.isArray(manifest.pages)) { + errors.push('/pages must be an array') + } else { + const seenIds = new Set() + manifest.pages.forEach((page, index) => { + if (!isPlainObject(page)) { + errors.push(`/pages/${index} must be an object`) + return + } + if (typeof page.id !== 'string') { + errors.push(`/pages/${index}/id must be a string`) + } else if (seenIds.has(page.id)) { + errors.push(`/pages/${index}/id "${page.id}" must be unique within pages[]`) + } else { + seenIds.add(page.id) + } + if (typeof page.route !== 'string') errors.push(`/pages/${index}/route must be a string`) + if (typeof page.title !== 'string') errors.push(`/pages/${index}/title must be a string`) + if (typeof page.type !== 'string' || !allowedTypes.includes(page.type)) { + errors.push(`/pages/${index}/type must be one of: ${allowedTypes.join(', ')}`) + } + if (page.type === 'custom' && typeof page.component !== 'string') { + errors.push(`/pages/${index}/component is required when type is "custom"`) + } + }) + } + + if (manifest.dependencies !== undefined) { + if (!Array.isArray(manifest.dependencies)) { + errors.push('/dependencies must be an array of strings') + } else { + manifest.dependencies.forEach((dep, index) => { + if (typeof dep !== 'string') { + errors.push(`/dependencies/${index} must be a string`) + } + }) + } + } + + return { valid: errors.length === 0, errors } +} diff --git a/src/index.js b/src/index.js index 7ace8e7..b0ddd65 100644 --- a/src/index.js +++ b/src/index.js @@ -84,7 +84,7 @@ export { } from './store/plugins/index.js' // Composables -export { useListView, useDetailView, useSubResource, useDashboardView, useContextMenu } from './composables/index.js' +export { useListView, useDetailView, useSubResource, useDashboardView, useContextMenu, useAppManifest } from './composables/index.js' // Localization export { registerTranslations } from './l10n/index.js' diff --git a/tests/composables/useAppManifest.spec.js b/tests/composables/useAppManifest.spec.js new file mode 100644 index 0000000..fb1c476 --- /dev/null +++ b/tests/composables/useAppManifest.spec.js @@ -0,0 +1,168 @@ +/** + * Tests for the useAppManifest composable. + * + * Covers REQ-JMR-002 scenarios from the json-manifest-renderer spec: + * - synchronous bundled load + * - deep-merge of BE response on 200 + * - silent fallback on 404 / network error + * - fallback on schema validation failure + * - Options API compatibility (via setup()) + * - options.endpoint and options.fetcher overrides + */ + +import { nextTick } from 'vue' + +jest.mock('@nextcloud/axios', () => ({ + __esModule: true, + default: { get: jest.fn() }, +})) +jest.mock('@nextcloud/router', () => ({ + generateUrl: jest.fn((path) => `/index.php${path}`), +})) + +const axios = require('@nextcloud/axios').default +const { useAppManifest } = require('../../src/composables/useAppManifest.js') + +const validBundled = { + version: '1.0.0', + menu: [{ id: 'home', label: 'app.home' }], + pages: [ + { id: 'home', route: '/', type: 'index', title: 'app.home', config: { register: 'r1', schema: 's1' } }, + ], +} + +/** Wait one microtask for the IIFE inside useAppManifest to resolve. */ +async function flush() { + await nextTick() + await Promise.resolve() + await nextTick() +} + +describe('useAppManifest', () => { + beforeEach(() => { + axios.get.mockReset() + }) + + it('returns the bundled manifest synchronously before the BE fetch resolves', () => { + // Make axios.get hang forever so the bundled value is what we observe. + axios.get.mockReturnValue(new Promise(() => {})) + const { manifest, isLoading } = useAppManifest('myapp', validBundled) + expect(manifest.value).toEqual(validBundled) + expect(isLoading.value).toBe(true) + }) + + it('deep-merges a 200 response over the bundled manifest', async () => { + axios.get.mockResolvedValue({ + status: 200, + data: { + version: '2.0.0', + menu: [{ id: 'extra', label: 'app.extra' }], + pages: validBundled.pages, + }, + }) + const { manifest, isLoading } = useAppManifest('myapp', validBundled) + await flush() + expect(manifest.value.version).toBe('2.0.0') + expect(manifest.value.menu[0].id).toBe('extra') + expect(manifest.value.pages).toEqual(validBundled.pages) + expect(isLoading.value).toBe(false) + }) + + it('silently falls back to bundled on a 404', async () => { + axios.get.mockRejectedValue({ response: { status: 404 } }) + const { manifest, isLoading, validationErrors } = useAppManifest('myapp', validBundled) + await flush() + expect(manifest.value).toEqual(validBundled) + expect(isLoading.value).toBe(false) + expect(validationErrors.value).toBeNull() + }) + + it('silently falls back on a network error', async () => { + axios.get.mockRejectedValue(new Error('network down')) + const { manifest } = useAppManifest('myapp', validBundled) + await flush() + expect(manifest.value).toEqual(validBundled) + }) + + it('falls back to bundled and sets validationErrors when the merged manifest fails schema validation', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + axios.get.mockResolvedValue({ + status: 200, + data: { + pages: [{ id: 'bad', route: '/bad', type: 'wizard', title: 'app.bad' }], + }, + }) + const { manifest, validationErrors } = useAppManifest('myapp', validBundled) + await flush() + expect(manifest.value).toEqual(validBundled) + expect(validationErrors.value).not.toBeNull() + expect(validationErrors.value.some((e) => e.includes('type'))).toBe(true) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('uses options.endpoint over the default generateUrl path', async () => { + axios.get.mockResolvedValue({ status: 200, data: validBundled }) + useAppManifest('myapp', validBundled, { endpoint: '/custom/manifest/url' }) + await flush() + expect(axios.get).toHaveBeenCalledWith('/custom/manifest/url') + }) + + it('uses options.fetcher when provided, bypassing axios entirely', async () => { + const customFetcher = jest.fn().mockResolvedValue({ + status: 200, + data: { version: '3.5.0', menu: validBundled.menu, pages: validBundled.pages }, + }) + const { manifest, isLoading } = useAppManifest('myapp', validBundled, { fetcher: customFetcher }) + await flush() + expect(customFetcher).toHaveBeenCalled() + expect(axios.get).not.toHaveBeenCalled() + expect(manifest.value.version).toBe('3.5.0') + expect(isLoading.value).toBe(false) + }) + + it('returns refs that are accessible from a Vue 2 Options API setup()', async () => { + axios.get.mockResolvedValue({ status: 200, data: validBundled }) + const result = useAppManifest('myapp', validBundled) + // The contract: the returned object is { manifest, isLoading, validationErrors } + // where each value is a Vue ref. Returning this from setup() exposes + // `this.manifest`, `this.isLoading`, `this.validationErrors` automatically + // in Vue 2.7's bridge. + expect(result).toHaveProperty('manifest') + expect(result).toHaveProperty('isLoading') + expect(result).toHaveProperty('validationErrors') + // Each must look like a ref (has a .value getter). + expect(result.manifest).toHaveProperty('value') + expect(result.isLoading).toHaveProperty('value') + expect(result.validationErrors).toHaveProperty('value') + await flush() + }) + + it('rejects a manifest with duplicate page ids', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + axios.get.mockResolvedValue({ + status: 200, + data: { + pages: [ + { id: 'dup', route: '/a', type: 'index', title: 'a' }, + { id: 'dup', route: '/b', type: 'index', title: 'b' }, + ], + }, + }) + const { manifest, validationErrors } = useAppManifest('myapp', validBundled) + await flush() + expect(manifest.value).toEqual(validBundled) + expect(validationErrors.value.some((e) => e.includes('unique'))).toBe(true) + warnSpy.mockRestore() + }) + + it('rejects a manifest with a non-semver version', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + axios.get.mockResolvedValue({ status: 200, data: { version: 'not-semver' } }) + const { manifest, validationErrors } = useAppManifest('myapp', validBundled) + await flush() + expect(manifest.value).toEqual(validBundled) + expect(validationErrors.value.some((e) => e.includes('semver'))).toBe(true) + warnSpy.mockRestore() + }) +}) From cdb35389184c90e93b0cf61ea95c062aa8e9db42 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 22:41:04 +0200 Subject: [PATCH 030/143] feat(manifest): add useAppStatus composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements REQ-JMR-012 from the json-manifest-renderer spec. Generic over any Nextcloud app id — `useAppStatus('openregister')`, `useAppStatus('opencatalogi')`, etc. — so the dependency-check phase of CnAppRoot can call it once per entry in `manifest.dependencies`. Reads from `@nextcloud/capabilities`, which is bootstrapped synchronously at page load, then exposes the result as Vue refs: { installed, enabled, loading } Results are cached per appId at module level for the page lifetime; subsequent calls return the same refs so all consumers share state. On capability-read failure the composable falls back to `{ installed: false, enabled: false }` and emits a `console.warn`, so a malformed capabilities response never crashes the app shell. Tests cover all REQ-JMR-012 scenarios plus null-capabilities and multi-appId cache isolation. 6/6 passing. Barrels updated: src/composables/index.js and src/index.js export the composable as `useAppStatus`. A test-only `__resetAppStatusCacheForTests` helper is exported from the composable file but not from the public barrel — only the test suite imports it directly to reset module state between cases. --- src/composables/index.js | 1 + src/composables/useAppStatus.js | 83 ++++++++++++++++++++++++ src/index.js | 2 +- tests/composables/useAppStatus.spec.js | 89 ++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/composables/useAppStatus.js create mode 100644 tests/composables/useAppStatus.spec.js diff --git a/src/composables/index.js b/src/composables/index.js index d0e60bf..c7aa2d6 100644 --- a/src/composables/index.js +++ b/src/composables/index.js @@ -4,3 +4,4 @@ export { useSubResource } from './useSubResource.js' export { useDashboardView } from './useDashboardView.js' export { useContextMenu } from './useContextMenu.js' export { useAppManifest } from './useAppManifest.js' +export { useAppStatus } from './useAppStatus.js' diff --git a/src/composables/useAppStatus.js b/src/composables/useAppStatus.js new file mode 100644 index 0000000..4f410a9 --- /dev/null +++ b/src/composables/useAppStatus.js @@ -0,0 +1,83 @@ +import { ref } from 'vue' +import { getCapabilities } from '@nextcloud/capabilities' + +/** + * Per-`appId` cache of status results. Populated lazily on first call. + * + * The Vue refs themselves are stored in the cache; subsequent calls for + * the same `appId` return the same refs so all consumers share state. + * + * Module-level lifetime — survives until the page is reloaded. + */ +const cache = new Map() + +/** + * Composable that reports whether a given Nextcloud app is installed + * and enabled, by checking the bootstrapped capabilities object. + * + * Implements REQ-JMR-012 of the json-manifest-renderer capability: + * - generic over `appId` so callers can check `openregister`, + * `opencatalogi`, or any other Conduction app + * - results are cached per `appId` for the page lifetime; repeated + * calls reuse the same refs without re-invoking `getCapabilities()` + * - on error the composable falls back to `{ installed: false, + * enabled: false }` and logs a `console.warn`, so a failed + * capabilities read never crashes the app shell + * + * The current implementation reads from `@nextcloud/capabilities`, + * which is populated synchronously at page bootstrap. The OCS apps + * endpoint is not used as a fallback today; if/when capabilities turn + * out to lag a fresh app install, that fallback is a small addition. + * + * @param {string} appId Nextcloud app id (e.g. `"openregister"`). + * @return {{ installed: import('vue').Ref, enabled: import('vue').Ref, loading: import('vue').Ref }} + * + * @example + * const { installed, enabled, loading } = useAppStatus('openregister') + * // After loading.value flips to false, installed.value is the answer. + */ +export function useAppStatus(appId) { + if (cache.has(appId)) { + return cache.get(appId) + } + + const installed = ref(false) + const enabled = ref(false) + const loading = ref(true) + + const result = { installed, enabled, loading } + cache.set(appId, result) + + try { + const capabilities = getCapabilities() + if ( + capabilities + && typeof capabilities === 'object' + && Object.prototype.hasOwnProperty.call(capabilities, appId) + ) { + installed.value = true + enabled.value = true + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + `[useAppStatus] Failed to read capabilities for "${appId}":`, + err, + ) + // installed and enabled stay false + } finally { + loading.value = false + } + + return result +} + +/** + * Test-only helper to reset the per-`appId` cache. Not exported from + * the package barrel — only the test suite imports it directly. + * + * @internal + */ +export function __resetAppStatusCacheForTests() { + cache.clear() +} diff --git a/src/index.js b/src/index.js index b0ddd65..3b61625 100644 --- a/src/index.js +++ b/src/index.js @@ -84,7 +84,7 @@ export { } from './store/plugins/index.js' // Composables -export { useListView, useDetailView, useSubResource, useDashboardView, useContextMenu, useAppManifest } from './composables/index.js' +export { useListView, useDetailView, useSubResource, useDashboardView, useContextMenu, useAppManifest, useAppStatus } from './composables/index.js' // Localization export { registerTranslations } from './l10n/index.js' diff --git a/tests/composables/useAppStatus.spec.js b/tests/composables/useAppStatus.spec.js new file mode 100644 index 0000000..7d61cb0 --- /dev/null +++ b/tests/composables/useAppStatus.spec.js @@ -0,0 +1,89 @@ +/** + * Tests for the useAppStatus composable. + * + * Covers REQ-JMR-012 scenarios from the json-manifest-renderer spec: + * - returns installed=true when the given app's capability is present + * - returns installed=false when the capability is absent + * - caches results per appId across repeated calls + * - returns safe defaults and logs a warning on capabilities error + */ + +jest.mock('@nextcloud/capabilities', () => ({ + getCapabilities: jest.fn(), +})) + +const { getCapabilities } = require('@nextcloud/capabilities') +const { + useAppStatus, + __resetAppStatusCacheForTests, +} = require('../../src/composables/useAppStatus.js') + +describe('useAppStatus', () => { + beforeEach(() => { + getCapabilities.mockReset() + __resetAppStatusCacheForTests() + }) + + it('returns installed=true and enabled=true when the appId capability is present', () => { + getCapabilities.mockReturnValue({ + files: {}, + openregister: { version: '1.0.0' }, + }) + const { installed, enabled, loading } = useAppStatus('openregister') + expect(installed.value).toBe(true) + expect(enabled.value).toBe(true) + expect(loading.value).toBe(false) + }) + + it('returns installed=false when the appId capability is absent', () => { + getCapabilities.mockReturnValue({ files: {} }) + const { installed, enabled, loading } = useAppStatus('opencatalogi') + expect(installed.value).toBe(false) + expect(enabled.value).toBe(false) + expect(loading.value).toBe(false) + }) + + it('caches the result per appId and does not re-fetch on repeated calls', () => { + getCapabilities.mockReturnValue({ openregister: {} }) + const first = useAppStatus('openregister') + const second = useAppStatus('openregister') + expect(getCapabilities).toHaveBeenCalledTimes(1) + // Returns the same ref objects so all consumers share state. + expect(second.installed).toBe(first.installed) + expect(second.enabled).toBe(first.enabled) + expect(second.loading).toBe(first.loading) + }) + + it('caches separately per appId', () => { + getCapabilities.mockReturnValue({ openregister: {} }) + useAppStatus('openregister') + useAppStatus('opencatalogi') + // Two distinct cache misses → two reads. + expect(getCapabilities).toHaveBeenCalledTimes(2) + // And the two results differ. + const orStatus = useAppStatus('openregister') + const ocStatus = useAppStatus('opencatalogi') + expect(orStatus.installed.value).toBe(true) + expect(ocStatus.installed.value).toBe(false) + }) + + it('returns safe defaults and warns when getCapabilities throws', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + getCapabilities.mockImplementation(() => { + throw new Error('capabilities boot failure') + }) + const { installed, enabled, loading } = useAppStatus('openregister') + expect(installed.value).toBe(false) + expect(enabled.value).toBe(false) + expect(loading.value).toBe(false) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('handles getCapabilities returning null without crashing', () => { + getCapabilities.mockReturnValue(null) + const { installed, loading } = useAppStatus('openregister') + expect(installed.value).toBe(false) + expect(loading.value).toBe(false) + }) +}) From 9ff1ba9425d5efb8b9c9a37d79a52e0babee5b03 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 22:48:41 +0200 Subject: [PATCH 031/143] feat(manifest): add CnPageRenderer (core dispatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements REQ-JMR-005 task 3.1 from the json-manifest-renderer spec — the type-dispatch and devtools-naming half. Slot overrides (headerComponent / actionsComponent — task 3.2) are deferred to a follow-up commit alongside task 3.4 which adds the matching #header slot to CnIndexPage / CnDetailPage. CnPageRenderer: - Mounted inside ; finds the manifest page whose `id` equals `$route.name` (no path matching, per REQ-JMR-005) - For type=index|detail|dashboard, dispatches to the matching Cn*Page component via defineAsyncComponent so apps that use only a subset of types do not pay the bundle cost for the others (notably the GridStack-backed CnDashboardPage) - For type=custom, resolves the component name against the app-provided customComponents registry; logs a console.warn and renders an empty wrapper if the name is not registered - Logs a console.warn when no manifest page matches the current route - Sets `$options.name = "CnPageRenderer:"` in created() for Vue devtools and stack-trace clarity - Renders a wrapper
with `display: contents` so it does not introduce a layout layer - Forwards `page.config` to the dispatched component as v-bind props, leaving config shape generic (per-type prop validation lives on the target page components themselves) Composability: - manifest, customComponents, translate are all accepted as props (each defaulting to null) and as injects (cnManifest, cnCustomComponents, cnTranslate). Props win over inject when both are present, enabling standalone use without CnAppRoot Tests: 16 cases covering route matching, all four type dispatch paths, config forwarding, devtools naming, props-vs-inject precedence, and defensive handling of missing manifest / missing route name. All pass (plus the existing 266 — 282/282 across 13 suites, no regressions). Barrels: CnPageRenderer added to src/components/index.js and src/index.js so consumers can `import { CnPageRenderer } from '@conduction/nextcloud-vue'`. --- .../CnPageRenderer/CnPageRenderer.vue | 180 +++++++++++++++++ src/components/CnPageRenderer/index.js | 3 + src/components/index.js | 1 + src/index.js | 1 + tests/components/CnPageRenderer.spec.js | 188 ++++++++++++++++++ 5 files changed, 373 insertions(+) create mode 100644 src/components/CnPageRenderer/CnPageRenderer.vue create mode 100644 src/components/CnPageRenderer/index.js create mode 100644 tests/components/CnPageRenderer.spec.js diff --git a/src/components/CnPageRenderer/CnPageRenderer.vue b/src/components/CnPageRenderer/CnPageRenderer.vue new file mode 100644 index 0000000..09d99b8 --- /dev/null +++ b/src/components/CnPageRenderer/CnPageRenderer.vue @@ -0,0 +1,180 @@ + + + + + + diff --git a/src/components/CnPageRenderer/index.js b/src/components/CnPageRenderer/index.js new file mode 100644 index 0000000..0e5c4f8 --- /dev/null +++ b/src/components/CnPageRenderer/index.js @@ -0,0 +1,3 @@ +import CnPageRenderer from './CnPageRenderer.vue' +export default CnPageRenderer +export { CnPageRenderer } diff --git a/src/components/index.js b/src/components/index.js index d19b485..544164e 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -55,3 +55,4 @@ export { CnTableWidget } from './CnTableWidget/index.js' export { CnNoteCard } from './CnNoteCard/index.js' export { CnObjectDataWidget } from './CnObjectDataWidget/index.js' export { CnObjectMetadataWidget } from './CnObjectMetadataWidget/index.js' +export { CnPageRenderer } from './CnPageRenderer/index.js' diff --git a/src/index.js b/src/index.js index 3b61625..2493e69 100644 --- a/src/index.js +++ b/src/index.js @@ -60,6 +60,7 @@ export { CnNoteCard, CnObjectDataWidget, CnObjectMetadataWidget, + CnPageRenderer, registerIcons, } from './components/index.js' diff --git a/tests/components/CnPageRenderer.spec.js b/tests/components/CnPageRenderer.spec.js new file mode 100644 index 0000000..0f13f11 --- /dev/null +++ b/tests/components/CnPageRenderer.spec.js @@ -0,0 +1,188 @@ +/** + * Tests for CnPageRenderer. + * + * Covers REQ-JMR-005 from the json-manifest-renderer spec — the core + * type-dispatch logic. Slot-override behaviour (REQ-JMR-005 head/actions + * scenarios) is covered in a follow-up commit alongside the + * `headerComponent` / `actionsComponent` wiring. + */ + +import { mount, shallowMount } from '@vue/test-utils' +import CnPageRenderer from '../../src/components/CnPageRenderer/CnPageRenderer.vue' + +const SettingsPageStub = { + name: 'SettingsPageStub', + template: '
settings
', +} + +const sampleManifest = { + version: '1.0.0', + menu: [], + pages: [ + { id: 'home', route: '/', type: 'index', title: 'app.home', config: { schema: { name: 's1' }, columns: [] } }, + { id: 'home-detail', route: '/items/:id', type: 'detail', title: 'app.detail' }, + { id: 'overview', route: '/overview', type: 'dashboard', title: 'app.overview' }, + { id: 'settings', route: '/settings', type: 'custom', title: 'app.settings', component: 'SettingsPage' }, + { id: 'broken', route: '/broken', type: 'custom', title: 'app.broken', component: 'NonExistent' }, + ], +} + +function mountRenderer(routeName, { useProps = false, customComponents = { SettingsPage: SettingsPageStub } } = {}) { + const provide = useProps + ? {} + : { + cnManifest: sampleManifest, + cnCustomComponents: customComponents, + cnTranslate: (k) => k, + } + const propsData = useProps + ? { + manifest: sampleManifest, + customComponents, + translate: (k) => k, + } + : {} + return shallowMount(CnPageRenderer, { + propsData, + provide, + mocks: { + $route: { name: routeName }, + }, + }) +} + +describe('CnPageRenderer', () => { + let warnSpy + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + describe('route matching', () => { + it('matches by $route.name === page.id and ignores route paths', () => { + const wrapper = mountRenderer('home') + expect(wrapper.vm.currentPage).toMatchObject({ id: 'home', type: 'index' }) + // data-page-id is on the wrapper div + expect(wrapper.attributes('data-page-id')).toBe('home') + }) + + it('renders nothing when no page matches the route', () => { + const wrapper = mountRenderer('does-not-exist') + expect(wrapper.vm.currentPage).toBeNull() + expect(wrapper.find('[data-page-id]').exists()).toBe(false) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('No page found for $route.name = "does-not-exist"'), + ) + }) + }) + + describe('type dispatch', () => { + it('returns an async component wrapper for type=index', () => { + const wrapper = mountRenderer('home') + const component = wrapper.vm.resolvedComponent + expect(component).not.toBeNull() + // defineAsyncComponent in Vue 2.7 returns either a function or + // an object wrapping the loader; both are acceptable. + expect(['function', 'object']).toContain(typeof component) + // And it is NOT one of the synchronous stub registry components. + expect(component).not.toBe(SettingsPageStub) + }) + + it('returns an async component wrapper for type=detail', () => { + const wrapper = mountRenderer('home-detail') + expect(wrapper.vm.resolvedComponent).not.toBeNull() + }) + + it('returns an async component wrapper for type=dashboard', () => { + const wrapper = mountRenderer('overview') + expect(wrapper.vm.resolvedComponent).not.toBeNull() + }) + + it('renders the resolved custom component synchronously', () => { + const wrapper = mountRenderer('settings') + expect(wrapper.vm.resolvedComponent).toBe(SettingsPageStub) + expect(wrapper.attributes('data-page-id')).toBe('settings') + }) + + it('logs a warning and renders an empty wrapper when a custom component is missing from the registry', () => { + const wrapper = mountRenderer('broken', { customComponents: {} }) + expect(wrapper.vm.resolvedComponent).toBeNull() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Custom component "NonExistent" not found in registry'), + ) + // Wrapper still renders (page exists), but no inner content. + expect(wrapper.attributes('data-page-id')).toBe('broken') + }) + }) + + describe('config forwarding', () => { + it('forwards page.config as resolvedProps', () => { + const wrapper = mountRenderer('home') + expect(wrapper.vm.resolvedProps).toEqual(sampleManifest.pages[0].config) + }) + + it('returns an empty object when page has no config', () => { + const wrapper = mountRenderer('home-detail') + expect(wrapper.vm.resolvedProps).toEqual({}) + }) + }) + + describe('devtools naming', () => { + it('sets $options.name to CnPageRenderer: in created()', () => { + const wrapper = mountRenderer('home') + expect(wrapper.vm.$options.name).toBe('CnPageRenderer:home') + }) + + it('keeps the original name when no page matches', () => { + const wrapper = mountRenderer('missing') + expect(wrapper.vm.$options.name).toBe('CnPageRenderer') + }) + }) + + describe('props vs inject precedence', () => { + it('uses the manifest passed via prop when no inject is available', () => { + const wrapper = mountRenderer('home', { useProps: true }) + expect(wrapper.vm.effectiveManifest).toEqual(sampleManifest) + expect(wrapper.vm.currentPage.id).toBe('home') + }) + + it('uses customComponents prop for custom-type resolution when no inject is available', () => { + const wrapper = mountRenderer('settings', { useProps: true }) + expect(wrapper.vm.resolvedComponent).toBe(SettingsPageStub) + }) + + it('falls back to inject when no manifest prop is given', () => { + const wrapper = mount(CnPageRenderer, { + provide: { + cnManifest: sampleManifest, + cnCustomComponents: { SettingsPage: SettingsPageStub }, + }, + mocks: { $route: { name: 'settings' } }, + }) + expect(wrapper.vm.effectiveManifest).toEqual(sampleManifest) + expect(wrapper.vm.resolvedComponent).toBe(SettingsPageStub) + }) + }) + + describe('defensive handling', () => { + it('returns null currentPage when manifest is missing pages array', () => { + const wrapper = shallowMount(CnPageRenderer, { + propsData: { manifest: { version: '1.0.0', menu: [], pages: undefined } }, + mocks: { $route: { name: 'home' } }, + }) + expect(wrapper.vm.currentPage).toBeNull() + }) + + it('returns null currentPage when $route.name is missing', () => { + const wrapper = shallowMount(CnPageRenderer, { + propsData: { manifest: sampleManifest }, + mocks: { $route: {} }, + }) + expect(wrapper.vm.currentPage).toBeNull() + }) + }) +}) From 21eac89ca2ca1eaf3c8cc544f71dca13d025f857 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 22:50:44 +0200 Subject: [PATCH 032/143] feat(manifest): wire CnPageRenderer slot overrides + add #header slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second half of REQ-JMR-005 from the json-manifest-renderer spec — tasks 3.2 (slot override wiring) and 3.4 (verify/add #header slot on existing page components). CnIndexPage / CnDetailPage: - Add a `#header` scoped slot wrapping the existing header element. Default slot content reproduces the previous rendering (CnPageHeader on CnIndexPage when showTitle is true; the icon + title + description block on CnDetailPage), so existing consumers see no change. Scoped slot props expose title, description, and icon for consumers that want to render a derived header without losing context. - On CnDetailPage, the existing #actions slot stays in the right column so headerComponent and actionsComponent overrides remain independent — replacing the header does not lose the actions area. CnPageRenderer: - Wires `page.headerComponent` → `#header` slot of the dispatched page component, and `page.actionsComponent` → `#actions` slot. Each is resolved against the same customComponents registry that handles `type: "custom"` pages. Missing names emit a console.warn naming the field and the unresolved name. - Slot props from the page component (title / description / icon / show-title etc.) are forwarded into the override component via `v-bind="slotProps"` so override authors can render derived UI. Tests: 3 new cases covering both overrides resolving from registry, both being null when not declared, and the missing-name warning. The full suite is green (285/285 across 13 suites, no regressions). --- src/components/CnDetailPage/CnDetailPage.vue | 43 ++++++++++------- src/components/CnIndexPage/CnIndexPage.vue | 16 +++++-- .../CnPageRenderer/CnPageRenderer.vue | 41 +++++++++++++++- tests/components/CnPageRenderer.spec.js | 48 ++++++++++++++++++- 4 files changed, 126 insertions(+), 22 deletions(-) diff --git a/src/components/CnDetailPage/CnDetailPage.vue b/src/components/CnDetailPage/CnDetailPage.vue index df8b496..e7833a8 100644 --- a/src/components/CnDetailPage/CnDetailPage.vue +++ b/src/components/CnDetailPage/CnDetailPage.vue @@ -24,23 +24,34 @@
-
- - - -
-

- {{ title }} -

-

- {{ description }} -

+ + +
+ + + +
+

+ {{ title }} +

+

+ {{ description }} +

+
-
+
diff --git a/src/components/CnIndexPage/CnIndexPage.vue b/src/components/CnIndexPage/CnIndexPage.vue index 7267409..9cac18e 100644 --- a/src/components/CnIndexPage/CnIndexPage.vue +++ b/src/components/CnIndexPage/CnIndexPage.vue @@ -1,11 +1,19 @@ @@ -154,6 +161,38 @@ export default { resolvedProps() { return this.currentPage?.config ?? {} }, + /** + * Custom component to render in the dispatched page's `#header` + * slot, resolved from `page.headerComponent` against the registry. + * Null when not set or not found in the registry (with a console + * warning emitted when an unknown name is referenced). + */ + headerOverride() { + return this.resolveSlotOverride('headerComponent') + }, + /** + * Custom component to render in the dispatched page's `#actions` + * slot, resolved from `page.actionsComponent` against the registry. + */ + actionsOverride() { + return this.resolveSlotOverride('actionsComponent') + }, + }, + + methods: { + resolveSlotOverride(field) { + const name = this.currentPage?.[field] + if (!name) return null + const resolved = this.effectiveCustomComponents[name] + if (!resolved) { + // eslint-disable-next-line no-console + console.warn( + `[CnPageRenderer] Slot-override component "${name}" referenced by page id "${this.currentPage.id}" (${field}) not found in registry.`, + ) + return null + } + return resolved + }, }, created() { diff --git a/tests/components/CnPageRenderer.spec.js b/tests/components/CnPageRenderer.spec.js index 0f13f11..825681b 100644 --- a/tests/components/CnPageRenderer.spec.js +++ b/tests/components/CnPageRenderer.spec.js @@ -15,6 +15,9 @@ const SettingsPageStub = { template: '
settings
', } +const HeaderStub = { name: 'HeaderStub', template: '
' } +const ActionsStub = { name: 'ActionsStub', template: '
' } + const sampleManifest = { version: '1.0.0', menu: [], @@ -24,10 +27,31 @@ const sampleManifest = { { id: 'overview', route: '/overview', type: 'dashboard', title: 'app.overview' }, { id: 'settings', route: '/settings', type: 'custom', title: 'app.settings', component: 'SettingsPage' }, { id: 'broken', route: '/broken', type: 'custom', title: 'app.broken', component: 'NonExistent' }, + { + id: 'home-with-header', + route: '/with-header', + type: 'index', + title: 'app.home', + headerComponent: 'MyHeader', + actionsComponent: 'MyActions', + }, + { + id: 'home-bad-header', + route: '/bad-header', + type: 'index', + title: 'app.home', + headerComponent: 'NonExistent', + }, ], } -function mountRenderer(routeName, { useProps = false, customComponents = { SettingsPage: SettingsPageStub } } = {}) { +const defaultRegistry = () => ({ + SettingsPage: SettingsPageStub, + MyHeader: HeaderStub, + MyActions: ActionsStub, +}) + +function mountRenderer(routeName, { useProps = false, customComponents = defaultRegistry() } = {}) { const provide = useProps ? {} : { @@ -168,6 +192,28 @@ describe('CnPageRenderer', () => { }) }) + describe('slot overrides (headerComponent / actionsComponent)', () => { + it('resolves headerComponent and actionsComponent against the registry', () => { + const wrapper = mountRenderer('home-with-header') + expect(wrapper.vm.headerOverride).toBe(HeaderStub) + expect(wrapper.vm.actionsOverride).toBe(ActionsStub) + }) + + it('returns null overrides when the page does not declare them', () => { + const wrapper = mountRenderer('home') + expect(wrapper.vm.headerOverride).toBeNull() + expect(wrapper.vm.actionsOverride).toBeNull() + }) + + it('logs a warning when a referenced override component is missing from the registry', () => { + const wrapper = mountRenderer('home-bad-header') + expect(wrapper.vm.headerOverride).toBeNull() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Slot-override component "NonExistent"'), + ) + }) + }) + describe('defensive handling', () => { it('returns null currentPage when manifest is missing pages array', () => { const wrapper = shallowMount(CnPageRenderer, { From 54bac1c651d365978706f227fce26f0891c35178 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 22:56:08 +0200 Subject: [PATCH 033/143] feat(manifest): add CnAppNav (manifest-driven app navigation) Implements REQ-JMR-004 from the json-manifest-renderer spec. CnAppNav reads `manifest.menu[]` and renders an NcAppNavigation tree. Menu items are sorted by `order` (ascending; items without an order sink to the bottom), filtered by `permission` against the `permissions` prop, and labeled via the injected translate function. Active state is set on the item whose `route` equals `$route.name`. One level of nested `children[]` is rendered as nested NcAppNavigationItem instances. Children inherit the same permission filter as their parent. Composability: manifest, translate, and permissions are all available as props that override the injected values from CnAppRoot, enabling standalone use without the full app shell. Tests: 14 cases covering ordering, label resolution, permission filtering (including children), active-route highlighting, children rendering, the props-vs-inject precedence, and a manifest with no menu array. All pass. Mocks: extends tests/__mocks__/nextcloud-vue.js with stubs for NcAppNavigation, NcAppNavigationItem, NcContent, NcEmptyContent so component tests can mount cleanly without the real @nextcloud/vue. Barrels updated: src/components/index.js and src/index.js export CnAppNav. --- src/components/CnAppNav/CnAppNav.vue | 134 +++++++++++++++++++++ src/components/CnAppNav/index.js | 3 + src/components/index.js | 1 + src/index.js | 1 + tests/__mocks__/nextcloud-vue.js | 8 ++ tests/components/CnAppNav.spec.js | 169 +++++++++++++++++++++++++++ 6 files changed, 316 insertions(+) create mode 100644 src/components/CnAppNav/CnAppNav.vue create mode 100644 src/components/CnAppNav/index.js create mode 100644 tests/components/CnAppNav.spec.js diff --git a/src/components/CnAppNav/CnAppNav.vue b/src/components/CnAppNav/CnAppNav.vue new file mode 100644 index 0000000..d84037d --- /dev/null +++ b/src/components/CnAppNav/CnAppNav.vue @@ -0,0 +1,134 @@ + + + + diff --git a/src/components/CnAppNav/index.js b/src/components/CnAppNav/index.js new file mode 100644 index 0000000..dcb2192 --- /dev/null +++ b/src/components/CnAppNav/index.js @@ -0,0 +1,3 @@ +import CnAppNav from './CnAppNav.vue' +export default CnAppNav +export { CnAppNav } diff --git a/src/components/index.js b/src/components/index.js index 544164e..b26bf30 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -56,3 +56,4 @@ export { CnNoteCard } from './CnNoteCard/index.js' export { CnObjectDataWidget } from './CnObjectDataWidget/index.js' export { CnObjectMetadataWidget } from './CnObjectMetadataWidget/index.js' export { CnPageRenderer } from './CnPageRenderer/index.js' +export { CnAppNav } from './CnAppNav/index.js' diff --git a/src/index.js b/src/index.js index 2493e69..52df6ad 100644 --- a/src/index.js +++ b/src/index.js @@ -61,6 +61,7 @@ export { CnObjectDataWidget, CnObjectMetadataWidget, CnPageRenderer, + CnAppNav, registerIcons, } from './components/index.js' diff --git a/tests/__mocks__/nextcloud-vue.js b/tests/__mocks__/nextcloud-vue.js index 7e2753e..009324a 100644 --- a/tests/__mocks__/nextcloud-vue.js +++ b/tests/__mocks__/nextcloud-vue.js @@ -15,6 +15,10 @@ export const NcNoteCard = createStub('NcNoteCard') export const NcLoadingIcon = createStub('NcLoadingIcon') export const NcTextField = createStub('NcTextField') export const NcCheckboxRadioSwitch = createStub('NcCheckboxRadioSwitch') +export const NcAppNavigation = createStub('NcAppNavigation') +export const NcAppNavigationItem = createStub('NcAppNavigationItem') +export const NcContent = createStub('NcContent') +export const NcEmptyContent = createStub('NcEmptyContent') export default { NcDialog, @@ -23,4 +27,8 @@ export default { NcLoadingIcon, NcTextField, NcCheckboxRadioSwitch, + NcAppNavigation, + NcAppNavigationItem, + NcContent, + NcEmptyContent, } diff --git a/tests/components/CnAppNav.spec.js b/tests/components/CnAppNav.spec.js new file mode 100644 index 0000000..d85ad3e --- /dev/null +++ b/tests/components/CnAppNav.spec.js @@ -0,0 +1,169 @@ +/** + * Tests for CnAppNav. + * + * Covers REQ-JMR-004 from the json-manifest-renderer spec — manifest + * menu rendering, ordering, permission filtering, label resolution + * via the injected translate function, active-route highlighting, + * one-level nested children, and the standalone props-vs-inject + * fallback path. + */ + +import { mount } from '@vue/test-utils' +import CnAppNav from '../../src/components/CnAppNav/CnAppNav.vue' + +const baseManifest = { + version: '1.0.0', + pages: [], + menu: [ + { id: 'b', label: 'app.b', route: 'b', order: 2 }, + { id: 'a', label: 'app.a', route: 'a', order: 1 }, + { id: 'no-order', label: 'app.no-order', route: 'no-order' }, + { + id: 'c', + label: 'app.c', + route: 'c', + order: 3, + children: [ + { id: 'c1', label: 'app.c1', route: 'c1' }, + { id: 'c2', label: 'app.c2', route: 'c2', permission: 'admin' }, + ], + }, + { id: 'admin', label: 'app.admin', route: 'admin', order: 4, permission: 'admin' }, + ], +} + +function mountNav({ + manifest = baseManifest, + permissions = [], + useProps = false, + routeName = 'a', + translate, +} = {}) { + const provide = useProps + ? {} + : { + cnManifest: manifest, + cnTranslate: translate ?? ((k) => k), + } + const propsData = { + permissions, + ...(useProps + ? { + manifest, + translate: translate ?? ((k) => k), + } + : {}), + } + return mount(CnAppNav, { + propsData, + provide, + mocks: { + $route: { name: routeName }, + }, + }) +} + +describe('CnAppNav', () => { + describe('ordering', () => { + it('renders top-level items sorted by ascending `order`, with unordered items last', () => { + const wrapper = mountNav() + const ids = wrapper.vm.visibleItems.map((item) => item.id) + // permissions=[] means no permission gating → all items render. + // Order by `order` ascending: a(1), b(2), c(3), admin(4), then unordered. + expect(ids).toEqual(['a', 'b', 'c', 'admin', 'no-order']) + }) + }) + + describe('label resolution', () => { + it('resolves labels via the injected translate function', () => { + const translate = jest.fn((key) => key.split('.').pop()) + mountNav({ translate }) + expect(translate).toHaveBeenCalledWith('app.a') + expect(translate).toHaveBeenCalledWith('app.b') + }) + + it('uses the props-supplied translate when no inject is available', () => { + const translate = jest.fn((key) => `[t]${key}`) + mountNav({ useProps: true, translate }) + expect(translate).toHaveBeenCalledWith('app.a') + }) + }) + + describe('permission filtering', () => { + it('hides items whose permission is not in the permissions prop', () => { + const wrapper = mountNav({ permissions: ['user'] }) + const ids = wrapper.vm.visibleItems.map((item) => item.id) + expect(ids).not.toContain('admin') + }) + + it('shows items with matching permission', () => { + const wrapper = mountNav({ permissions: ['admin'] }) + const ids = wrapper.vm.visibleItems.map((item) => item.id) + expect(ids).toContain('admin') + }) + + it('renders all items when the permissions prop is empty (default)', () => { + const wrapper = mountNav() + const ids = wrapper.vm.visibleItems.map((item) => item.id) + expect(ids).toContain('admin') + }) + + it('filters children by permission too', () => { + const wrapper = mountNav({ permissions: ['user'] }) + const c = wrapper.vm.visibleItems.find((i) => i.id === 'c') + const childIds = wrapper.vm.visibleChildren(c).map((ch) => ch.id) + expect(childIds).toEqual(['c1']) + }) + }) + + describe('active route', () => { + it('marks the item whose route equals $route.name as active', () => { + const wrapper = mountNav({ routeName: 'b' }) + expect(wrapper.vm.isActive({ route: 'b' })).toBe(true) + expect(wrapper.vm.isActive({ route: 'a' })).toBe(false) + }) + + it('returns false when item has no route', () => { + const wrapper = mountNav() + expect(wrapper.vm.isActive({ id: 'noroute' })).toBe(false) + }) + }) + + describe('children rendering', () => { + it('returns child items when present', () => { + const wrapper = mountNav() + const c = wrapper.vm.visibleItems.find((i) => i.id === 'c') + expect(wrapper.vm.visibleChildren(c)).toHaveLength(2) + }) + + it('returns an empty array when item has no children', () => { + const wrapper = mountNav() + const a = wrapper.vm.visibleItems.find((i) => i.id === 'a') + expect(wrapper.vm.visibleChildren(a)).toEqual([]) + }) + }) + + describe('props vs inject', () => { + it('uses manifest prop when provided', () => { + const customManifest = { + version: '1.0.0', + pages: [], + menu: [{ id: 'only', label: 'app.only' }], + } + const wrapper = mountNav({ manifest: customManifest, useProps: true }) + expect(wrapper.vm.visibleItems).toEqual([{ id: 'only', label: 'app.only' }]) + }) + + it('falls back to injected manifest when no prop given', () => { + const wrapper = mountNav() + expect(wrapper.vm.effectiveManifest).toEqual(baseManifest) + }) + }) + + describe('defensive handling', () => { + it('handles a manifest with no menu array', () => { + const wrapper = mountNav({ manifest: { version: '1.0.0', pages: [] }, useProps: true }) + expect(wrapper.vm.visibleItems).toEqual([]) + }) + }) +}) From 1d54a2957382b9f943a2c251010277b3d0ceca11 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 22:57:37 +0200 Subject: [PATCH 034/143] feat(manifest): add CnAppLoading and CnDependencyMissing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements REQ-JMR-010 and REQ-JMR-011 from the json-manifest-renderer spec — the two phase-screen components used by CnAppRoot during the loading and dependency-check phases respectively. CnAppLoading (REQ-JMR-010): - Centered full-page loading screen - NcLoadingIcon spinner, optional logo, message - Props: `message` (default "Loading…"), `logoUrl` (default "") - Slot: `#logo` (overrides the default when consumers want custom branding) - Nextcloud CSS variables only CnDependencyMissing (REQ-JMR-011): - Full-page screen listing the missing/disabled dependencies and linking to install or enable each - Props: `dependencies` (required Array of `{ id, name?, installUrl?, enabled? }`), `appName`, plus optional `heading`, `intro`, `installLabel`, `enableLabel` for localisation overrides - Falls back to `/index.php/settings/apps` when an entry has no explicit `installUrl` - Nextcloud CSS variables only Both components are designed to be used internally by CnAppRoot via its `#loading` and `#dependency-missing` slots; consuming apps can override either slot for custom branding. Tests: 6 cases for CnAppLoading and 7 for CnDependencyMissing covering default + overridden labels, slot override, link resolution, the two label paths (install vs enable), and the no-nldesign-variable check. 13/13 passing. Barrels updated. --- src/components/CnAppLoading/CnAppLoading.vue | 93 +++++++++++ src/components/CnAppLoading/index.js | 3 + .../CnDependencyMissing.vue | 152 ++++++++++++++++++ src/components/CnDependencyMissing/index.js | 3 + src/components/index.js | 2 + src/index.js | 2 + tests/components/CnAppLoading.spec.js | 38 +++++ tests/components/CnDependencyMissing.spec.js | 80 +++++++++ 8 files changed, 373 insertions(+) create mode 100644 src/components/CnAppLoading/CnAppLoading.vue create mode 100644 src/components/CnAppLoading/index.js create mode 100644 src/components/CnDependencyMissing/CnDependencyMissing.vue create mode 100644 src/components/CnDependencyMissing/index.js create mode 100644 tests/components/CnAppLoading.spec.js create mode 100644 tests/components/CnDependencyMissing.spec.js diff --git a/src/components/CnAppLoading/CnAppLoading.vue b/src/components/CnAppLoading/CnAppLoading.vue new file mode 100644 index 0000000..600f823 --- /dev/null +++ b/src/components/CnAppLoading/CnAppLoading.vue @@ -0,0 +1,93 @@ + + + + + + diff --git a/src/components/CnAppLoading/index.js b/src/components/CnAppLoading/index.js new file mode 100644 index 0000000..3065264 --- /dev/null +++ b/src/components/CnAppLoading/index.js @@ -0,0 +1,3 @@ +import CnAppLoading from './CnAppLoading.vue' +export default CnAppLoading +export { CnAppLoading } diff --git a/src/components/CnDependencyMissing/CnDependencyMissing.vue b/src/components/CnDependencyMissing/CnDependencyMissing.vue new file mode 100644 index 0000000..1611a96 --- /dev/null +++ b/src/components/CnDependencyMissing/CnDependencyMissing.vue @@ -0,0 +1,152 @@ + + + + + + diff --git a/src/components/CnDependencyMissing/index.js b/src/components/CnDependencyMissing/index.js new file mode 100644 index 0000000..e7848c2 --- /dev/null +++ b/src/components/CnDependencyMissing/index.js @@ -0,0 +1,3 @@ +import CnDependencyMissing from './CnDependencyMissing.vue' +export default CnDependencyMissing +export { CnDependencyMissing } diff --git a/src/components/index.js b/src/components/index.js index b26bf30..5a4ab73 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -57,3 +57,5 @@ export { CnObjectDataWidget } from './CnObjectDataWidget/index.js' export { CnObjectMetadataWidget } from './CnObjectMetadataWidget/index.js' export { CnPageRenderer } from './CnPageRenderer/index.js' export { CnAppNav } from './CnAppNav/index.js' +export { CnAppLoading } from './CnAppLoading/index.js' +export { CnDependencyMissing } from './CnDependencyMissing/index.js' diff --git a/src/index.js b/src/index.js index 52df6ad..0eb622e 100644 --- a/src/index.js +++ b/src/index.js @@ -62,6 +62,8 @@ export { CnObjectMetadataWidget, CnPageRenderer, CnAppNav, + CnAppLoading, + CnDependencyMissing, registerIcons, } from './components/index.js' diff --git a/tests/components/CnAppLoading.spec.js b/tests/components/CnAppLoading.spec.js new file mode 100644 index 0000000..be8d472 --- /dev/null +++ b/tests/components/CnAppLoading.spec.js @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils' +import CnAppLoading from '../../src/components/CnAppLoading/CnAppLoading.vue' + +describe('CnAppLoading', () => { + it('renders the default message', () => { + const wrapper = mount(CnAppLoading) + expect(wrapper.text()).toContain('Loading...') + }) + + it('renders a custom message via prop', () => { + const wrapper = mount(CnAppLoading, { propsData: { message: 'Booting…' } }) + expect(wrapper.text()).toContain('Booting…') + }) + + it('renders a logo image when logoUrl is set', () => { + const wrapper = mount(CnAppLoading, { propsData: { logoUrl: '/img/logo.svg' } }) + expect(wrapper.find('img.cn-app-loading__logo').attributes('src')).toBe('/img/logo.svg') + }) + + it('does not render a logo when logoUrl is empty', () => { + const wrapper = mount(CnAppLoading) + expect(wrapper.find('img.cn-app-loading__logo').exists()).toBe(false) + }) + + it('renders #logo slot content overriding the default img', () => { + const wrapper = mount(CnAppLoading, { + slots: { logo: '' }, + }) + expect(wrapper.find('.custom-logo').exists()).toBe(true) + expect(wrapper.find('img.cn-app-loading__logo').exists()).toBe(false) + }) + + it('uses Nextcloud CSS variables only', () => { + const wrapper = mount(CnAppLoading) + const html = wrapper.html() + expect(html).not.toContain('--nldesign-') + }) +}) diff --git a/tests/components/CnDependencyMissing.spec.js b/tests/components/CnDependencyMissing.spec.js new file mode 100644 index 0000000..952e82d --- /dev/null +++ b/tests/components/CnDependencyMissing.spec.js @@ -0,0 +1,80 @@ +import { mount } from '@vue/test-utils' +import CnDependencyMissing from '../../src/components/CnDependencyMissing/CnDependencyMissing.vue' + +describe('CnDependencyMissing', () => { + it('renders an item per dependency', () => { + const wrapper = mount(CnDependencyMissing, { + propsData: { + dependencies: [ + { id: 'openregister', name: 'OpenRegister' }, + { id: 'opencatalogi', name: 'OpenCatalogi' }, + ], + }, + }) + const items = wrapper.findAll('.cn-dependency-missing__item') + expect(items).toHaveLength(2) + expect(items.at(0).text()).toContain('OpenRegister') + expect(items.at(1).text()).toContain('OpenCatalogi') + }) + + it('falls back to id when no name provided', () => { + const wrapper = mount(CnDependencyMissing, { + propsData: { dependencies: [{ id: 'openregister' }] }, + }) + expect(wrapper.text()).toContain('openregister') + }) + + it('uses dep.installUrl when provided', () => { + const wrapper = mount(CnDependencyMissing, { + propsData: { + dependencies: [ + { + id: 'openregister', + name: 'OpenRegister', + installUrl: '/index.php/settings/apps/app-details/openregister', + }, + ], + }, + }) + const link = wrapper.find('.cn-dependency-missing__item-link') + expect(link.attributes('href')).toBe('/index.php/settings/apps/app-details/openregister') + }) + + it('falls back to /index.php/settings/apps when no installUrl provided', () => { + const wrapper = mount(CnDependencyMissing, { + propsData: { dependencies: [{ id: 'openregister', name: 'OpenRegister' }] }, + }) + expect(wrapper.find('.cn-dependency-missing__item-link').attributes('href')).toBe( + '/index.php/settings/apps', + ) + }) + + it('uses the enable label when dep.enabled is false', () => { + const wrapper = mount(CnDependencyMissing, { + propsData: { + dependencies: [{ id: 'openregister', name: 'OpenRegister', enabled: false }], + enableLabel: 'Enable now', + installLabel: 'Install now', + }, + }) + expect(wrapper.find('.cn-dependency-missing__item-link').text()).toBe('Enable now') + }) + + it('uses the install label otherwise', () => { + const wrapper = mount(CnDependencyMissing, { + propsData: { + dependencies: [{ id: 'openregister', name: 'OpenRegister' }], + enableLabel: 'Enable now', + installLabel: 'Install now', + }, + }) + expect(wrapper.find('.cn-dependency-missing__item-link').text()).toBe('Install now') + }) + + it('uses Nextcloud CSS variables only', () => { + const wrapper = mount(CnDependencyMissing, { + propsData: { dependencies: [{ id: 'x', name: 'X' }] }, + }) + expect(wrapper.html()).not.toContain('--nldesign-') + }) +}) From ee055112059b04c55c66eb73fb429a08718b84b7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 23:03:33 +0200 Subject: [PATCH 035/143] feat(manifest): add CnAppRoot orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements REQ-JMR-003 (provide/inject + slots) and REQ-JMR-013 (phase orchestration) from the json-manifest-renderer spec. Closes the core implementation track of the change. CnAppRoot is the optional top-level shell that ties the manifest system together. It accepts the reactive manifest from useAppManifest and orchestrates three rendering phases: 1. Loading phase — while `isLoading` is true. Default content is ; consumers can override via the #loading slot. 2. Dependency-check phase — once loaded, calls useAppStatus(id) for each entry in `manifest.dependencies` and renders when any are missing/disabled. Override via #dependency-missing slot. 3. Shell phase — manifest valid + dependencies satisfied. Renders #menu (default ), , plus optional #header-actions / #sidebar / #footer slots. Provides cnManifest, cnCustomComponents, and cnTranslate so any descendant (CnPageRenderer, CnAppNav, app-side components) can inject them without prop drilling. Naming note: the translate prop is named `translate`, not `t`, to avoid shadowing the global `t()` method that Conduction apps install via Vue.mixin({ methods: { t, n } }) — a real collision the spec hadn't accounted for. The provide key is still `cnTranslate`. Spec will be updated alongside the migration guide. Tests: 13 cases covering all three phases, every slot override (loading, dependency-missing, menu, header-actions, sidebar, footer), provide/inject for each of the three keys including default fallbacks, and multi-dependency aggregation. All pass; full suite is 325/325 across 17 suites with no regressions. Barrels updated: CnAppRoot exported from src/components/index.js and src/index.js. Stub additions in tests/__mocks__/nextcloud-vue.js (NcContent, NcAppNavigation, NcAppNavigationItem, NcEmptyContent) landed in the prior CnAppNav commit. --- src/components/CnAppRoot/CnAppRoot.vue | 184 ++++++++++++++++++++++++ src/components/CnAppRoot/index.js | 3 + src/components/index.js | 1 + src/index.js | 1 + tests/components/CnAppRoot.spec.js | 188 +++++++++++++++++++++++++ 5 files changed, 377 insertions(+) create mode 100644 src/components/CnAppRoot/CnAppRoot.vue create mode 100644 src/components/CnAppRoot/index.js create mode 100644 tests/components/CnAppRoot.spec.js diff --git a/src/components/CnAppRoot/CnAppRoot.vue b/src/components/CnAppRoot/CnAppRoot.vue new file mode 100644 index 0000000..e9637eb --- /dev/null +++ b/src/components/CnAppRoot/CnAppRoot.vue @@ -0,0 +1,184 @@ + + + + diff --git a/src/components/CnAppRoot/index.js b/src/components/CnAppRoot/index.js new file mode 100644 index 0000000..aa4870b --- /dev/null +++ b/src/components/CnAppRoot/index.js @@ -0,0 +1,3 @@ +import CnAppRoot from './CnAppRoot.vue' +export default CnAppRoot +export { CnAppRoot } diff --git a/src/components/index.js b/src/components/index.js index 5a4ab73..e39886f 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -59,3 +59,4 @@ export { CnPageRenderer } from './CnPageRenderer/index.js' export { CnAppNav } from './CnAppNav/index.js' export { CnAppLoading } from './CnAppLoading/index.js' export { CnDependencyMissing } from './CnDependencyMissing/index.js' +export { CnAppRoot } from './CnAppRoot/index.js' diff --git a/src/index.js b/src/index.js index 0eb622e..6e2b37b 100644 --- a/src/index.js +++ b/src/index.js @@ -64,6 +64,7 @@ export { CnAppNav, CnAppLoading, CnDependencyMissing, + CnAppRoot, registerIcons, } from './components/index.js' diff --git a/tests/components/CnAppRoot.spec.js b/tests/components/CnAppRoot.spec.js new file mode 100644 index 0000000..381d8c6 --- /dev/null +++ b/tests/components/CnAppRoot.spec.js @@ -0,0 +1,188 @@ +/** + * Tests for CnAppRoot. + * + * Covers REQ-JMR-003 (provide/inject + slots) and REQ-JMR-013 (phase + * orchestration: loading → dependency-check → shell) from the + * json-manifest-renderer spec. + */ + +import { mount } from '@vue/test-utils' + +jest.mock('@nextcloud/capabilities', () => ({ + getCapabilities: jest.fn(), +})) +const { getCapabilities } = require('@nextcloud/capabilities') +const { __resetAppStatusCacheForTests } = require('../../src/composables/useAppStatus.js') +const CnAppRoot = require('../../src/components/CnAppRoot/CnAppRoot.vue').default + +const baseManifest = { + version: '1.0.0', + menu: [{ id: 'home', label: 'app.home', route: 'home' }], + pages: [{ id: 'home', route: '/', type: 'index', title: 'app.home' }], + dependencies: [], +} + +function mountRoot({ + manifest = baseManifest, + isLoading = false, + slots = {}, + customComponents = {}, + t = (k) => k, +} = {}) { + return mount(CnAppRoot, { + propsData: { manifest, appId: 'myapp', isLoading, customComponents, translate: t }, + mocks: { + $route: { name: 'home' }, + }, + stubs: { + 'router-view': { template: '
' }, + }, + slots, + }) +} + +describe('CnAppRoot', () => { + beforeEach(() => { + getCapabilities.mockReset() + __resetAppStatusCacheForTests() + }) + + describe('phase orchestration (REQ-JMR-013)', () => { + it('renders the loading phase while isLoading is true', () => { + getCapabilities.mockReturnValue({}) + const wrapper = mountRoot({ isLoading: true }) + expect(wrapper.vm.phase).toBe('loading') + expect(wrapper.find('.cn-app-loading').exists()).toBe(true) + expect(wrapper.find('.router-view-stub').exists()).toBe(false) + }) + + it('renders the dependency-missing phase when a declared dependency is absent', () => { + getCapabilities.mockReturnValue({}) // no openregister key + const wrapper = mountRoot({ + manifest: { ...baseManifest, dependencies: ['openregister'] }, + }) + expect(wrapper.vm.phase).toBe('dependency-missing') + expect(wrapper.find('.cn-dependency-missing').exists()).toBe(true) + expect(wrapper.find('.router-view-stub').exists()).toBe(false) + }) + + it('renders the shell phase when all dependencies are installed', () => { + getCapabilities.mockReturnValue({ openregister: {} }) + const wrapper = mountRoot({ + manifest: { ...baseManifest, dependencies: ['openregister'] }, + }) + expect(wrapper.vm.phase).toBe('shell') + expect(wrapper.find('.router-view-stub').exists()).toBe(true) + }) + + it('renders the shell phase when manifest declares no dependencies', () => { + const wrapper = mountRoot() + expect(wrapper.vm.phase).toBe('shell') + }) + }) + + describe('slot overrides', () => { + it('renders the #loading slot when provided', () => { + const wrapper = mountRoot({ + isLoading: true, + slots: { loading: '
' }, + }) + expect(wrapper.find('.custom-loading').exists()).toBe(true) + expect(wrapper.find('.cn-app-loading').exists()).toBe(false) + }) + + it('renders the #dependency-missing slot when provided', () => { + getCapabilities.mockReturnValue({}) + const wrapper = mountRoot({ + manifest: { ...baseManifest, dependencies: ['openregister'] }, + slots: { 'dependency-missing': '
' }, + }) + expect(wrapper.find('.custom-dep-missing').exists()).toBe(true) + expect(wrapper.find('.cn-dependency-missing').exists()).toBe(false) + }) + + it('renders the #menu slot in the shell phase, replacing CnAppNav', () => { + const wrapper = mountRoot({ + slots: { menu: '
' }, + }) + expect(wrapper.find('.custom-menu').exists()).toBe(true) + // Default CnAppNav (NcAppNavigation stub) does not render + expect(wrapper.find('.stub.NcAppNavigation').exists()).toBe(false) + }) + + it('falls back to CnAppNav when no #menu slot is given', () => { + const wrapper = mountRoot() + // NcAppNavigation stub renders — confirms CnAppNav was used + expect(wrapper.find('.stub.NcAppNavigation').exists()).toBe(true) + }) + + it('renders #header-actions, #sidebar, and #footer slots in the shell phase', () => { + const wrapper = mountRoot({ + slots: { + 'header-actions': '
', + sidebar: '
', + footer: '
', + }, + }) + expect(wrapper.find('.ha').exists()).toBe(true) + expect(wrapper.find('.sb').exists()).toBe(true) + expect(wrapper.find('.ft').exists()).toBe(true) + }) + }) + + describe('provide / inject (REQ-JMR-003)', () => { + // Inspect the provide() return directly. CnAppRoot's provide is a + // function on the component options; calling it with `this` bound + // to the instance gives the object Vue would expose to descendants. + function getProvided(wrapper) { + return wrapper.vm.$options.provide.call(wrapper.vm) + } + + it('provides cnManifest, cnCustomComponents, and cnTranslate to descendants', () => { + const wrapper = mountRoot({ + customComponents: { SettingsPage: { name: 'X', template: '
' } }, + t: (k) => `[t]${k}`, + }) + const provided = getProvided(wrapper) + expect(provided.cnManifest).toBe(wrapper.vm.manifest) + expect(provided.cnManifest.version).toBe('1.0.0') + expect(provided.cnCustomComponents).toEqual({ + SettingsPage: { name: 'X', template: '
' }, + }) + expect(typeof provided.cnTranslate).toBe('function') + expect(provided.cnTranslate('key')).toBe('[t]key') + }) + + it('provides an identity-fn cnTranslate when no t prop is given', () => { + const wrapper = mount(CnAppRoot, { + propsData: { manifest: baseManifest, appId: 'myapp' }, + mocks: { $route: { name: 'home' } }, + stubs: { 'router-view': true }, + }) + const provided = getProvided(wrapper) + expect(typeof provided.cnTranslate).toBe('function') + expect(provided.cnTranslate('key')).toBe('key') + }) + + it('provides an empty registry when no customComponents prop is given', () => { + const wrapper = mount(CnAppRoot, { + propsData: { manifest: baseManifest, appId: 'myapp' }, + mocks: { $route: { name: 'home' } }, + stubs: { 'router-view': true }, + }) + const provided = getProvided(wrapper) + expect(provided.cnCustomComponents).toEqual({}) + }) + }) + + describe('multiple dependencies', () => { + it('treats the manifest as resolved only when every dependency is installed', () => { + getCapabilities.mockReturnValue({ openregister: {} }) // missing opencatalogi + const wrapper = mountRoot({ + manifest: { ...baseManifest, dependencies: ['openregister', 'opencatalogi'] }, + }) + expect(wrapper.vm.phase).toBe('dependency-missing') + expect(wrapper.vm.unresolvedDependencies.map((d) => d.id)).toEqual(['opencatalogi']) + }) + }) +}) From a8a68e1c9e75eb47a60959dbf592b55a01a8ca82 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 23:05:35 +0200 Subject: [PATCH 036/143] feat(manifest): extract validateManifest util + schema fixtures + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements REQ-JMR-008 task 7.1 from the json-manifest-renderer spec. - Extracts the manifest validator from useAppManifest into src/utils/validateManifest.js so it is reusable from both the runtime composable and the schema/fixture tests, and is part of the public API for consumers that want to validate manifests at build time. - Re-exported from src/index.js as `validateManifest`. - useAppManifest now imports from the new util; behaviour unchanged. - tests/fixtures/manifest-valid.json — exercises all four page types (index, detail, dashboard, custom), one-level nested children, permission-gated entries, and a `dependencies: ["openregister"]`. - tests/fixtures/manifest-invalid.json — captures the rejection cases: non-semver version, menu item missing its label, duplicate page id, unknown page type, custom page missing the component field. - tests/schemas/app-manifest.schema.spec.js — 13 cases checking the schema metadata (draft 2020-12, $id matches the GitHub raw URL on main, title, closed type enum, top-level required fields, additionalProperties:false) and that the FE validator passes the valid fixture and rejects the invalid one with the specific errors for each rule. Note on FE-only narrowness: the hand-rolled validator covers the runtime rules (required fields, semver, closed enum, page id uniqueness, custom-page component requirement). The richer schema constraints — additionalProperties:false on menu/page items, format URI on $schema, etc. — are enforced by Ajv in the BE / hydra CI validators consuming the same JSON Schema file (hydra#195). --- src/composables/useAppManifest.js | 93 +------------------- src/index.js | 1 + src/utils/validateManifest.js | 101 ++++++++++++++++++++++ tests/fixtures/manifest-invalid.json | 27 ++++++ tests/fixtures/manifest-valid.json | 88 +++++++++++++++++++ tests/schemas/app-manifest.schema.spec.js | 86 ++++++++++++++++++ 6 files changed, 304 insertions(+), 92 deletions(-) create mode 100644 src/utils/validateManifest.js create mode 100644 tests/fixtures/manifest-invalid.json create mode 100644 tests/fixtures/manifest-valid.json create mode 100644 tests/schemas/app-manifest.schema.spec.js diff --git a/src/composables/useAppManifest.js b/src/composables/useAppManifest.js index fedc9c2..e503c31 100644 --- a/src/composables/useAppManifest.js +++ b/src/composables/useAppManifest.js @@ -1,7 +1,7 @@ import { ref } from 'vue' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' -import schema from '../schemas/app-manifest.schema.json' +import { validateManifest } from '../utils/validateManifest.js' /** * Composable that loads and validates a Conduction app manifest. @@ -114,94 +114,3 @@ function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) } -/** - * Validate a manifest against the manifest JSON Schema. Hand-rolled - * minimal validator covering the rules required by REQ-JMR-001: - * - Top-level `version`, `menu`, `pages` are required. - * - `version` matches the semver pattern. - * - `pages[].type` is in the closed enum. - * - `pages[].id` is unique across the array. - * - Required fields on menu items and pages are present. - * - * The richer schema constraints (additionalProperties, format URI, etc.) - * are enforced by the BE / hydra CI validators that consume the same - * schema file with Ajv. The FE validator is intentionally narrow so that - * a FE-only check failure has a tight, actionable error message. - * - * @param {object} manifest The merged manifest to validate. - * @return {{ valid: boolean, errors: string[] }} - */ -function validateManifest(manifest) { - const errors = [] - - if (!isPlainObject(manifest)) { - return { valid: false, errors: ['manifest must be an object'] } - } - - const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ - - if (typeof manifest.version !== 'string') { - errors.push('/version must be a string') - } else if (!versionPattern.test(manifest.version)) { - errors.push(`/version "${manifest.version}" must match semver pattern`) - } - - if (!Array.isArray(manifest.menu)) { - errors.push('/menu must be an array') - } else { - manifest.menu.forEach((item, index) => { - if (!isPlainObject(item)) { - errors.push(`/menu/${index} must be an object`) - return - } - if (typeof item.id !== 'string') errors.push(`/menu/${index}/id must be a string`) - if (typeof item.label !== 'string') errors.push(`/menu/${index}/label must be a string`) - if (item.children !== undefined && !Array.isArray(item.children)) { - errors.push(`/menu/${index}/children must be an array`) - } - }) - } - - const allowedTypes = schema.$defs.page.properties.type.enum - - if (!Array.isArray(manifest.pages)) { - errors.push('/pages must be an array') - } else { - const seenIds = new Set() - manifest.pages.forEach((page, index) => { - if (!isPlainObject(page)) { - errors.push(`/pages/${index} must be an object`) - return - } - if (typeof page.id !== 'string') { - errors.push(`/pages/${index}/id must be a string`) - } else if (seenIds.has(page.id)) { - errors.push(`/pages/${index}/id "${page.id}" must be unique within pages[]`) - } else { - seenIds.add(page.id) - } - if (typeof page.route !== 'string') errors.push(`/pages/${index}/route must be a string`) - if (typeof page.title !== 'string') errors.push(`/pages/${index}/title must be a string`) - if (typeof page.type !== 'string' || !allowedTypes.includes(page.type)) { - errors.push(`/pages/${index}/type must be one of: ${allowedTypes.join(', ')}`) - } - if (page.type === 'custom' && typeof page.component !== 'string') { - errors.push(`/pages/${index}/component is required when type is "custom"`) - } - }) - } - - if (manifest.dependencies !== undefined) { - if (!Array.isArray(manifest.dependencies)) { - errors.push('/dependencies must be an array of strings') - } else { - manifest.dependencies.forEach((dep, index) => { - if (typeof dep !== 'string') { - errors.push(`/dependencies/${index} must be a string`) - } - }) - } - } - - return { valid: errors.length === 0, errors } -} diff --git a/src/index.js b/src/index.js index 6e2b37b..f0868b4 100644 --- a/src/index.js +++ b/src/index.js @@ -97,4 +97,5 @@ export { registerTranslations } from './l10n/index.js' // Utilities export { buildHeaders, buildQueryString, parseResponseError, networkError, genericError } from './utils/index.js' export { columnsFromSchema, formatValue, filtersFromSchema, fieldsFromSchema } from './utils/index.js' +export { validateManifest } from './utils/validateManifest.js' export { filterWidgetsByVisibility, isWidgetVisible, getCurrentUserId, getCurrentUserGroups, resetVisibilityCache } from './utils/index.js' diff --git a/src/utils/validateManifest.js b/src/utils/validateManifest.js new file mode 100644 index 0000000..2d7c30b --- /dev/null +++ b/src/utils/validateManifest.js @@ -0,0 +1,101 @@ +import schema from '../schemas/app-manifest.schema.json' + +/** + * Validate a manifest object against the manifest JSON Schema. + * + * Hand-rolled minimal validator covering the rules required by + * REQ-JMR-001 of the json-manifest-renderer spec: + * - Top-level `version`, `menu`, `pages` are required. + * - `version` matches the semver pattern. + * - `pages[].type` is in the closed enum. + * - `pages[].id` is unique across the array. + * - Required fields on menu items and pages are present. + * - `dependencies` (when present) is an array of strings. + * - `pages[].component` is required when `type` is "custom". + * + * The richer schema constraints (`additionalProperties: false`, `format` + * URI, etc.) are enforced by the BE / hydra CI validators that consume + * the same schema file with Ajv. The FE validator is intentionally + * narrow so a FE-only failure produces tight, actionable messages. + * + * @param {object} manifest The manifest object to validate. + * @return {{ valid: boolean, errors: string[] }} + */ +export function validateManifest(manifest) { + const errors = [] + + if (!isPlainObject(manifest)) { + return { valid: false, errors: ['manifest must be an object'] } + } + + const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ + + if (typeof manifest.version !== 'string') { + errors.push('/version must be a string') + } else if (!versionPattern.test(manifest.version)) { + errors.push(`/version "${manifest.version}" must match semver pattern`) + } + + if (!Array.isArray(manifest.menu)) { + errors.push('/menu must be an array') + } else { + manifest.menu.forEach((item, index) => { + if (!isPlainObject(item)) { + errors.push(`/menu/${index} must be an object`) + return + } + if (typeof item.id !== 'string') errors.push(`/menu/${index}/id must be a string`) + if (typeof item.label !== 'string') errors.push(`/menu/${index}/label must be a string`) + if (item.children !== undefined && !Array.isArray(item.children)) { + errors.push(`/menu/${index}/children must be an array`) + } + }) + } + + const allowedTypes = schema.$defs.page.properties.type.enum + + if (!Array.isArray(manifest.pages)) { + errors.push('/pages must be an array') + } else { + const seenIds = new Set() + manifest.pages.forEach((page, index) => { + if (!isPlainObject(page)) { + errors.push(`/pages/${index} must be an object`) + return + } + if (typeof page.id !== 'string') { + errors.push(`/pages/${index}/id must be a string`) + } else if (seenIds.has(page.id)) { + errors.push(`/pages/${index}/id "${page.id}" must be unique within pages[]`) + } else { + seenIds.add(page.id) + } + if (typeof page.route !== 'string') errors.push(`/pages/${index}/route must be a string`) + if (typeof page.title !== 'string') errors.push(`/pages/${index}/title must be a string`) + if (typeof page.type !== 'string' || !allowedTypes.includes(page.type)) { + errors.push(`/pages/${index}/type must be one of: ${allowedTypes.join(', ')}`) + } + if (page.type === 'custom' && typeof page.component !== 'string') { + errors.push(`/pages/${index}/component is required when type is "custom"`) + } + }) + } + + if (manifest.dependencies !== undefined) { + if (!Array.isArray(manifest.dependencies)) { + errors.push('/dependencies must be an array of strings') + } else { + manifest.dependencies.forEach((dep, index) => { + if (typeof dep !== 'string') { + errors.push(`/dependencies/${index} must be a string`) + } + }) + } + } + + return { valid: errors.length === 0, errors } +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/tests/fixtures/manifest-invalid.json b/tests/fixtures/manifest-invalid.json new file mode 100644 index 0000000..ceadf17 --- /dev/null +++ b/tests/fixtures/manifest-invalid.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json", + "version": "not-semver", + "menu": [ + { "id": "missing-label" } + ], + "pages": [ + { + "id": "dup", + "route": "/a", + "type": "wizard", + "title": "bad type" + }, + { + "id": "dup", + "route": "/b", + "type": "index", + "title": "duplicate id" + }, + { + "id": "no-component", + "route": "/c", + "type": "custom", + "title": "missing component field" + } + ] +} diff --git a/tests/fixtures/manifest-valid.json b/tests/fixtures/manifest-valid.json new file mode 100644 index 0000000..18956f9 --- /dev/null +++ b/tests/fixtures/manifest-valid.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json", + "version": "1.0.0", + "dependencies": ["openregister"], + "menu": [ + { + "id": "decisions", + "label": "myapp.menu.decisions", + "icon": "icon-checkmark", + "route": "decisions-index", + "order": 10 + }, + { + "id": "configuration", + "label": "myapp.menu.configuration", + "icon": "icon-settings", + "order": 90, + "children": [ + { "id": "users", "label": "myapp.menu.users", "route": "users-index" }, + { "id": "audit", "label": "myapp.menu.audit", "route": "audit-index", "permission": "admin" } + ] + }, + { + "id": "settings", + "label": "myapp.menu.settings", + "icon": "icon-settings", + "route": "settings", + "order": 99 + } + ], + "pages": [ + { + "id": "decisions-index", + "route": "/decisions", + "type": "index", + "title": "myapp.decisions.title", + "config": { + "register": "decisions", + "schema": "decision", + "columns": ["title", "status", "deadline"] + } + }, + { + "id": "decisions-detail", + "route": "/decisions/:id", + "type": "detail", + "title": "myapp.decisions.detail", + "config": { + "register": "decisions", + "schema": "decision" + } + }, + { + "id": "users-index", + "route": "/users", + "type": "index", + "title": "myapp.users.title", + "config": { "register": "users", "schema": "user" } + }, + { + "id": "audit-index", + "route": "/audit", + "type": "index", + "title": "myapp.audit.title", + "config": { "register": "audit", "schema": "audit-event" } + }, + { + "id": "overview", + "route": "/", + "type": "dashboard", + "title": "myapp.overview.title", + "config": { + "widgets": [ + { "id": "kpis", "title": "KPIs", "type": "custom" }, + { "id": "chart", "title": "Trends", "type": "custom" } + ], + "layout": [] + } + }, + { + "id": "settings", + "route": "/settings", + "type": "custom", + "title": "myapp.settings.title", + "component": "SettingsPage" + } + ] +} diff --git a/tests/schemas/app-manifest.schema.spec.js b/tests/schemas/app-manifest.schema.spec.js new file mode 100644 index 0000000..39c6fb1 --- /dev/null +++ b/tests/schemas/app-manifest.schema.spec.js @@ -0,0 +1,86 @@ +/** + * Tests for the app-manifest JSON Schema and the FE validator that + * checks against it. Covers REQ-JMR-001 from the json-manifest-renderer + * spec, exercised against the fixtures in tests/fixtures. + * + * Note: the FE validator is the hand-rolled `validateManifest` (see + * src/utils/validateManifest.js) which covers the rules that matter at + * runtime (required fields, semver, closed page-type enum, page id + * uniqueness, dependencies type, custom-page component requirement). + * The full schema with `additionalProperties:false` and `format` URI + * checks is enforced by Ajv in the BE / hydra CI validators that + * consume the same JSON Schema file (see ConductionNL/hydra#195). + */ + +import schema from '../../src/schemas/app-manifest.schema.json' +import valid from '../fixtures/manifest-valid.json' +import invalid from '../fixtures/manifest-invalid.json' +import { validateManifest } from '../../src/utils/validateManifest.js' + +describe('app-manifest.schema.json (metadata)', () => { + it('declares JSON Schema draft 2020-12', () => { + expect(schema.$schema).toBe('https://json-schema.org/draft/2020-12/schema') + }) + + it('uses the GitHub raw URL on `main` as $id', () => { + expect(schema.$id).toBe( + 'https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json', + ) + }) + + it('has a title and description', () => { + expect(schema.title).toBe('Conduction App Manifest') + expect(typeof schema.description).toBe('string') + }) + + it('has a closed page-type enum of exactly index|detail|dashboard|custom', () => { + expect(schema.$defs.page.properties.type.enum).toEqual(['index', 'detail', 'dashboard', 'custom']) + }) + + it('lists version, menu, pages as top-level required fields', () => { + expect(schema.required).toEqual(expect.arrayContaining(['version', 'menu', 'pages'])) + }) + + it('disallows additional top-level properties', () => { + expect(schema.additionalProperties).toBe(false) + }) +}) + +describe('validateManifest (FE)', () => { + it('passes a valid fixture exercising all four page types and nested children', () => { + const result = validateManifest(valid) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + }) + + it('rejects a non-semver version', () => { + const result = validateManifest(invalid) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.includes('/version') && e.includes('semver'))).toBe(true) + }) + + it('rejects a page with an unknown type', () => { + const result = validateManifest(invalid) + expect(result.errors.some((e) => e.includes('type must be one of'))).toBe(true) + }) + + it('rejects duplicate page ids', () => { + const result = validateManifest(invalid) + expect(result.errors.some((e) => e.includes('unique within pages[]'))).toBe(true) + }) + + it('rejects a custom-type page that is missing the `component` field', () => { + const result = validateManifest(invalid) + expect(result.errors.some((e) => e.includes('component is required when type is "custom"'))).toBe(true) + }) + + it('rejects a menu item missing its label', () => { + const result = validateManifest(invalid) + expect(result.errors.some((e) => e.includes('/menu/0/label'))).toBe(true) + }) + + it('returns a single error when manifest is not an object', () => { + const result = validateManifest('not an object') + expect(result).toEqual({ valid: false, errors: ['manifest must be an object'] }) + }) +}) From 6e0aec9e870d354c1c592563356edc521c9443c5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 27 Apr 2026 23:08:27 +0200 Subject: [PATCH 037/143] docs(manifest): demo manifest, migration guide, CLAUDE.md update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the documentation half of the json-manifest-renderer change: tasks 8 (demo manifest), 9 (migration guide), 6.2 (CLAUDE.md update). - examples/manifest-demo/manifest.json: full reference manifest exercising all four page types, nested menu items, permission gating, an OpenRegister dependency declaration, and the customComponents registry pattern. Validates against the JSON Schema shipped in src/schemas/app-manifest.schema.json. - examples/manifest-demo/README.md: minimal main.ts wiring (Tier 4 — full CnAppRoot shell) showing the canonical bootstrap pattern. - docs/migrating-to-manifest.md: tier-by-tier guide (1 → 4) with before/after code snippets. Covers what each tier adds, when to pick which, and how to keep a custom menu in Tier 4 via the `#menu` slot. Also documents the manifest field semantics, the customComponents registry, the i18n contract (translation keys only, app passes translate), and build-time validation via validateManifest. - CLAUDE.md: * Adds new "Manifest Renderer" subsection under "Layout & Pages" with one-line summaries of CnAppRoot, CnAppNav, CnPageRenderer, CnAppLoading, CnDependencyMissing. * Adds useAppManifest and useAppStatus to "Available Composables". * Adds validateManifest to "Available Utilities". * Notes the new #header / #actions slot overrides on CnIndexPage and CnDetailPage. * Adds a "JSON Manifest Renderer" how-to section with a minimal manifest example, pointing readers at the demo and migration guide for deeper detail. Full suite still 338/338 across 18 suites. --- CLAUDE.md | 42 +++++- docs/migrating-to-manifest.md | 200 +++++++++++++++++++++++++++ examples/manifest-demo/README.md | 61 ++++++++ examples/manifest-demo/manifest.json | 95 +++++++++++++ 4 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 docs/migrating-to-manifest.md create mode 100644 examples/manifest-demo/README.md create mode 100644 examples/manifest-demo/manifest.json diff --git a/CLAUDE.md b/CLAUDE.md index 346fad0..913e537 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,11 +23,18 @@ Consumer apps MUST also call `registerTranslations()` once in `main.js` (alongsi ### Available Components **Layout & Pages** -- `CnIndexPage` — Top-level schema-driven index page (table/cards, pagination, mass actions, dialogs) -- `CnDetailPage` — Generic detail/overview page with stats table and flexible content slots (simpler alternative to CnIndexPage) +- `CnIndexPage` — Top-level schema-driven index page (table/cards, pagination, mass actions, dialogs). Overridable via `#header` and `#actions` slots. +- `CnDetailPage` — Generic detail/overview page with stats table and flexible content slots. Overridable via `#header` and `#actions` slots. - `CnPageHeader` — Page header with icon, title, description - `CnActionsBar` — Action bar with add button, mass actions, view toggle, search +**Manifest Renderer (JSON-driven app shell)** +- `CnAppRoot` — Top-level app wrapper. Orchestrates loading → dependency-check → shell phases. Provides `cnManifest`, `cnCustomComponents`, `cnTranslate` to descendants. Slots: `#loading`, `#dependency-missing`, `#menu`, `#header-actions`, `#sidebar`, `#footer` — each independently overridable. Use this when adopting the full manifest pattern; lower tiers (just `useAppManifest`, or `+ CnPageRenderer`, or `+ CnAppNav`) are also supported. +- `CnAppNav` — Manifest-driven `NcAppNavigation`. Reads `manifest.menu[]`; sorts by `order`; filters by `permission`; one level of `children[]`. Accepts `manifest`, `translate`, `permissions` as props (with inject fallback) for standalone use. +- `CnPageRenderer` — Type dispatcher mounted inside ``. Matches `$route.name === page.id`, dispatches by `page.type` (`index | detail | dashboard | custom`) using `defineAsyncComponent` for tree-shaking. Forwards `page.config` as props; resolves `page.headerComponent` / `page.actionsComponent` against the customComponents registry. +- `CnAppLoading` — Default loading screen (logo slot + NcLoadingIcon + message). Used by CnAppRoot's `#loading` phase. +- `CnDependencyMissing` — Default dependency-missing screen (lists missing apps with install/enable links). Used by CnAppRoot's `#dependency-missing` phase. + **Data Display** - `CnDetailGrid` — Data-driven label-value grid with grid and horizontal layout modes - `CnDataTable` — Sortable data table with selection, loading, empty states @@ -94,6 +101,7 @@ Consumer apps MUST also call `registerTranslations()` once in `main.js` (alongsi - `buildQueryString(params)` — Build URL query string from params object - `parseResponseError(response)` — Extract error message from API response - `networkError()` / `genericError()` — Standard error message helpers +- `validateManifest(manifest)` — Validate an app manifest against the JSON Schema. Returns `{ valid, errors }`. Use at build time or in test fixtures; the same validator runs at runtime inside `useAppManifest`. ### Available Store - `useObjectStore` — Generic Pinia store for OpenRegister objects (CRUD, pagination, search, caching) @@ -105,6 +113,8 @@ Consumer apps MUST also call `registerTranslations()` once in `main.js` (alongsi - `useFileSelection(options)` — File upload/drop handling - `useDashboardView(options)` — Dashboard state: widget defs, layout, NC widget loading, add/remove/persist - `useContextMenu()` — Right-click context menu positioning and state (cursor CSS vars, open/close, action helpers) +- `useAppManifest(appId, bundledManifest, options?)` — Load + validate the app manifest. Returns `{ manifest, isLoading, validationErrors }`. Synchronous bundled load + async backend-merge stub (silent fallback on 4xx / network errors); validates via `validateManifest`. Pass `options.endpoint` or `options.fetcher` to override the backend URL or inject a mock. +- `useAppStatus(appId)` — Check whether a Nextcloud app is installed and enabled via `@nextcloud/capabilities`. Returns `{ installed, enabled, loading }`. Cached per appId for the page lifetime. CnAppRoot calls this once per `manifest.dependencies` entry to drive the dependency-check phase. ### CnIndexPage Dialog Override System @@ -177,6 +187,34 @@ const DEFAULT_LAYOUT = [ ``` +### JSON Manifest Renderer + +Apps can declare their entire shell — routes, navigation, page configuration, dependencies — in a single `src/manifest.json`. The library reads it and renders the app. Adoption is incremental (four tiers from "just `useAppManifest`" to "full `CnAppRoot` shell"); a custom menu component can replace the default `CnAppNav` via the `#menu` slot. + +Minimal manifest: + +```json +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json", + "version": "1.0.0", + "dependencies": ["openregister"], + "menu": [ + { "id": "decisions", "label": "myapp.menu.decisions", "icon": "icon-checkmark", "route": "decisions-index", "order": 10 } + ], + "pages": [ + { "id": "decisions-index", "route": "/decisions", "type": "index", "title": "myapp.decisions.title", + "config": { "register": "decisions", "schema": "decision", "columns": ["title", "status"] } }, + { "id": "decisions-detail", "route": "/decisions/:id", "type": "detail", "title": "myapp.decisions.detail", + "config": { "register": "decisions", "schema": "decision" } }, + { "id": "settings", "route": "/settings", "type": "custom", "title": "myapp.settings.title", "component": "SettingsPage" } + ] +} +``` + +`page.id` is also the vue-router route name; CnPageRenderer matches by `$route.name === page.id`. The `type` enum is closed (`index | detail | dashboard | custom`) — bespoke pages use `type: "custom"` with a registry component. + +See `examples/manifest-demo/manifest.json` for a fuller reference and `docs/migrating-to-manifest.md` for tier-by-tier adoption guidance. + ## Rules for Modifying Components 1. **NEVER break existing prop interfaces** — new props MUST have defaults diff --git a/docs/migrating-to-manifest.md b/docs/migrating-to-manifest.md new file mode 100644 index 0000000..1f75cd6 --- /dev/null +++ b/docs/migrating-to-manifest.md @@ -0,0 +1,200 @@ +# Migrating to the JSON manifest + +`@conduction/nextcloud-vue` ships a JSON-driven manifest renderer. Apps declare their routes, navigation, page content, and widget configuration in a single `src/manifest.json`. The library turns it into a working Nextcloud app shell. + +You don't have to adopt the whole stack. Pick a **tier** that fits your app's current state — each tier is self-contained, and you can move up later without breaking anything. + +| Tier | Add | What changes | +|---|---|---| +| 1 | `useAppManifest` | Just a validated, reactive manifest. No layout / routing changes. | +| 2 | + `CnPageRenderer` | Reuse the type-dispatch logic. App still owns its router config and root layout. | +| 3 | + `CnAppNav` *(or a custom menu)* | Manifest-driven navigation. App still owns its root shell. | +| 4 | + `CnAppRoot` | Full shell. Loading + dependency-check + menu + router-view, all orchestrated from the manifest. | + +--- + +## Tier 1 — `useAppManifest` only + +You get a reactive, validated manifest with the future backend-override merge wired in. Everything else stays in your app. + +```ts +import { useAppManifest } from '@conduction/nextcloud-vue' +import bundledManifest from './manifest.json' + +export default { + setup() { + const { manifest, isLoading, validationErrors } = useAppManifest('myapp', bundledManifest) + return { manifest, isLoading, validationErrors } + }, +} +``` + +`manifest.value` is the bundled value immediately, then deep-merged with any `200` from `/index.php/apps/{appId}/api/manifest`. `404` / network errors fall back silently. Schema validation failures keep the bundled value and surface in `validationErrors`. + +Use `options.endpoint` and `options.fetcher` to override the URL or inject a mock for tests. + +--- + +## Tier 2 — `+ CnPageRenderer` + +Add the type dispatcher. Map your vue-router config to mount `CnPageRenderer` at every route — the renderer reads the manifest and dispatches to `CnIndexPage` / `CnDetailPage` / `CnDashboardPage` / a registry component based on `page.type`. + +```ts +import { CnPageRenderer, useAppManifest } from '@conduction/nextcloud-vue' + +const { manifest } = useAppManifest('myapp', bundledManifest) + +const router = new VueRouter({ + routes: bundledManifest.pages.map((p) => ({ + name: p.id, // page.id IS the vue-router route name + path: p.route, // page.route is the path pattern + component: CnPageRenderer, + props: { manifest: manifest.value, customComponents: { SettingsPage } }, + })), +}) +``` + +CnPageRenderer accepts `manifest`, `customComponents`, and `translate` as props (each falls back to `inject` from a CnAppRoot ancestor when absent), so you can use it standalone. + +--- + +## Tier 3 — `+ CnAppNav` (or your own menu) + +Add manifest-driven navigation. Pass `manifest` and `translate` as props (or rely on inject if you wrap CnPageRenderer + your own provide). + +```vue + + + +``` + +**Custom menu instead?** Skip `CnAppNav` entirely. Either keep your existing menu component, or use `CnAppRoot` (tier 4) and override the `#menu` slot — see below. + +--- + +## Tier 4 — `+ CnAppRoot` + +Full shell: phase orchestration (`loading` → `dependency-check` → `shell`), provide/inject for `cnManifest` / `cnCustomComponents` / `cnTranslate`, default loading and dependency-missing screens. + +```ts +import Vue from 'vue' +import VueRouter from 'vue-router' +import { translate, translatePlural } from '@nextcloud/l10n' +import { CnAppRoot, CnPageRenderer, useAppManifest } from '@conduction/nextcloud-vue' +import bundledManifest from './manifest.json' +import SettingsPage from './views/SettingsPage.vue' + +Vue.use(VueRouter) +Vue.mixin({ methods: { t: translate, n: translatePlural } }) + +const router = new VueRouter({ + routes: bundledManifest.pages.map((p) => ({ + name: p.id, + path: p.route, + component: CnPageRenderer, + })), +}) + +new Vue({ + router, + render: (h) => { + const { manifest, isLoading } = useAppManifest('myapp', bundledManifest) + return h(CnAppRoot, { + props: { + manifest: manifest.value, + appId: 'myapp', + isLoading: isLoading.value, + customComponents: { SettingsPage }, + translate: (key) => translate('myapp', key), + permissions: window.OC?.currentUser?.permissions ?? [], + }, + }) + }, +}).$mount('#content') +``` + +### Keeping a custom menu in Tier 4 + +Override the `#menu` slot: + +```vue + + + +``` + +`CnAppRoot` also exposes `#loading`, `#dependency-missing`, `#header-actions`, `#sidebar`, and `#footer` — each independently overridable. + +--- + +## What goes in `manifest.json` + +See [`examples/manifest-demo/manifest.json`](../examples/manifest-demo/manifest.json) for a full reference manifest exercising all four page types, nested menu items, permission gating, and a dependency declaration. + +Key fields: + +- **`version`** — semver of the manifest content. Bump when meaningful changes land. +- **`dependencies`** — Nextcloud app ids that must be installed and enabled. CnAppRoot's dependency-check phase blocks the shell when any are missing. +- **`menu[]`** — top-level nav entries. `id`, `label` (i18n key), optional `icon`, `route` (vue-router route name = page.id), `order`, `permission`, one level of `children[]`. +- **`pages[]`** — page definitions. `id` (the vue-router route name), `route` (path pattern), `type` (`index | detail | dashboard | custom`), `title` (i18n key), `config` (type-specific), `component` (when `type: "custom"`), optional `headerComponent` / `actionsComponent` slot overrides. + +The closed `type` enum is the main defense against DSL creep. Anything bespoke goes in a `type: "custom"` page that resolves a component name from the registry you pass to `CnAppRoot`. + +## Custom-component registry + +```ts +import SettingsPage from './views/SettingsPage.vue' +import DecisionsHeader from './views/DecisionsHeader.vue' + +const customComponents = { + SettingsPage, // for type: "custom" pages with `"component": "SettingsPage"` + DecisionsHeader, // for `headerComponent: "DecisionsHeader"` slot overrides +} +``` + +Pass it to `CnAppRoot` (Tier 4) or to `CnPageRenderer` (Tier 2/3). The library statically imports nothing app-specific — your registry is the audit point for "what custom code does this app actually have?". + +## i18n + +The manifest stores translation keys only — `decidesk.menu.decisions`, never inline strings. Pass a `translate` function (`(key) => string`) to `CnAppRoot` / `CnAppNav` / `CnPageRenderer`. Typically a closure over `@nextcloud/l10n`'s `translate(appId, key)`. The library never imports `t()` from a specific app. + +This makes mechanical i18n key checking possible in CI — every translatable string in the manifest is a static field of a known shape (see `ConductionNL/hydra#194`). + +## Validating manifests at build time + +```ts +import { validateManifest } from '@conduction/nextcloud-vue' + +const result = validateManifest(myManifest) +if (!result.valid) { + console.error('manifest invalid:', result.errors) + process.exit(1) +} +``` + +The same validator runs at runtime inside `useAppManifest` against any backend-merged result; failures fall back to the bundled manifest with a console.warn. diff --git a/examples/manifest-demo/README.md b/examples/manifest-demo/README.md new file mode 100644 index 0000000..9575b49 --- /dev/null +++ b/examples/manifest-demo/README.md @@ -0,0 +1,61 @@ +# Manifest demo + +A reference `manifest.json` and minimal `main.ts` wiring that exercises every page type and feature of the JSON-driven manifest renderer. + +## What's in the manifest + +- All four page types: `index`, `detail`, `dashboard`, `custom` +- A nested menu (one level of `children[]`) +- A permission-gated nav item (`audit`) +- A declared dependency (`openregister`) that triggers the dependency-check phase if not installed +- A `headerComponent`/`actionsComponent` slot override pattern (omitted from the demo for clarity, but supported per REQ-JMR-005) + +## Minimal `main.ts` (Tier 4 — full shell) + +```ts +import Vue from 'vue' +import VueRouter from 'vue-router' +import { translate, translatePlural } from '@nextcloud/l10n' +import { CnAppRoot, useAppManifest } from '@conduction/nextcloud-vue' +import bundledManifest from './manifest.json' +import SettingsPage from './views/SettingsPage.vue' + +Vue.use(VueRouter) +Vue.mixin({ methods: { t: translate, n: translatePlural } }) + +// vue-router config is generated from the manifest pages: each page.id +// becomes a route name, page.route becomes the path, and CnPageRenderer +// is the component for every route. +import { CnPageRenderer } from '@conduction/nextcloud-vue' + +const router = new VueRouter({ + routes: bundledManifest.pages.map((p) => ({ + name: p.id, + path: p.route, + component: CnPageRenderer, + })), +}) + +new Vue({ + router, + render: (h) => { + const { manifest, isLoading } = useAppManifest('myapp', bundledManifest) + return h(CnAppRoot, { + props: { + manifest: manifest.value, + appId: 'myapp', + isLoading: isLoading.value, + customComponents: { SettingsPage }, + translate: (key) => translate('myapp', key), + permissions: window.OC?.currentUser?.permissions ?? [], + }, + }) + }, +}).$mount('#content') +``` + +The `customComponents` registry is the bailout for `type: "custom"` pages and for slot overrides (`headerComponent` / `actionsComponent`). Add any app-specific Vue component there. + +## Lower-tier adoption + +You don't have to use the full shell. See `docs/migrating-to-manifest.md` for tiers 1–3 (use `useAppManifest` alone, or `+ CnPageRenderer`, or `+ CnAppNav`) — each tier is self-contained. diff --git a/examples/manifest-demo/manifest.json b/examples/manifest-demo/manifest.json new file mode 100644 index 0000000..59086ec --- /dev/null +++ b/examples/manifest-demo/manifest.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json", + "version": "1.0.0", + "dependencies": ["openregister"], + "menu": [ + { + "id": "overview", + "label": "demo.menu.overview", + "icon": "icon-home", + "route": "overview", + "order": 10 + }, + { + "id": "decisions", + "label": "demo.menu.decisions", + "icon": "icon-checkmark", + "route": "decisions-index", + "order": 20 + }, + { + "id": "configuration", + "label": "demo.menu.configuration", + "icon": "icon-settings", + "order": 90, + "children": [ + { "id": "users", "label": "demo.menu.users", "route": "users-index" }, + { "id": "audit", "label": "demo.menu.audit", "route": "audit-index", "permission": "admin" } + ] + }, + { + "id": "settings", + "label": "demo.menu.settings", + "icon": "icon-settings", + "route": "settings", + "order": 99 + } + ], + "pages": [ + { + "id": "overview", + "route": "/", + "type": "dashboard", + "title": "demo.overview.title", + "config": { + "widgets": [ + { "id": "kpis", "title": "demo.overview.kpis", "type": "custom" }, + { "id": "trend", "title": "demo.overview.trend", "type": "custom" } + ], + "layout": [ + { "id": 1, "widgetId": "kpis", "gridX": 0, "gridY": 0, "gridWidth": 12, "gridHeight": 2 }, + { "id": 2, "widgetId": "trend", "gridX": 0, "gridY": 2, "gridWidth": 12, "gridHeight": 4 } + ] + } + }, + { + "id": "decisions-index", + "route": "/decisions", + "type": "index", + "title": "demo.decisions.list", + "config": { + "register": "decisions", + "schema": "decision", + "columns": ["title", "status", "deadline"] + } + }, + { + "id": "decisions-detail", + "route": "/decisions/:id", + "type": "detail", + "title": "demo.decisions.detail", + "config": { "register": "decisions", "schema": "decision" } + }, + { + "id": "users-index", + "route": "/users", + "type": "index", + "title": "demo.users.title", + "config": { "register": "users", "schema": "user" } + }, + { + "id": "audit-index", + "route": "/audit", + "type": "index", + "title": "demo.audit.title", + "config": { "register": "audit", "schema": "audit-event" } + }, + { + "id": "settings", + "route": "/settings", + "type": "custom", + "title": "demo.settings.title", + "component": "SettingsPage" + } + ] +} From fb9e9d339a02ed848554124290597452aced5c81 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 28 Apr 2026 07:52:14 +0200 Subject: [PATCH 038/143] feat(manifest): generic slot-override map for CnPageRenderer Extends the json-manifest-renderer so any scoped slot a Cn*Page exposes can be overridden from the manifest, not just `#header` and `#actions`. Decidesk's existing list / detail views rely on `#create-dialog`, `#form-fields`, `#row-actions`, etc.; the previous two-named-slot design forced apps to wrap the whole view as `type: "custom"` to preserve those slots. Schema: `pages[].slots: { "": "" }` maps any slot name to a component in the customComponents registry. `headerComponent` / `actionsComponent` become sugar for `slots.header` / `slots.actions` and are merged into the same map by the renderer. CnPageRenderer: - New `resolvedSlotEntries` computed returns an array of `{ name, component }` pairs combining `page.slots`, `headerComponent`, and `actionsComponent`. - Template uses `