diff --git a/.coverage-baseline b/.coverage-baseline index 90737ba31..265fbfb0d 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -49.47 +80.00 diff --git a/.github/sig-floor.json b/.github/sig-floor.json new file mode 100644 index 000000000..da8686eec --- /dev/null +++ b/.github/sig-floor.json @@ -0,0 +1,40 @@ +{ + "about": "SIG 4-star checks this app meets and must keep meeting (gate 123, sig-floor). Generated by check_sig_floor.py write; raised by the workflow, never lowered by hand.", + "schema": 1, + "profile": "four-star", + "tools": { + "lizard": "1.24.3" + }, + "unit_reader": 2, + "measured_at": "9080e1fa1847f34da7d39925e3f7422eb8afbd7b", + "checks": [ + "javascript duplication", + "javascript unit_complexity >10", + "javascript unit_complexity >25", + "javascript unit_complexity >5", + "javascript unit_interfacing >2", + "javascript unit_interfacing >4", + "javascript unit_interfacing >6", + "javascript unit_size >15", + "javascript unit_size >30", + "javascript unit_size >60", + "php module_coupling >10", + "php module_coupling >20", + "php module_coupling >50", + "php unit_complexity >10", + "php unit_complexity >25", + "php unit_interfacing >4", + "php unit_interfacing >6", + "php unit_size >60", + "vue duplication", + "vue unit_complexity >10", + "vue unit_complexity >25", + "vue unit_complexity >5", + "vue unit_interfacing >2", + "vue unit_interfacing >4", + "vue unit_interfacing >6", + "vue unit_size >15", + "vue unit_size >30", + "vue unit_size >60" + ] +} diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml index 1dca5b78c..d6723440a 100644 --- a/.github/workflows/branch-policy.yml +++ b/.github/workflows/branch-policy.yml @@ -4,6 +4,9 @@ on: pull_request: branches: [main, beta, development] +permissions: + contents: read + jobs: check-source-branch: name: Branch Policy Check diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index a26a7dfce..114569370 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -154,29 +154,9 @@ jobs: with: app-name: launchpad php-version: "8.3" - # Pinned, because the shared workflow's DEFAULT is '["stable31", "stable32"]' - # and stable31 CANNOT WORK here. `additional-apps` below installs - # openregister, which declares min-version="32" (its - # lib/ContextChat/ContentProvider.php implements - # OCP\ContextChat\IContentProvider, absent from core before NC32). On NC31 - # `occ app:enable openregister` fails with - # App "Open Register" cannot be installed because it is not compatible - # with this version of the server. - # and the run continues anyway, because that failure is only a ::warning::. - # Every /apps/openregister/... call then returns Nextcloud's HTML 404 page. - # - # Order matters as much as membership: newman, playwright and - # journeydoc-capture all check out `fromJSON(nextcloud-test-refs)[0]` as - # their single server, so with the inherited default those three jobs ran - # entirely on the version openregister cannot load. - # - # THE LIST IS THE WHOLE DECLARED RANGE. This comment previously said - # "launchpad's own appinfo/info.xml floor stays at 29 … the NC32 constraint - # here is a property of the CI fixture, not of launchpad's code" — that is - # no longer true of the file it describes. info.xml on this branch declares - # , so 32 is the app's own - # floor, not a fixture artefact, and 32, 33 and 34 each get a leg. - nextcloud-test-refs: '["stable34", "stable32", "stable33"]' + # nextcloud-test-refs is deliberately not set: the shared quality.yml + # derives the Nextcloud matrix from appinfo/info.xml (min-version 32 to + # max-version 35), so the tested range follows the declared range. enable-psalm: true enable-phpstan: true enable-phpmetrics: true @@ -377,6 +357,15 @@ jobs: # deliberate). Floating both halves keeps caller and callee in step, # which is the only configuration in which a pinned path cannot expire. enable-hydra-gates: true + # Gate 122 (SIG unit ratchet) blocks: a unit this change adds or makes + # worse must stay at NLOC 60, McCabe 10 and at most 4 parameters, and a + # new duplicated fragment of 6+ lines fails. Units the change does not + # touch, or touches without making worse, are inherited and do not count. + hydra-gates-sig-blocking: true + # Maintainability floor: no rating in .maintainability-floor.json may drop + # more than 0.01 against the merge base (pull request) or below the + # committed floor (push). The floor is raised after improving merges. + enable-maintainability-floor: true # ── Cost controls (see ConductionNL/.github#596, #599) ─────────────── # Run PHPUnit and Playwright only when the diff could change their # verdict. Fails safe TOWARDS running: an unreadable diff, a force-push, diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 1f7ccf072..b3f847639 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -12,8 +12,14 @@ on: pull_request: branches: [development] +permissions: + contents: read + jobs: deploy: + permissions: + contents: write + packages: write uses: ConductionNL/.github/.github/workflows/documentation.yml@main # A reusable workflow receives NO secrets by default. Without this block the # callee's publish step finds CF_API_TOKEN empty, skips itself on its own diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index a122c1601..30c2dbed1 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -10,8 +10,14 @@ on: type: boolean default: true +permissions: + contents: read + jobs: triage: + permissions: + issues: write + contents: read uses: ConductionNL/.github/.github/workflows/issue-triage.yml@main with: app-name: launchpad diff --git a/.github/workflows/openspec-sync.yml b/.github/workflows/openspec-sync.yml index cd5de2c3a..ef24c8790 100644 --- a/.github/workflows/openspec-sync.yml +++ b/.github/workflows/openspec-sync.yml @@ -42,8 +42,14 @@ on: paths: ['openspec/**'] workflow_dispatch: +permissions: + contents: read + jobs: sync: + permissions: + issues: write + contents: read uses: ConductionNL/.github/.github/workflows/openspec-sync.yml@main with: app-name: launchpad diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a4950612..02ffc4b1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,9 +27,15 @@ concurrency: group: release-${{ github.ref_name }} cancel-in-progress: false +permissions: + contents: read + jobs: unstable: if: github.ref == 'refs/heads/development' + permissions: + contents: write + pull-requests: write uses: ConductionNL/.github/.github/workflows/release.yml@main with: release-type: unstable @@ -38,6 +44,9 @@ jobs: beta: if: github.ref == 'refs/heads/beta' + permissions: + contents: write + pull-requests: write uses: ConductionNL/.github/.github/workflows/release.yml@main with: release-type: beta @@ -46,6 +55,9 @@ jobs: stable: if: github.ref == 'refs/heads/main' + permissions: + contents: write + pull-requests: write uses: ConductionNL/.github/.github/workflows/release.yml@main with: release-type: stable diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..974fea8be --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 + +# OpenSSF Scorecard: rates this repository's supply-chain practices and +# publishes the result to the public Scorecard API and the Security tab. +name: Scorecard supply-chain security + +on: + branch_protection_rule: + schedule: + - cron: '30 4 * * 1' + push: + branches: [main] + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Upload the results to code scanning. + security-events: write + # Publish the results and get a badge (OIDC token for the Scorecard API). + id-token: write + contents: read + actions: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/sync-to-beta.yml b/.github/workflows/sync-to-beta.yml index 76a442693..62fd631be 100644 --- a/.github/workflows/sync-to-beta.yml +++ b/.github/workflows/sync-to-beta.yml @@ -5,6 +5,12 @@ on: branches: - development +permissions: + contents: read + jobs: sync: + permissions: + contents: write + pull-requests: write uses: ConductionNL/.github/.github/workflows/sync-to-beta.yml@main diff --git a/.gitignore b/.gitignore index 5f731da4b..84e115f72 100644 --- a/.gitignore +++ b/.gitignore @@ -62,8 +62,7 @@ Thumbs.db /custom_apps/ /config/ -# SBOM is published as release asset (see SECURITY.md), not stored in repo -sbom.cdx.json +# Intermediate SBOM files. The merged sbom.cdx.json at the root is committed. bom-php.cdx.json bom-npm.cdx.json diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..c519518f8 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,55 @@ +# gitleaks configuration: the default rules, plus allowlists for findings +# that are not secrets. Each allowlist names the exact files and only the +# rules that fire there, so anything else in those files is still reported. + +[extend] +useDefault = true + +[[allowlists]] +description = "Documentation examples: the default admin:admin login of the local development and demo containers." +targetRules = ["curl-auth-user"] +paths = [ + '''(^|/)docs/Installation/demo\-environment\.md$''', +] + +[[allowlists]] +description = "Spec scenario: an example public-share token in a Given/When/Then step, not a credential." +targetRules = ["generic-api-key"] +paths = [ + '''(^|/)openspec/changes/archive/2026\-06\-14\-dashboard\-public\-share/specs/dashboard\-public\-share/spec\.md$''', +] + +[[allowlists]] +description = "Smoke-test notes: curl logins of the throwaway local test users (admin, user, alice)." +targetRules = ["curl-auth-user"] +paths = [ + '''(^|/)openspec/changes/archive/2026\-06\-14\-role\-based\-content/smoke\.md$''', +] + +[[allowlists]] +description = "Spec example: a truncated sample JWT in an SDK usage snippet, not a credential." +targetRules = ["generic-api-key"] +paths = [ + '''(^|/)openspec/changes/embedded\-analytics/specs/sdk\-and\-rendering\.md$''', +] + +[[allowlists]] +description = "Design note: a curl example with a user:app-password placeholder." +targetRules = ["curl-auth-user"] +paths = [ + '''(^|/)openspec/changes/governance\-rest\-api\-reference/design\.md$''', +] + +[[allowlists]] +description = "Spec scenario: an example public-share token in a Given/When/Then step, not a credential." +targetRules = ["generic-api-key"] +paths = [ + '''(^|/)openspec/specs/dashboard\-public\-share/spec\.md$''', +] + +[[allowlists]] +description = "E2E seed script: curl login built from the ADMIN_USER/ADMIN_PASSWORD environment variables, not a literal." +targetRules = ["curl-auth-user"] +paths = [ + '''(^|/)tests/e2e/seed\.sh$''', +] diff --git a/.maintainability-floor.json b/.maintainability-floor.json new file mode 100644 index 000000000..964e0f5e0 --- /dev/null +++ b/.maintainability-floor.json @@ -0,0 +1,21 @@ +{ + "measured_at": "2026-11-11T15:41:23Z", + "commit": "4d835a4387a923ae69cd19c49c13c80257b82aa6", + "tool_version": "estimator sha256:391d6ca5ea39; lizard 1.24.3; jscpd 4.0.5; profile five-star", + "tolerance": 0.01, + "overall": 4.21, + "overall_range": [ + 4.21, + 4.32 + ], + "properties": { + "volume": null, + "duplication": 4.54, + "unit_size": 3.4, + "unit_complexity": 4.02, + "unit_interfacing": 3.14, + "module_coupling": 4.65, + "component_independence": 5.5, + "component_entanglement": 5.5 + } +} diff --git a/.procedure-code-baseline.json b/.procedure-code-baseline.json new file mode 100644 index 000000000..57e568c36 --- /dev/null +++ b/.procedure-code-baseline.json @@ -0,0 +1 @@ +{"count": 0} diff --git a/README.md b/README.md index d4911e6e3..18f800d51 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ LaunchPad supercharges the Nextcloud dashboard. Create multiple personalized wor 📚 **[Step-by-step tutorials](https://launchpad.conduction.nl/docs/category/tutorials)** — user + admin walkthroughs with screenshots, kept in sync with the live UI via the [journeydoc](https://github.com/ConductionNL/hydra/blob/development/openspec/architecture/adr-030-journeydoc-pattern.md) capture spec. +## Test coverage + + +Measured test coverage: 81.0% of statements (PHP 81.0% of 21,952 statements), at commit `b3fd2179` on 2026-11-11. The JavaScript frontend is not in this figure, because it has no coverage measurement yet. The figures come from [`coverage-summary.json`](coverage-summary.json); refresh them with `composer coverage:publish`. + + ## Screenshots diff --git a/SECURITY.md b/SECURITY.md index 4657ac8a5..34a631ad4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -60,7 +60,7 @@ curl -sL https://github.com/ConductionNL/launchpad/releases/download/v1.0.0/sbom ### Update cadence -A new SBOM is generated and attached on every release tag. We do not commit SBOMs into the repository tree — they are published exclusively as release assets to keep main-branch history clean and to guarantee every SBOM corresponds to an immutable release artifact. +A new SBOM is generated and attached on every release tag. The `sbom.cdx.json` at the repository root is a snapshot of the dependencies on the `development` branch. For an SBOM that matches an immutable release, use the release assets above. ### Format diff --git a/appinfo/info.xml b/appinfo/info.xml index 933756679..80e091808 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -52,7 +52,7 @@ Ideaal voor organisaties die consistente, samengestelde dashboards willen voor h Vrij en open source onder de EUPL-1.2-licentie. ]]> - 1.1.6-beta.20261002211709 + 1.1.7-unstable.20261005190000EUPL-1.2ConductionLaunchPad @@ -80,7 +80,7 @@ Vrij en open source onder de EUPL-1.2-licentie. dependency here. --> - + OCA\LaunchPad\BackgroundJob\HealthPingRefreshJob + + OCA\LaunchPad\BackgroundJob\ProfileFieldsSyncJob @@ -162,6 +164,7 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\LaunchPad\Command\DemoShowcasesInstallCommand OCA\LaunchPad\Command\DemoShowcasesListCommand + OCA\LaunchPad\Command\TemplateInstallCommand OCA\LaunchPad\Command\SetupCommand @@ -172,6 +175,15 @@ Vrij en open source onder de EUPL-1.2-licentie. + + + OCA\LaunchPad\Activity\Settings\DashboardSharedSetting + OCA\LaunchPad\Activity\Settings\DashboardPublishedSetting + OCA\LaunchPad\Activity\Settings\DashboardUpdatedSetting + OCA\LaunchPad\Activity\Settings\DashboardAcknowledgedSetting + \n" + . 'Measured test coverage: ' . $fmt($total['statements']['pct']) . ' of statements (' . implode(', ', $parts) . '), ' + . 'at commit `' . $commit . '` on ' . $summary['measuredAt'] . '. ' + . $jsNote + . "The figures come from [`coverage-summary.json`](coverage-summary.json); refresh them with `composer coverage:publish`.\n" + . ''; + +if (is_file($readme) === true) { + $text = (string) file_get_contents($readme); + $pattern = '/.*?/s'; + if (preg_match($pattern, $text) === 1) { + $text = preg_replace($pattern, $block, $text); + file_put_contents($readme, $text); + } else { + fwrite( + STDERR, + "coverage-publish: no coverage markers in README.md; add where the line belongs\n" + ); + } +} + +echo 'Coverage ' . $fmt($total['statements']['pct']) . ' (' . implode(', ', $parts) . ")\n"; +exit(0); diff --git a/sigrid.yaml b/sigrid.yaml new file mode 100644 index 000000000..b1ae2fb42 --- /dev/null +++ b/sigrid.yaml @@ -0,0 +1,76 @@ +# Sigrid analysis scope for Launchpad. +# +# Every key below is documented at +# https://docs.sigrid-says.com/reference/analysis-scope-configuration.html +# Sigrid's default excludes (node_modules, vendor, build output, docs, migrations, +# mocks and stubs) still apply on top of this file. + +# Pin the maintainability model, so a yearly recalibration never changes the +# rating without a deliberate change to this file. +model: "2026" + +# Every top-level directory is a component (lib/, src/, appinfo/, templates/ ...), +# as SIG's guidance (section 2.2) defines top-level components. See openregister +# ADR-014. +component_base_dirs: [""] + +# Test-code patterns can only be set per language, and listing languages switches +# off automatic technology detection. The list therefore names every technology +# in the repo, so nothing that automatic detection would find is dropped. +languages: + - name: php + production: + test: + include: + - ".*/tests/.*[.]php" + - name: javascript + production: + test: + include: + - ".*/tests/.*" + - ".*/__tests__/.*" + - ".*[.](spec|test)[.](js|mjs|cjs|ts|tsx)" + - name: typescript + production: + test: + include: + - ".*/tests/.*" + - ".*/__tests__/.*" + - ".*[.](spec|test)[.](js|mjs|cjs|ts|tsx)" + - name: vue + production: + test: + include: + - ".*/tests/.*[.]vue" + - name: python + production: + test: + include: + - ".*/tests?/.*[.]py" + - ".*/test_[^/]*[.]py" + - ".*/conftest[.]py" + - name: script + production: + test: + include: + - ".*/tests/.*[.]sh" + +# Only generated files and developer tooling are excluded. +exclude: + # Translation catalogues. l10n/.js is generated from l10n/.json + # (an OC.L10N.register() call around the same key/value pairs); no logic. + - ".*/l10n/[^/]*[.]js" + # Developer tooling: translation, quality, release and test-runner scripts. + # Not part of the shipped app. + - ".*/scripts/.*" + - ".*/tools/.*" + # OpenSpec change proposals: design notes and spike code for changes that are + # not built yet. The shipped code lives in lib/ and src/. + - ".*/openspec/changes/.*" + # Developer tooling for Claude Code under .claude/: skills, agents and their + # configuration, partly copied from upstream. Not part of the shipped app. + - ".*/[.]claude/.*" + # Root-level tool configuration (ESLint flat config, PHPStan bootstrap, PHP-CS-Fixer). + - ".*/eslint[.]config[.]m?js" + - ".*/phpstan-bootstrap[.]php" + - ".*/[.]php-cs-fixer[.]dist[.]php" diff --git a/src/App.vue b/src/App.vue index b3e55e9bf..203363ad4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -14,18 +14,95 @@ diff --git a/src/__tests__/App.navigationPanel.spec.js b/src/__tests__/App.navigationPanel.spec.js new file mode 100644 index 000000000..d967d4f01 --- /dev/null +++ b/src/__tests__/App.navigationPanel.spec.js @@ -0,0 +1,225 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The start page without the navigation panel (runtime-shell REQ-SHELL-009). + * + * Off, the panel renders; on, it is not rendered on the dashboard view and + * still is on every other page. The stand-in `CnAppRoot` keeps the real + * slot semantics: Vue renders a slot's default content when the override + * holds no real node, which is exactly what the override must not rely on. + * + * The route-no-loss rule: every entry the panel would have shown has a + * destination in the start-page menu, and every router destination is a + * page the app declares. + */ + +import { mount } from '@vue/test-utils' +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@conduction/nextcloud-vue', () => ({ + CnAppRoot: { + name: 'CnAppRoot', + template: + '
', + }, + // The user settings dialog's library sections (src/views/settings/UserSettings.vue). + CnCredentials: { name: 'CnCredentials', template: '
' }, + CnNotificationPreferences: { name: 'CnNotificationPreferences', template: '
' }, +})) +vi.mock('../customComponents.js', () => ({ default: {} })) +vi.mock('../services/iconCatalogue.js', () => ({ ICON_CATALOGUE: [] })) + +import App, { DASHBOARD_ROUTES, panelEntriesFor } from '../App.vue' +import { + isExternal, + resolveMenuItem, +} from '../components/Workspace/StartPageMenu.vue' +import bundledStub from '../manifest.json' +import { routesFromManifest } from '../utils/manifestRoutes.js' +import { applyManifestFragments } from '../utils/mergeManifestFragments.js' + +/** + * The manifest main.js builds: the bundled stub with every fragment + * merged, read from disk because `require.context` is webpack-only. + * + * @return {object} The merged manifest. + */ +function mergedManifest() { + const dir = path.resolve(__dirname, '../manifest.d') + const fragments = fs + .readdirSync(dir) + .filter((name) => name.endsWith('.json')) + .sort() + .map((name) => JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))) + return applyManifestFragments(bundledStub, fragments) +} + +function mountApp({ option, routeName, permissions = ['user'] }) { + return mount(App, { + props: { + manifest: mergedManifest(), + registry: {}, + pageTypes: {}, + permissions, + }, + global: { + provide: { startPageWithoutNavigation: option }, + mocks: { $route: { name: routeName } }, + }, + }) +} + +describe('App without the navigation panel (REQ-SHELL-009)', () => { + it('renders the panel when the option is off', () => { + const wrapper = mountApp({ option: false, routeName: 'Workspace' }) + expect(wrapper.find('[data-testid="cn-nav"]').exists()).toBe(true) + // Off is today's shell exactly: the root receives no `menu` slot, so + // the library renders its own panel as it did before this option. + expect( + wrapper.findComponent({ name: 'CnAppRoot' }).vm.$slots.menu, + ).toBeUndefined() + expect( + wrapper.find('[data-testid="launchpad-rail-suppressed"]').exists(), + ).toBe(false) + }) + + it('renders the panel when the option is missing from the initial state', () => { + const wrapper = mount(App, { + props: { manifest: mergedManifest(), registry: {}, pageTypes: {} }, + global: { mocks: { $route: { name: 'Workspace' } } }, + }) + expect(wrapper.find('[data-testid="cn-nav"]').exists()).toBe(true) + }) + + it.each(DASHBOARD_ROUTES)( + 'does not render the panel on the %s route when the option is on', + (routeName) => { + const wrapper = mountApp({ option: true, routeName }) + expect(wrapper.find('[data-testid="cn-nav"]').exists()).toBe(false) + expect(wrapper.find('nav').exists()).toBe(false) + const placeholder = wrapper.find( + '[data-testid="launchpad-rail-suppressed"]', + ) + expect(placeholder.exists()).toBe(true) + expect(placeholder.attributes('hidden')).toBeDefined() + }, + ) + + it.each([ + 'Store', + 'Reports', + 'Flows', + 'admin-settings', + 'admin-templates-index', + ])('keeps the panel on the %s page when the option is on', (routeName) => { + const wrapper = mountApp({ option: true, routeName }) + expect(wrapper.find('[data-testid="cn-nav"]').exists()).toBe(true) + }) + + it('is what the root receives', () => { + // The template binding is the wiring; a computed nobody binds is a + // guard with no call site. + const render = App.render?.toString() ?? '' + expect(render).toContain('railSuppressed') + expect(App.provide.call({ chromeState: {} })).toHaveProperty( + 'launchpadChrome', + ) + }) +}) + +describe('Route no loss: every panel destination stays reachable', () => { + const manifest = mergedManifest() + const routeNames = new Set( + routesFromManifest(manifest, {}) + .map((r) => r.name) + .filter(Boolean), + ) + + /** + * What CnAppNav renders from this manifest for a permission set: every + * entry in a panel section whose permission the account holds. The + * panel's own filter, restated here so this test does not pass by + * agreeing with `panelEntriesFor` on a wrong answer. + * + * @param {string[]} permissions The account's permissions. + * @return {object[]} The entries. + */ + function panelWouldShow(permissions) { + return manifest.menu.filter( + (item) => + ['main', 'footer', 'settings'].includes(item.section ?? 'main') + && (!item.permission || permissions.includes(item.permission)), + ) + } + + it.each([ + ['a member', ['user']], + ['an administrator', ['user', 'admin']], + ])('offers %s every entry the panel would have shown', (_who, permissions) => { + const shown = panelWouldShow(permissions) + expect(shown.length).toBeGreaterThan(0) + const offered = panelEntriesFor(manifest, permissions) + expect(offered.map((e) => e.id).sort()).toEqual( + shown.map((e) => e.id).sort(), + ) + for (const entry of offered) { + const item = resolveMenuItem(entry, () => {}) + expect(item, `${entry.id} has no destination`).not.toBeNull() + if (item.to) { + expect( + routeNames.has(item.to.name), + `${entry.id} -> ${item.to.name}`, + ).toBe(true) + } else { + expect(Boolean(item.href || item.onClick)).toBe(true) + } + } + }) + + it('hides the admin entries from a member, as the panel does', () => { + const ids = panelEntriesFor(manifest, ['user']).map((e) => e.id) + expect(ids).not.toContain('admin-settings') + expect(ids).not.toContain('admin-templates') + expect(ids).toContain('Documentation') + expect(ids).toContain('StoreMenu') + expect(ids).toContain('ReportsMenu') + expect(ids).toContain('FeaturesRoadmapMenu') + }) + + it('names the three footer destinations, with Reports under Advanced', () => { + const entries = panelEntriesFor(manifest, ['user']) + const footer = entries + .filter((e) => e.section === 'footer') + .map((e) => e.label) + expect(footer).toEqual([ + 'Documentation', + 'Store', + 'Features & roadmap', + ]) + expect(entries.find((e) => e.id === 'ReportsMenu').section).toBe('settings') + }) + + it('opens a scheme-prefixed link in a new tab and an app path in the same one', () => { + expect(isExternal('https://launchpad.conduction.nl')).toBe(true) + expect(isExternal('/index.php/settings/admin/launchpad')).toBe(false) + expect( + resolveMenuItem({ id: 'x', label: 'X', href: 'https://a.b' }, null) + .external, + ).toBe(true) + }) + + it('resolves an action entry to a button only when the opener exists', () => { + const entry = { + id: 'me', + label: 'Me', + action: 'user-settings', + route: 'ignored', + } + expect(resolveMenuItem(entry, null)).toBeNull() + const open = () => {} + expect(resolveMenuItem(entry, open)).toMatchObject({ onClick: open }) + }) +}) diff --git a/src/__tests__/App.supportNote.spec.js b/src/__tests__/App.supportNote.spec.js new file mode 100644 index 000000000..31c6eea01 --- /dev/null +++ b/src/__tests__/App.supportNote.spec.js @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The first-open support note is mounted for administrators only + * (runtime-shell REQ-SHELL-008): `App.vue` hands `CnAppRoot` `false` for + * every other account, which is the library's documented opt-out. + */ + +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@conduction/nextcloud-vue', () => ({ + CnAppRoot: { name: 'CnAppRoot', template: '
' }, +})) +vi.mock('../customComponents.js', () => ({ default: {} })) +vi.mock('../services/iconCatalogue.js', () => ({ ICON_CATALOGUE: [] })) + +import App from '../App.vue' + +describe('App support note', () => { + it('is off for a member', () => { + expect( + App.computed.supportNoteForThisAccount.call({ permissions: ['user'] }), + ).toBe(false) + }) + + it('is off when the permissions are missing', () => { + expect( + App.computed.supportNoteForThisAccount.call({ permissions: undefined }), + ).toBe(false) + }) + + it('stays on for an administrator', () => { + expect( + App.computed.supportNoteForThisAccount.call({ + permissions: ['user', 'admin'], + }), + ).toBe(true) + }) + + it('is what the root receives', () => { + // The template binding is the wiring; a computed nobody binds is a + // guard with no call site. + expect(App.render?.toString() ?? '').toContain('supportNoteForThisAccount') + }) +}) diff --git a/src/__tests__/App.translate.spec.js b/src/__tests__/App.translate.spec.js new file mode 100644 index 000000000..3773c2af0 --- /dev/null +++ b/src/__tests__/App.translate.spec.js @@ -0,0 +1,49 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The manifest's strings (menu labels, page titles and descriptions) are + * translated in LaunchPad's own catalogue. `CnAppRoot` defaults its + * `translate` prop to the identity, so without the binding every one of them + * stayed English. + */ + +import { describe, expect, it, vi } from 'vitest' + +const translate = vi.fn((app, text) => `${app}:${text}`) + +vi.mock('@nextcloud/l10n', async (importOriginal) => ({ + ...(await importOriginal()), + translate: (...args) => translate(...args), +})) +vi.mock('@conduction/nextcloud-vue', () => ({ + CnAppRoot: { name: 'CnAppRoot', template: '
' }, +})) +vi.mock('../customComponents.js', () => ({ default: {} })) +vi.mock('../services/iconCatalogue.js', () => ({ ICON_CATALOGUE: [] })) + +import App from '../App.vue' + +describe('App manifest translator', () => { + it('looks a manifest string up in the launchpad catalogue', () => { + expect(App.methods.translateForApp('Documentation')).toBe( + 'launchpad:Documentation', + ) + }) + + it('passes placeholder values on', () => { + const vars = { count: 3 } + App.methods.translateForApp('{count} dashboards', vars) + expect(translate).toHaveBeenLastCalledWith( + 'launchpad', + '{count} dashboards', + vars, + ) + }) + + it('is what the root receives', () => { + // The template binding is the wiring; a method nobody binds is a + // translator with no call site. + expect(App.render?.toString() ?? '').toContain('translateForApp') + }) +}) diff --git a/src/__tests__/adminEntryLibraryCatalogue.spec.js b/src/__tests__/adminEntryLibraryCatalogue.spec.js new file mode 100644 index 000000000..35ba0e1d8 --- /dev/null +++ b/src/__tests__/adminEntryLibraryCatalogue.spec.js @@ -0,0 +1,21 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Every webpack entry that renders library components registers the library + * catalogue itself. The admin page (src/admin.js) is its own entry: without + * the call the admin shell chrome reads in English. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('the library catalogue', () => { + it.each(['main.js', 'admin.js'])('is registered by src/%s', (entry) => { + const source = readFileSync(resolve(__dirname, '..', entry), 'utf8') + expect(source).toMatch( + /import\s*\{[^}]*\bregisterTranslations\b[^}]*\}\s*from\s*'@conduction\/nextcloud-vue'/, + ) + expect(source).toMatch(/^\s*registerTranslations\(\)/m) + }) +}) diff --git a/src/__tests__/flowPageOptions.spec.js b/src/__tests__/flowPageOptions.spec.js new file mode 100644 index 000000000..c7f03acfd --- /dev/null +++ b/src/__tests__/flowPageOptions.spec.js @@ -0,0 +1,24 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The flow page turns on the Canvas / List switch (`config.listMode`) and the + * test run on an example payload (`config.testPayload`) of CnFlowEditorPage. + * Both are opt-in, so losing a key hides the screen without any error. + */ + +import { describe, expect, it } from 'vitest' +import manifest from '../manifest.json' + +const flowPages = manifest.pages.filter((page) => page.type === 'flow') + +describe('flow page options', () => { + it('declares a flow page', () => { + expect(flowPages.length).toBeGreaterThan(0) + }) + + it.each(flowPages.map((page) => [page.id, page]))('%s offers the list view and the payload test', (_id, page) => { + expect(page.config.listMode).toBe(true) + expect(page.config.testPayload).toBe(true) + }) +}) diff --git a/src/__tests__/flowPages.spec.js b/src/__tests__/flowPages.spec.js new file mode 100644 index 000000000..fae21e838 --- /dev/null +++ b/src/__tests__/flowPages.spec.js @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The flow pages use the canonical form: an index over the `flows` source whose + * rows open a page of type `flow`, on the addresses links already point at. + * + * @spec exclude Structural invariant of the shipped manifest; no behavioural spec. + */ +import * as fs from 'fs' +import * as path from 'path' +import { describe, expect, it } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +const { pages } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../manifest.json'), 'utf8')) +const byId = new Map(pages.map((page) => [page.id, page])) +const router = createRouter({ + history: createMemoryHistory(), + routes: pages.map((page) => ({ name: page.id, path: page.route, component: {} })), +}) + +describe('flow pages', () => { + it('names no deprecated flow page type', () => { + expect(pages.filter((page) => ['flows', 'flow-detail'].includes(page.type)).map((page) => page.id)).toEqual([]) + }) + + it('Flows is an index over the flows source, scoped to this app', () => { + const { type, config } = byId.get('Flows') + expect(type).toBe('index') + expect(config.entitySource).toBe('flows') + expect(config.app).toBe('launchpad') + }) + + it('Flows opens its rows on a flow page', () => { + expect(byId.get(byId.get('Flows').config.rowRoute).type).toBe('flow') + }) + + it('keeps the flow addresses on the flow pages', () => { + expect(router.resolve('/flows').name).toBe('Flows') + expect(router.resolve('/flows/new').name).toBe('FlowDetail') + expect(router.resolve('/flows/abc').name).toBe('FlowDetail') + }) +}) diff --git a/src/__tests__/l10nNoDuplicateKeys.spec.js b/src/__tests__/l10nNoDuplicateKeys.spec.js new file mode 100644 index 000000000..0854c4354 --- /dev/null +++ b/src/__tests__/l10nNoDuplicateKeys.spec.js @@ -0,0 +1,63 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * A key appears once in the Dutch catalogues. Parallel pull requests each + * appended the same key; git merged both lines without a conflict, and the + * LAST copy won at runtime. In nl.js that made "Set as default" read + * "Instellen als standaard" and "View mode" read "Weergave", while nl.json + * and the rest of the app said otherwise. JSON.parse and a JS object cannot + * see a duplicate, so this reads the raw text. Approach copied from + * nextcloud-vue tests/l10n/catalogueNoDuplicateKeys.spec.js. + */ +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +const DIR = join(__dirname, '../../l10n') + +/** + * The keys that occur more than once in one object of a JSON-shaped text. + * + * @param {string} text The raw object text. + * @return {string[]} The duplicated keys. + */ +function duplicateKeys(text) { + const duplicates = [] + const seenStack = [new Set()] + const re = /"((?:[^"\\]|\\.)*)"\s*:|[{}]/g + let match + while ((match = re.exec(text)) !== null) { + if (match[0] === '{') { + seenStack.push(new Set()) + } else if (match[0] === '}') { + seenStack.pop() + } else { + const seen = seenStack[seenStack.length - 1] + if (seen.has(match[1])) { + duplicates.push(match[1]) + } + seen.add(match[1]) + } + } + return duplicates +} + +describe('Dutch l10n catalogues', () => { + it('nl.json has no key twice', () => { + const text = readFileSync(join(DIR, 'nl.json'), 'utf8') + JSON.parse(text) + expect(duplicateKeys(text)).toEqual([]) + }) + + it('nl.js has no key twice', () => { + const text = readFileSync(join(DIR, 'nl.js'), 'utf8') + // The translations object: from the first "{" to the "}" before the plural rule. + const body = text.slice(text.indexOf('{'), text.lastIndexOf('},')) + expect(duplicateKeys(body)).toEqual([]) + }) + + it('finds a duplicate when there is one', () => { + expect(duplicateKeys('{"a": "1", "b": "2", "a": "3"}')).toEqual(['a']) + }) +}) diff --git a/src/admin.js b/src/admin.js index 9ea2374ec..d09e60e53 100644 --- a/src/admin.js +++ b/src/admin.js @@ -11,6 +11,7 @@ * Provided values are plain (non-reactive) snapshots (REQ-INIT-005). */ +import { registerTranslations } from '@conduction/nextcloud-vue' import { translatePlural as n, translate as t } from '@nextcloud/l10n' import { createPinia } from 'pinia' import { createApp, h } from 'vue' @@ -27,12 +28,20 @@ import { widgetBridge } from './services/widgetBridge.js' import { loadInitialState } from './utils/loadInitialState.js' import './publicPath.js' +// The library's own stylesheet (board look, widgets, dialogs). Without it +// the page only had the library CSS another app's bundle happened to load. +import '@conduction/nextcloud-vue/css/index.css' +import './styles/modals.css' // Reference the imported singleton so its side-effecting construction is not // tree-shaken / flagged as an unused import. void widgetBridge +// The library's own catalogue (admin shell chrome). main.js registers it for +// the app pages; this entry is a separate bundle. +registerTranslations() + // Vue 3 has no global Vue constructor — t/n are installed as an app-level // mixin on the instance created below. const pinia = createPinia() diff --git a/src/components/AcknowledgementPrompt.vue b/src/components/AcknowledgementPrompt.vue index dfc073add..09fd3b408 100644 --- a/src/components/AcknowledgementPrompt.vue +++ b/src/components/AcknowledgementPrompt.vue @@ -60,6 +60,7 @@ + + diff --git a/src/components/Widgets/Renderers/AttentionWidgetForm.vue b/src/components/Widgets/Renderers/AttentionWidgetForm.vue new file mode 100644 index 000000000..3f3416d2d --- /dev/null +++ b/src/components/Widgets/Renderers/AttentionWidgetForm.vue @@ -0,0 +1,114 @@ + + + + + + + diff --git a/src/components/Widgets/Renderers/CalendarWidget.vue b/src/components/Widgets/Renderers/CalendarWidget.vue index bb2f5778f..f255d61b8 100644 --- a/src/components/Widgets/Renderers/CalendarWidget.vue +++ b/src/components/Widgets/Renderers/CalendarWidget.vue @@ -157,6 +157,7 @@ + + + diff --git a/src/components/admin/DashboardBulkOperations.vue b/src/components/admin/DashboardBulkOperations.vue index 8b9a4db3c..bdbd58f6c 100644 --- a/src/components/admin/DashboardBulkOperations.vue +++ b/src/components/admin/DashboardBulkOperations.vue @@ -232,6 +232,56 @@ - + + diff --git a/src/components/admin/tabs/TemplatesPage.vue b/src/components/admin/tabs/TemplatesPage.vue index f3311a2ce..146b0a1cc 100644 --- a/src/components/admin/tabs/TemplatesPage.vue +++ b/src/components/admin/tabs/TemplatesPage.vue @@ -27,6 +27,101 @@ }}

+
+

{{ t('launchpad', 'Ready-made templates') }}

+

+ {{ + t( + 'launchpad', + 'Add a template that comes with LaunchPad. Then edit it to choose who gets it.', + ) + }} +

+
+
+ {{ shipped.name }} + {{ + shipped.description + }} + + {{ + t( + 'launchpad', + 'Its lists read these registers: {registers}. A list whose register is not on this instance is hidden for members.', + { registers: shipped.registers.join(', ') }, + ) + }} + + + {{ + t( + 'launchpad', + 'No app here provides these widgets, so they will show empty: {widgets}', + { widgets: shipped.missingWidgets.join(', ') }, + ) + }} + +
+
+ + {{ + t('launchpad', 'Update to version {version}', { + version: shipped.version, + }) + }} + + + {{ t('launchpad', 'Added') }} + + + {{ t('launchpad', 'Add template') }} + +
+
+
+ +

+ {{ statusMessage }} +

+ + +
+ + diff --git a/src/dialogs/__tests__/ForceReleaseLockDialog.spec.js b/src/dialogs/__tests__/ForceReleaseLockDialog.spec.js deleted file mode 100644 index 06f89e866..000000000 --- a/src/dialogs/__tests__/ForceReleaseLockDialog.spec.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2026 Conduction B.V. - * SPDX-License-Identifier: EUPL-1.2 - * - * ForceReleaseLockDialog: the confirmation before an administrator takes - * over a colleague's lock (dashboard-locking REQ-LOCKUI-003). - */ - -import { mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' -import ForceReleaseLockDialog from '../ForceReleaseLockDialog.vue' - -vi.mock('@nextcloud/l10n', () => ({ - t: (_app, text, vars = {}) => - text.replace(/\{(\w+)\}/g, (_m, k) => vars[k] ?? `{${k}}`), -})) - -vi.mock('@conduction/nextcloud-vue', () => ({ - NcDialog: { - name: 'NcDialog', - props: ['name', 'open'], - template: - '

{{ name }}

', - }, - NcButton: { - name: 'NcButton', - emits: ['click'], - template: '', - }, -})) - -describe('ForceReleaseLockDialog', () => { - it('names the holder and confirms or cancels', async () => { - const wrapper = mount(ForceReleaseLockDialog, { - props: { open: true, holderName: 'Sanne' }, - }) - expect(wrapper.text()).toContain('Sanne') - const [cancel, confirm] = wrapper.findAll('button') - await confirm.trigger('click') - expect(wrapper.emitted('confirm')).toHaveLength(1) - await cancel.trigger('click') - expect(wrapper.emitted('update:open')[0]).toEqual([false]) - }) -}) diff --git a/src/dialogs/__tests__/ReadConfirmationDialog.spec.js b/src/dialogs/__tests__/ReadConfirmationDialog.spec.js index 1ac5907cc..e6c05ea29 100644 --- a/src/dialogs/__tests__/ReadConfirmationDialog.spec.js +++ b/src/dialogs/__tests__/ReadConfirmationDialog.spec.js @@ -18,10 +18,12 @@ vi.mock('@conduction/nextcloud-vue', () => ({ props: ['open'], template: '
', }, - NcButton: { - name: 'NcButton', - emits: ['click'], - template: '', + CnDialogActions: { + name: 'CnDialogActions', + props: { canSubmit: { type: Boolean, default: true } }, + emits: ['cancel', 'submit'], + template: + '', + '', + }, + CnDialogActions: { + name: 'CnDialogActions', + props: { + saving: { type: Boolean, default: false }, + canSubmit: { type: Boolean, default: true }, + confirmLabel: { type: String, default: 'Save' }, + cancelLabel: { type: String, default: 'Cancel' }, + variant: { type: String, default: 'primary' }, + confirmIcon: { type: String, default: '' }, + }, + emits: ['cancel', 'submit'], + template: + '', + }, + NcCheckboxRadioSwitch: { + name: 'NcCheckboxRadioSwitch', + props: ['modelValue'], + emits: ['update:modelValue'], + template: '', + }, + NcTextField: { + name: 'NcTextField', + props: ['modelValue', 'label', 'placeholder'], + emits: ['update:modelValue'], + template: '', + }, +})) + +const FIXTURE = path.resolve(__dirname, 'fixtures/dialogActions.golden.json') + +const SCHEDULES = { + empty: {}, + 'go live only': { goLive: '2026-10-01T09:00' }, + 'take down only': { takeDown: '2026-11-01T18:00' }, + both: { goLive: '2026-10-01T09:00', takeDown: '2026-11-01T18:00' }, + 'with error': { goLive: '2026-10-01T09:00', error: 'Refused' }, +} + +const CONFIRMATIONS = { + 'no placement': { placement: null }, + 'asks with prompt and deadline': { + placement: { + requiresAcknowledgement: '1', + acknowledgementPrompt: 'Read it', + acknowledgementDeadline: '2026-12-01', + }, + }, + 'asks, blank prompt': { + placement: { requiresAcknowledgement: 1, acknowledgementPrompt: ' ' }, + }, + 'switched on in the dialog': { + placement: { requiresAcknowledgement: 0 }, + ask: true, + prompt: ' Got it ', + }, +} + +/** + * An emitted value with every ISO timestamp written back as the viewer's + * local `YYYY-MM-DDTHH:mm`, so the recording does not depend on the time zone. + * + * @param {unknown} value The emitted events. + * @return {unknown} The same events, time zone free. + */ +function local(value) { + return JSON.parse( + JSON.stringify(value, (_key, entry) => { + if (typeof entry !== 'string' || !/^\d{4}-\d\d-\d\dT.*Z$/.test(entry)) { + return entry + } + const date = new Date(entry) + const pad = (n) => String(n).padStart(2, '0') + return `local ${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` + }), + ) +} + +/** + * The footer buttons and what clicking each emits, each on a fresh mount. + * + * @param {Function} make Mounts the dialog in its state. + * @return {Promise} The outcome. + */ +async function footer(make) { + const first = await make() + const buttons = first.findAll('footer button').map((b) => ({ + text: b.text(), + disabled: b.attributes('disabled') !== undefined, + })) + const clicks = {} + for (let index = 0; index < buttons.length; index++) { + const wrapper = await make() + await wrapper.findAll('footer button').at(index).trigger('click') + clicks[buttons[index].text] = local(wrapper.emitted()) + delete clicks[buttons[index].text].click + } + return { buttons, clicks } +} + +describe('dialog footers (golden)', () => { + it('matches the recorded outcomes case by case', async () => { + const actual = {} + for (const [name, state] of Object.entries(SCHEDULES)) { + actual[`schedule | ${name}`] = await footer(async () => { + const wrapper = mount(ScheduleDashboardDialog, { + props: { open: true, error: state.error ?? '' }, + }) + await wrapper.setData({ + goLive: state.goLive ?? '', + takeDown: state.takeDown ?? '', + }) + return wrapper + }) + } + for (const [name, state] of Object.entries(CONFIRMATIONS)) { + actual[`read confirmation | ${name}`] = await footer(async () => { + const wrapper = mount(ReadConfirmationDialog, { + props: { open: true, placement: state.placement }, + }) + if ('ask' in state) { + await wrapper.setData({ ask: state.ask, prompt: state.prompt }) + } + return wrapper + }) + } + if (process.env.RECORD_GOLDEN) { + fs.writeFileSync(FIXTURE, JSON.stringify(actual, null, '\t') + '\n') + } + const expected = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')) + expect(Object.keys(actual)).toEqual(Object.keys(expected)) + for (const [name, value] of Object.entries(expected)) { + expect(actual[name], name).toEqual(value) + } + }) +}) diff --git a/src/dialogs/__tests__/fixtures/dialogActions.golden.json b/src/dialogs/__tests__/fixtures/dialogActions.golden.json new file mode 100644 index 000000000..f2ebd1fb8 --- /dev/null +++ b/src/dialogs/__tests__/fixtures/dialogActions.golden.json @@ -0,0 +1,276 @@ +{ + "schedule | empty": { + "buttons": [ + { + "text": "Cancel", + "disabled": false + }, + { + "text": "Schedule", + "disabled": true + } + ], + "clicks": { + "Cancel": { + "update:open": [ + [ + false + ] + ] + }, + "Schedule": {} + } + }, + "schedule | go live only": { + "buttons": [ + { + "text": "Cancel", + "disabled": false + }, + { + "text": "Schedule", + "disabled": false + } + ], + "clicks": { + "Cancel": { + "update:open": [ + [ + false + ] + ] + }, + "Schedule": { + "save": [ + [ + { + "publishAt": "local 2026-10-01T09:00", + "unpublishAt": null + } + ] + ] + } + } + }, + "schedule | take down only": { + "buttons": [ + { + "text": "Cancel", + "disabled": false + }, + { + "text": "Schedule", + "disabled": false + } + ], + "clicks": { + "Cancel": { + "update:open": [ + [ + false + ] + ] + }, + "Schedule": { + "save": [ + [ + { + "publishAt": null, + "unpublishAt": "local 2026-11-01T18:00" + } + ] + ] + } + } + }, + "schedule | both": { + "buttons": [ + { + "text": "Cancel", + "disabled": false + }, + { + "text": "Schedule", + "disabled": false + } + ], + "clicks": { + "Cancel": { + "update:open": [ + [ + false + ] + ] + }, + "Schedule": { + "save": [ + [ + { + "publishAt": "local 2026-10-01T09:00", + "unpublishAt": "local 2026-11-01T18:00" + } + ] + ] + } + } + }, + "schedule | with error": { + "buttons": [ + { + "text": "Cancel", + "disabled": false + }, + { + "text": "Schedule", + "disabled": false + } + ], + "clicks": { + "Cancel": { + "update:open": [ + [ + false + ] + ] + }, + "Schedule": { + "save": [ + [ + { + "publishAt": "local 2026-10-01T09:00", + "unpublishAt": null + } + ] + ] + } + } + }, + "read confirmation | no placement": { + "buttons": [ + { + "text": "Cancel", + "disabled": false + }, + { + "text": "Save", + "disabled": false + } + ], + "clicks": { + "Cancel": { + "update:open": [ + [ + false + ] + ] + }, + "Save": { + "save": [ + [ + { + "requiresAcknowledgement": 0, + "acknowledgementPrompt": null, + "acknowledgementDeadline": null + } + ] + ] + } + } + }, + "read confirmation | asks with prompt and deadline": { + "buttons": [ + { + "text": "Cancel", + "disabled": false + }, + { + "text": "Save", + "disabled": false + } + ], + "clicks": { + "Cancel": { + "update:open": [ + [ + false + ] + ] + }, + "Save": { + "save": [ + [ + { + "requiresAcknowledgement": 1, + "acknowledgementPrompt": "Read it", + "acknowledgementDeadline": "2026-12-01" + } + ] + ] + } + } + }, + "read confirmation | asks, blank prompt": { + "buttons": [ + { + "text": "Cancel", + "disabled": false + }, + { + "text": "Save", + "disabled": false + } + ], + "clicks": { + "Cancel": { + "update:open": [ + [ + false + ] + ] + }, + "Save": { + "save": [ + [ + { + "requiresAcknowledgement": 1, + "acknowledgementPrompt": null, + "acknowledgementDeadline": null + } + ] + ] + } + } + }, + "read confirmation | switched on in the dialog": { + "buttons": [ + { + "text": "Cancel", + "disabled": false + }, + { + "text": "Save", + "disabled": false + } + ], + "clicks": { + "Cancel": { + "update:open": [ + [ + false + ] + ] + }, + "Save": { + "save": [ + [ + { + "requiresAcknowledgement": 1, + "acknowledgementPrompt": "Got it", + "acknowledgementDeadline": null + } + ] + ] + } + } + } +} diff --git a/src/main.js b/src/main.js index 719f13398..cfeeaf82a 100644 --- a/src/main.js +++ b/src/main.js @@ -36,6 +36,7 @@ import { registerBuiltinDashboardWidgets, registerDashboardWidget, registerIcons, + registerTranslations, useAppManifest, } from '@conduction/nextcloud-vue' import axios from '@nextcloud/axios' @@ -57,9 +58,19 @@ import { currentPermissions } from './utils/permissions.js' import './publicPath.js' import './services/widgetBridge.js' import 'gridstack/dist/gridstack.min.css' +// The library's own stylesheet (board look, widgets, dialogs). Without it +// the page only had the library CSS another app's bundle happened to load. +import '@conduction/nextcloud-vue/css/index.css' import './styles/workspace.css' +import './styles/modals.css' registerBuiltinDashboardWidgets() +// The library's own strings ("Good afternoon, {name}" in the greeting header, +// its dialogs and empty states) live in the `nextcloud-vue` translation +// domain, which only exists once the app registers it. Without this call a +// Dutch start page greeted in English. +registerTranslations() + // Populate the shared CnIcon registry with the MDI icons LaunchPad's // manifests name (ADR-077 rule 3). // diff --git a/src/manifest.json b/src/manifest.json index 1a120bf9a..22bd12f54 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -16,17 +16,10 @@ "display": "cards", "optionsSource": "datasets", "configKey": "demo_dataset", + "loadAction": "load-demo-data", "title": "Load example data?", "required": false, - "body": "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install." - }, - { - "id": "load-demo-data", - "type": "run-action", - "action": "load-demo-data", - "title": "Load the example data", - "required": false, - "body": "Loads what you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards." + "body": "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Each card has its own Load button. Pick \"None\" on a production install." }, { "id": "done", @@ -36,7 +29,14 @@ ] }, "version": "0.2.0", - "dependencies": [], + "look": "board", + "dependencies": [ + { + "id": "openregister", + "required": false, + "name": "OpenRegister" + } + ], "observability": { "_note": "LaunchPad consumes the OpenRegister AppHost observability engine (ADR-040). These descriptors reproduce the output of the now-deleted hand-written HealthController + MetricsController. The /api/health and /api/metrics routes are aliased to the AppHost generic controllers in lib/AppInfo/Application.php, so the engine reads this block at request time. The Prometheus prefix is the runtime app id `launchpad`. Parity: implicit launchpad_info/launchpad_up plus launchpad_dashboards_total/widgets_total/tiles_total match the pre-adoption contract; documented intentional improvements are the now-public health endpoint and the added health `app`/`version` fields (ADR-006).", "health": { @@ -138,7 +138,7 @@ "label": "Reports", "icon": "ChartBoxOutline", "route": "Reports", - "section": "footer", + "section": "settings", "order": 95 }, { @@ -167,7 +167,14 @@ "config": { "app": "launchpad", "title": "Store", - "description": "Install a dashboard another organisation published. Point LaunchPad at a registry and browse what it offers." + "description": "Install a dashboard another organisation published. Point LaunchPad at a registry and browse what it offers.", + "kindLenses": [ + { "kind": "app-template", "label": "App template" }, + { "kind": "adapter", "label": "Adapter" }, + { "kind": "source-template", "label": "Source template" }, + { "kind": "configuration-template", "label": "Configuration template" }, + { "kind": "agent-template", "label": "Agent template" } + ] }, "_note": "ADR-080. Discovery is engine-owned: openregister's GenericStoreService owns the SSRF guard, the redirect refusal and the Bearer-only token. Install is LaunchPad's, in Controller\\StoreController, and runs through the existing ImportService. With no registry configured the page makes NO network call and reports not_configured. There is deliberately NO top-level `store` block: it configures the engine's GenericStoreController, which cannot write a LaunchPad dashboard, so the engine alias yields to the leaf controller. The block this replaced declared `openregister.configset` types, which would have offered configuration sets to somebody looking for a dashboard." }, @@ -211,9 +218,10 @@ "title": "Flows", "config": { "entitySource": "flows", - "app": "launchpad" + "app": "launchpad", + "rowRoute": "FlowDetail" }, - "_note": "ADR-110 Decision 4: a flow is app-specific, so the authoring surface lives here rather than behind a deep link to another app's list. An ordinary index over the named `flows` source (config.entitySource) rather than the deprecated `flows` page type, reading OpenRegister's one native flow store (ADR-065), scoped to this app by config.app." + "_note": "ADR-110 Decision 4: a flow is app-specific, so the authoring surface lives here rather than behind a deep link to another app's list. An ordinary index over the named `flows` source (config.entitySource) rather than the deprecated `flows` page type, reading OpenRegister's one native flow store (ADR-065), scoped to this app by config.app. rowRoute names the FlowDetail page (a route name, not a path): a named-source page has no register and schema, so without it a row click opens nothing and Edit falls back to the source's own /flows path." }, { "id": "FlowDetail", @@ -221,6 +229,8 @@ "type": "flow", "title": "Flow", "config": { + "listMode": true, + "testPayload": true, "app": "launchpad" }, "_note": "The shared CnFlowDetail canvas over the same single engine. Controls render in the NC app sidebar so the canvas keeps full width." @@ -260,7 +270,7 @@ "label": "Dashboards", "icon": "ViewDashboardOutline", "route": { - "name": "dashboards" + "name": "Workspace" }, "format": { "style": "decimal", @@ -281,7 +291,7 @@ "label": "Personal", "icon": "ViewDashboardOutline", "route": { - "name": "dashboards" + "name": "Workspace" }, "format": { "style": "decimal", @@ -305,7 +315,7 @@ "label": "Shared with a group", "icon": "Sitemap", "route": { - "name": "dashboards" + "name": "Workspace" }, "format": { "style": "decimal", @@ -329,7 +339,7 @@ "label": "Admin templates", "icon": "FileReplaceOutline", "route": { - "name": "admin-templates" + "name": "admin-templates-index" }, "format": { "style": "decimal", @@ -379,7 +389,7 @@ "hideHeader": true, "emptyText": "No dashboards yet", "viewAllRoute": { - "name": "dashboards" + "name": "Workspace" }, "viewAllLabel": "Open a dashboard", "columns": [ diff --git a/src/modals/AcknowledgementReportModal.vue b/src/modals/AcknowledgementReportModal.vue index 62ead5c68..d36bbeda4 100644 --- a/src/modals/AcknowledgementReportModal.vue +++ b/src/modals/AcknowledgementReportModal.vue @@ -17,6 +17,7 @@