diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index a26a7dfce..f2dc96bdc 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 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/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.20261005190000 EUPL-1.2 Conduction LaunchPad @@ -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 + + + + + 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__/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/WidgetWrapper.vue b/src/components/WidgetWrapper.vue index a7eadd1bc..d1f9dcd68 100644 --- a/src/components/WidgetWrapper.vue +++ b/src/components/WidgetWrapper.vue @@ -34,7 +34,8 @@ :borderless="isChromelessFrame" :flush="isChromelessFrame || rendersOwnHeader" :showRefresh="false" - :showRequestFeature="false"> + :showRequestFeature="false" + :showActions="showActionsMenu"> @@ -232,6 +233,18 @@ export default { return this.placement?.widgetId === 'nc-widget' }, + /** + * Whether the card's overflow menu shows. On by default; a placement + * whose content says `showActions: false` (Mijn werkdag 4, as LpStart + * draws it) hides it outside edit mode. The cog stays for editing. + * + * @spec openspec/specs/widgets/spec.md + * @return {boolean} false only for an opted-out placement in view mode. + */ + showActionsMenu() { + return this.editMode || this.placement?.content?.showActions !== false + }, + /** @spec openspec/specs/widgets/spec.md */ isChromelessFrame() { return this.isTileWidget || this.isChromelessType diff --git a/src/components/Widgets/Renderers/AttentionWidget.vue b/src/components/Widgets/Renderers/AttentionWidget.vue new file mode 100644 index 000000000..3e7bdd320 --- /dev/null +++ b/src/components/Widgets/Renderers/AttentionWidget.vue @@ -0,0 +1,351 @@ + + + + + + + diff --git a/src/components/Widgets/Renderers/AttentionWidgetForm.vue b/src/components/Widgets/Renderers/AttentionWidgetForm.vue new file mode 100644 index 000000000..7c73ad99e --- /dev/null +++ b/src/components/Widgets/Renderers/AttentionWidgetForm.vue @@ -0,0 +1,116 @@ + + + + + + + diff --git a/src/components/Widgets/Renderers/PeopleWidget.vue b/src/components/Widgets/Renderers/PeopleWidget.vue index c775028d8..c0302f331 100644 --- a/src/components/Widgets/Renderers/PeopleWidget.vue +++ b/src/components/Widgets/Renderers/PeopleWidget.vue @@ -11,7 +11,9 @@ type="search" class="people-widget__search" :aria-label="t('launchpad', 'Search people')" - :placeholder="t('launchpad', 'Search by name or email…')" /> + :placeholder=" + t('launchpad', 'Search by name, email or expertise…') + " />