@conduction/nextcloud-vue 2.133.0 → 2.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  2. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +85 -10
  3. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  4. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js +60 -36
  5. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  6. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue.js +1 -1
  7. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue2.js +10 -0
  8. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue2.js.map +1 -1
  9. package/dist/esm/components/CnIntegrationWidget/CnIntegrationWidget.vue3.js.map +1 -1
  10. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue.js +1 -1
  11. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js +202 -6
  12. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue2.js.map +1 -1
  13. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js +137 -55
  14. package/dist/esm/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue3.js.map +1 -1
  15. package/dist/esm/dialogs/CnPartyAddDialog.vue.js +9 -0
  16. package/dist/esm/dialogs/CnPartyAddDialog.vue.js.map +1 -0
  17. package/dist/esm/dialogs/CnPartyAddDialog.vue2.js +184 -0
  18. package/dist/esm/dialogs/CnPartyAddDialog.vue2.js.map +1 -0
  19. package/dist/esm/dialogs/CnPartyAddDialog.vue3.js +168 -0
  20. package/dist/esm/dialogs/CnPartyAddDialog.vue3.js.map +1 -0
  21. package/dist/esm/l10n/en.json.js +11 -1
  22. package/dist/esm/l10n/en.json.js.map +1 -1
  23. package/dist/esm/l10n/nl.json.js +11 -1
  24. package/dist/esm/l10n/nl.json.js.map +1 -1
  25. package/dist/esm/library-style.js +1 -1
  26. package/dist/esm/nextcloud-vue.css +95 -70
  27. package/dist/esm/utils/objectParties.js +79 -1
  28. package/dist/esm/utils/objectParties.js.map +1 -1
  29. package/dist/nextcloud-vue.cjs.js +5683 -4860
  30. package/dist/nextcloud-vue.cjs.js.map +2 -2
  31. package/dist/nextcloud-vue.css +95 -70
  32. package/l10n/en.json +11 -1
  33. package/l10n/nl.json +11 -1
  34. package/package.json +1 -1
  35. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +105 -12
  36. package/src/components/CnIntegrationWidget/CnIntegrationWidget.vue +10 -0
  37. package/src/components/CnObjectPartiesWidget/CnObjectPartiesWidget.vue +264 -3
  38. package/src/dialogs/CnPartyAddDialog.vue +309 -0
  39. package/src/utils/objectParties.js +78 -0
@@ -1 +1 @@
1
- {"version":3,"file":"CnIntegrationWidget.vue3.js","sources":["../../../../src/components/CnIntegrationWidget/CnIntegrationWidget.vue?vue&type=template&id=2d91b76f&lang.js"],"sourcesContent":["<!--\n CnIntegrationWidget — tabbed, app-faithful integration widget for\n OpenRegister object detail pages and dashboards.\n\n Supersedes the generic Phase E pair (CnIntegrationCard /\n CnIntegrationWidgetGrid) which rendered one one-size surface that\n erased each integrated app's visual identity. This widget gives every\n integration its own tab (app icon + label + accent), composing the\n existing bespoke per-leaf content component (`provider.tab`), PLUS a\n single-integration mode for manifest multi-placement.\n\n TWO MODES\n ---------\n - Tabbed (default): a tab strip with one tab per integration available\n for `surface` (optionally narrowed by `include`), sorted by `.order`.\n The active tab renders that leaf's content. Unavailable integrations\n still get a tab; clicking shows an NcEmptyContent set-up state.\n - Single (`:only=\"deck\"`): NO tab strip — just one integration's\n content under a compact header (app icon + label). This is what an\n app manifest places several times on one detail page (ADR-024).\n\n AVAILABILITY (Phase J-B)\n ------------------------\n Per-integration availability is sourced, in order:\n 1. descriptor `available` (boolean) when the descriptor carries it,\n 2. else the OCS capability\n `openregister.integrations.providers[].available`\n (getCapabilities()), distinguishing missing-app from\n not-configured via a `reason` field when present,\n 3. else `isAppInstalled(requiredApp)` (OC.appswebroots / capabilities).\n When unavailable, the leaf renders NcEmptyContent: app icon, a\n \"{App} not available\" / \"not configured\" name, a short description, and\n an NcButton linking to the leaf's setup docs\n (`https://openregister.conduction.nl/docs/Integrations/{id}/`).\n\n DESCRIPTOR CONTRACT (filled by the per-app waves)\n -------------------------------------------------\n In addition to the registry's existing fields, the widget reads:\n - icon (MDI name string) — the app icon, rendered via CnIcon.\n - accentColor (hex) — per-app brand tint for the active tab / header.\n - appName (string) — backing-app display name for empty copy\n (defaults to `label`).\n - docsUrl (string) — setup-docs link (defaults to the\n openregister.conduction.nl path derived from `id`).\n - surfaces (string[]) — optional surface allowlist (eligible\n everywhere when omitted).\n These are normalised in `src/integrations/registry.js`; the per-app\n fidelity waves refine accentColor/appName per leaf.\n\n MANIFEST PLACEMENT (ADR-024)\n ----------------------------\n An app manifest references this component by name and passes config as\n props, e.g. to place two single-integration widgets on a detail page:\n\n \"widgets\": [\n { \"component\": \"CnIntegrationWidget\",\n \"props\": { \"only\": \"deck\", \"surface\": \"detail-page\" } },\n { \"component\": \"CnIntegrationWidget\",\n \"props\": { \"only\": \"calendar\", \"surface\": \"detail-page\" } }\n ]\n\n The host wires `register` / `schema` / `objectId` from the object\n context. Omit `only` (and optionally pass `include`) for the tabbed\n surface.\n\n See ADR-019 (registry), ADR-022 (sidebar tab contract), ADR-024\n (manifest widget placement), ADR-004 (component composition).\n\n @example\n Tabbed, all detail-page integrations:\n <CnIntegrationWidget\n register=\"decidesk\"\n schema=\"meeting\"\n object-id=\"obj-1\" />\n\n @example\n Single-integration, manifest-placeable:\n <CnIntegrationWidget\n only=\"deck\"\n register=\"decidesk\"\n schema=\"meeting\"\n object-id=\"obj-1\" />\n-->\n<template>\n\t<div class=\"cn-integration-widget\" :data-surface=\"surface\" :data-mode=\"mode\">\n\t\t<!-- SINGLE MODE: one integration, no tab strip. -->\n\t\t<template v-if=\"mode === 'single'\">\n\t\t\t<div v-if=\"singleProvider\" class=\"cn-integration-widget__single\">\n\t\t\t\t<header class=\"cn-integration-widget__header\" :style=\"accentStyle(singleProvider)\">\n\t\t\t\t\t<CnIcon\n\t\t\t\t\t\tv-if=\"singleProvider.icon\"\n\t\t\t\t\t\t:name=\"singleProvider.icon\"\n\t\t\t\t\t\t:size=\"20\"\n\t\t\t\t\t\tclass=\"cn-integration-widget__header-icon\" />\n\t\t\t\t\t<span class=\"cn-integration-widget__header-label\">{{ singleProvider.label }}</span>\n\t\t\t\t\t<div class=\"cn-integration-widget__header-actions\">\n\t\t\t\t\t\t<CnActionsMenu\n\t\t\t\t\t\t\t:documentationUrl=\"documentationUrl\"\n\t\t\t\t\t\t\t:widgetId=\"resolvedWidgetId\"\n\t\t\t\t\t\t\t:title=\"singleProvider.label\"\n\t\t\t\t\t\t\t:surface=\"`widget:${resolvedWidgetId}`\"\n\t\t\t\t\t\t\ttestidBase=\"cn-integration-widget\" />\n\t\t\t\t\t</div>\n\t\t\t\t</header>\n\t\t\t\t<div class=\"cn-integration-widget__panel\">\n\t\t\t\t\t<CnIntegrationWidgetEmpty\n\t\t\t\t\t\tv-if=\"!isAvailable(singleProvider)\"\n\t\t\t\t\t\t:provider=\"singleProvider\"\n\t\t\t\t\t\t:reason=\"unavailableReason(singleProvider)\" />\n\t\t\t\t\t<CnLeafMountHost\n\t\t\t\t\t\tv-else-if=\"isMountLeaf(singleProvider)\"\n\t\t\t\t\t\t:provider=\"singleProvider\"\n\t\t\t\t\t\t:mountProps=\"leafMountProps(singleProvider)\" />\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"leafComponent(singleProvider)\"\n\t\t\t\t\t\tv-else\n\t\t\t\t\t\tv-bind=\"leafProps(singleProvider)\" />\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<!-- The leaf is not registered: its app is absent or did not load. -->\n\t\t\t<CnIntegrationWidgetEmpty\n\t\t\t\tv-else-if=\"appName\"\n\t\t\t\t:provider=\"absentProvider\"\n\t\t\t\treason=\"missing-app\"\n\t\t\t\tdata-testid=\"cn-integration-widget-absent\" />\n\t\t\t<NcEmptyContent v-else :name=\"unknownLeafLabel\" />\n\t\t</template>\n\n\t\t<!-- TABBED MODE: tab strip + active panel. -->\n\t\t<template v-else>\n\t\t\t<NcEmptyContent\n\t\t\t\tv-if=\"visibleProviders.length === 0\"\n\t\t\t\t:name=\"noIntegrationsLabel\" />\n\t\t\t<template v-else>\n\t\t\t\t<div class=\"cn-integration-widget__tabbar\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"cn-integration-widget__tabs\"\n\t\t\t\t\t\trole=\"tablist\"\n\t\t\t\t\t\t:aria-label=\"tabListLabel\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tv-for=\"(provider, idx) in visibleProviders\"\n\t\t\t\t\t\t\t:id=\"tabId(provider)\"\n\t\t\t\t\t\t\t:key=\"provider.id\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\tclass=\"cn-integration-widget__tab\"\n\t\t\t\t\t\t\t:class=\"{ 'cn-integration-widget__tab--active': provider.id === activeId }\"\n\t\t\t\t\t\t\t:style=\"tabAccentStyle(provider)\"\n\t\t\t\t\t\t\t:aria-selected=\"provider.id === activeId ? 'true' : 'false'\"\n\t\t\t\t\t\t\t:tabindex=\"provider.id === activeId ? 0 : -1\"\n\t\t\t\t\t\t\t:aria-controls=\"panelId(provider)\"\n\t\t\t\t\t\t\t:data-testid=\"`cn-integration-widget-tab-${provider.id}`\"\n\t\t\t\t\t\t\t@click=\"selectTab(provider.id)\"\n\t\t\t\t\t\t\t@keydown=\"onTabKeydown($event, idx)\">\n\t\t\t\t\t\t\t<CnIcon\n\t\t\t\t\t\t\t\tv-if=\"provider.icon\"\n\t\t\t\t\t\t\t\t:name=\"provider.icon\"\n\t\t\t\t\t\t\t\t:size=\"18\"\n\t\t\t\t\t\t\t\tclass=\"cn-integration-widget__tab-icon\" />\n\t\t\t\t\t\t\t<span class=\"cn-integration-widget__tab-label\">{{ provider.label }}</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"cn-integration-widget__header-actions\">\n\t\t\t\t\t\t<CnActionsMenu\n\t\t\t\t\t\t\t:documentationUrl=\"documentationUrl\"\n\t\t\t\t\t\t\t:widgetId=\"resolvedWidgetId\"\n\t\t\t\t\t\t\t:title=\"headerTitle\"\n\t\t\t\t\t\t\t:surface=\"`widget:${resolvedWidgetId}`\"\n\t\t\t\t\t\t\ttestidBase=\"cn-integration-widget\" />\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tv-if=\"activeProvider\"\n\t\t\t\t\t:id=\"panelId(activeProvider)\"\n\t\t\t\t\tclass=\"cn-integration-widget__panel\"\n\t\t\t\t\trole=\"tabpanel\"\n\t\t\t\t\t:aria-labelledby=\"tabId(activeProvider)\"\n\t\t\t\t\ttabindex=\"0\">\n\t\t\t\t\t<CnIntegrationWidgetEmpty\n\t\t\t\t\t\tv-if=\"!isAvailable(activeProvider)\"\n\t\t\t\t\t\t:provider=\"activeProvider\"\n\t\t\t\t\t\t:reason=\"unavailableReason(activeProvider)\" />\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"leafComponent(activeProvider)\"\n\t\t\t\t\t\tv-else\n\t\t\t\t\t\tv-bind=\"leafProps(activeProvider)\" />\n\t\t\t\t</div>\n\t\t\t</template>\n\t\t</template>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcEmptyContent } from '@nextcloud/vue'\nimport CnIcon from '../CnIcon/CnIcon.vue'\nimport CnLeafMountHost from '../CnLeafMountHost/CnLeafMountHost.vue'\nimport CnIntegrationWidgetEmpty from './CnIntegrationWidgetEmpty.vue'\nimport { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'\nimport { registerIntegrationIcons } from '../../integrations/icons.js'\nimport { isAppInstalled } from '../../utils/appInstalled.js'\nimport { CnActionsMenu } from '../CnActionsMenu/index.js'\nimport { resolveProviderAvailability } from './availability.js'\n\n// Ensure every descriptor's MDI icon resolves through CnIcon regardless\n// of what the host app registered at bootstrap — otherwise tabs fall\n// back to the generic HelpCircleOutline glyph. Idempotent.\nregisterIntegrationIcons()\n\nconst VALID_SURFACES = ['user-dashboard', 'app-dashboard', 'detail-page', 'single-entity']\n\n/**\n * CnIntegrationWidget — tabbed integration widget + single-integration\n * mode. See the file-level docblock for the contract.\n */\nexport default {\n\tname: 'CnIntegrationWidget',\n\n\tcomponents: { NcEmptyContent, CnIcon, CnIntegrationWidgetEmpty, CnActionsMenu, CnLeafMountHost },\n\n\tprops: {\n\t\t/**\n\t\t * Optional registry override (used in tests so registrations\n\t\t * don't leak across cases). Defaults to the singleton.\n\t\t */\n\t\tregistry: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * OpenRegister register id — a slug/uuid string, or the register\n\t\t * OBJECT a detail page injects. Accepts both for the same reason\n\t\t * `schema` does; see below.\n\t\t */\n\t\tregister: { type: [String, Object], default: '' },\n\t\t/**\n\t\t * OpenRegister schema — a slug/uuid string, or the schema OBJECT the\n\t\t * detail-page widget grid injects.\n\t\t *\n\t\t * This used to be `type: String`, and a detail page handed it the\n\t\t * resolved object instead. Vue reported the mismatch as a dev-only\n\t\t * warning (\"Expected String with value \\\"[object Object]\\\", got\n\t\t * Object\") and then passed the object through unchanged, so the leaf\n\t\t * built `/apps/openregister/api/schemas/[object Object]` and got a 404\n\t\t * — an unreadable URL rather than a named failure, and silent in\n\t\t * production where the warning is compiled out.\n\t\t *\n\t\t * `CnAuditTrailWidget` already accepted both shapes and collapsed the\n\t\t * object to its slug; this mirrors that so the two widgets cannot\n\t\t * disagree about what a detail page may hand them.\n\t\t */\n\t\tschema: { type: [String, Object], default: '' },\n\t\t/** Parent object id. Forwarded to each leaf. */\n\t\tobjectId: { type: [String, Number], default: '' },\n\t\t/**\n\t\t * Single-integration mode: render ONLY this leaf id with no tab\n\t\t * strip. The manifest multi-placement entry point (ADR-024).\n\t\t */\n\t\tonly: { type: String, default: '' },\n\t\t/**\n\t\t * Rendering surface — filters integrations by `surfaces.includes()`\n\t\t * when a descriptor declares a `surfaces` allowlist.\n\t\t */\n\t\tsurface: {\n\t\t\ttype: String,\n\t\t\tdefault: 'detail-page',\n\t\t\tvalidator: (s) => VALID_SURFACES.includes(s),\n\t\t},\n\n\t\t/**\n\t\t * Explicit allowlist of leaf ids to show as tabs. When omitted,\n\t\t * all registered integrations eligible for `surface` are shown.\n\t\t *\n\t\t * @type {string[]|null}\n\t\t */\n\t\tinclude: { type: Array, default: null },\n\t\t/** Base API URL forwarded to each leaf content component. */\n\t\tapiBase: { type: String, default: '/apps/openregister/api' },\n\t\t/** Optional object type forwarded to each leaf content component. */\n\t\tobjectType: { type: String, default: '' },\n\t\t/**\n\t\t * Documentation link target for the header's shared Actions menu.\n\t\t * When non-empty a \"Documentation\" item opens it in a new tab;\n\t\t * empty (the default) hides the item.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tdocumentationUrl: { type: String, default: '' },\n\t\t/**\n\t\t * Stable id carried on the Actions-menu refresh / request-feature\n\t\t * payloads and surface string. When empty the header title is\n\t\t * slugified as a fallback (see `resolvedWidgetId`).\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\twidgetId: { type: String, default: '' },\n\n\t\t/**\n\t\t * The record's display name, handed to the leaf as `objectLabel` (a\n\t\t * leaf titles its panel or its dialog with it).\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tobjectLabel: { type: String, default: '' },\n\n\t\t/**\n\t\t * The surface the leaf itself is told it renders on, when it differs\n\t\t * from `surface` (a leaf placed in a dialog that draws its sidebar-tab\n\t\t * look: `sidebar-tab`). Empty passes `surface`.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tleafSurface: { type: String, default: '' },\n\n\t\t/**\n\t\t * The app that provides the `only` leaf, named in the empty state when\n\t\t * the leaf is not registered (\"Decidiq not available\"). Without it an\n\t\t * unregistered leaf shows the plain \"not available\" line.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tappName: { type: String, default: '' },\n\n\t\t/** The icon of that empty state (an MDI name). */\n\t\tappIcon: { type: String, default: '' },\n\t},\n\n\tsetup(props) {\n\t\tconst { integrations, resolveTab } = useIntegrationRegistry(props.registry || undefined)\n\t\t// `resolveTab` swaps a lib-owned entry for THIS bundle's component\n\t\t// (openregister#1958); the entry's own `tab` may belong to another\n\t\t// bundle's Vue and then renders its nested components as literal\n\t\t// unknown elements. See `leafComponent`.\n\t\treturn { registryIntegrations: integrations, resolveRegistryTab: resolveTab }\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\t/** Currently selected tab id (tabbed mode only). */\n\t\t\tactiveId: '',\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/** 'single' when `only` is set, else 'tabbed'. */\n\t\tmode() {\n\t\t\treturn this.only ? 'single' : 'tabbed'\n\t\t},\n\n\t\t/**\n\t\t * The register SLUG. A register object collapses to its slug/name/id,\n\t\t * mirroring `CnAuditTrailWidget.resolvedRegister`.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedRegister() {\n\t\t\tconst r = this.register || ''\n\t\t\treturn typeof r === 'string' ? r : (r && (r.slug || r.name || r.id)) || ''\n\t\t},\n\n\t\t/**\n\t\t * The schema SLUG. A schema object collapses to its slug/name/id.\n\t\t *\n\t\t * Leaves interpolate this straight into an OpenRegister URL, so an\n\t\t * object reaching them produces `/api/schemas/[object Object]` and a\n\t\t * 404 that names nothing. Collapsing here means every leaf receives\n\t\t * the string it already documents, and a caller that hands over the\n\t\t * whole schema object — which the detail-page widget grid does — is\n\t\t * served rather than silently broken.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedSchema() {\n\t\t\tconst s = this.schema || ''\n\t\t\treturn typeof s === 'string' ? s : (s && (s.slug || s.name || s.id)) || ''\n\t\t},\n\n\t\t/**\n\t\t * The provider for single mode (resolved from `only`), or null\n\t\t * when the id is unknown / not eligible for the surface.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tsingleProvider() {\n\t\t\tif (!this.only) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst all = this.registryIntegrations || []\n\t\t\tfor (const provider of all) {\n\t\t\t\tif (provider.id === this.only && this.eligibleForSurface(provider)) {\n\t\t\t\t\treturn provider\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * Integrations shown as tabs: eligible for the surface, narrowed\n\t\t * by `include` when set, sorted by the registry's `order` (the\n\t\t * snapshot is already order-sorted, so we preserve that).\n\t\t *\n\t\t * @return {object[]}\n\t\t */\n\t\tvisibleProviders() {\n\t\t\tconst include = Array.isArray(this.include) ? this.include : null\n\t\t\treturn (this.registryIntegrations || []).filter((provider) => {\n\t\t\t\tif (!this.eligibleForSurface(provider)) {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\tif (include !== null && !include.includes(provider.id)) {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The currently active provider in tabbed mode.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tactiveProvider() {\n\t\t\tconst list = this.visibleProviders\n\t\t\tfor (const provider of list) {\n\t\t\t\tif (provider.id === this.activeId) {\n\t\t\t\t\treturn provider\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn list.length > 0 ? list[0] : null\n\t\t},\n\n\t\tnoIntegrationsLabel() {\n\t\t\treturn t('nextcloud-vue', 'No integrations available yet.')\n\t\t},\n\n\t\t/**\n\t\t * The stand-in descriptor the empty state draws for an `only` leaf\n\t\t * that is not registered.\n\t\t *\n\t\t * @return {{id: string, appName: string, icon: string}}\n\t\t * @spec openspec/changes/integration-widget-mounts-leaves/specs/integration-widget-mounts-leaves/spec.md#requirement-a-single-integration-widget-mounts-a-mount-mode-leaf\n\t\t */\n\t\tabsentProvider() {\n\t\t\treturn { id: this.only, appName: this.appName, icon: this.appIcon }\n\t\t},\n\n\t\tunknownLeafLabel() {\n\t\t\treturn t('nextcloud-vue', 'This integration is not available.')\n\t\t},\n\n\t\ttabListLabel() {\n\t\t\treturn t('nextcloud-vue', 'Integrations')\n\t\t},\n\n\t\t/**\n\t\t * Label shown by the active header — the single-mode provider's\n\t\t * label, else the active tab's label, else the generic tab-list\n\t\t * label. Fed to the shared Actions menu as its `title`.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\theaderTitle() {\n\t\t\tif (this.mode === 'single') {\n\t\t\t\treturn this.singleProvider ? this.singleProvider.label : this.unknownLeafLabel\n\t\t\t}\n\t\t\treturn this.activeProvider ? this.activeProvider.label : this.tabListLabel\n\t\t},\n\n\t\t/**\n\t\t * Stable widget id for the Actions menu: the explicit `widgetId`\n\t\t * prop when set, else a slugified `headerTitle` (lowercase,\n\t\t * non-alphanumerics → '-'), falling back to 'integration'.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedWidgetId() {\n\t\t\tif (this.widgetId) {\n\t\t\t\treturn this.widgetId\n\t\t\t}\n\t\t\tconst slug = String(this.headerTitle || '')\n\t\t\t\t.toLowerCase()\n\t\t\t\t.replace(/[^a-z0-9]+/g, '-')\n\t\t\t\t.replace(/^-+|-+$/g, '')\n\t\t\treturn slug || 'integration'\n\t\t},\n\t},\n\n\twatch: {\n\t\t/**\n\t\t * Keep the active tab valid when the provider set changes (late\n\t\t * registration, surface/include change). Re-anchor to the first\n\t\t * provider if the current active id disappears.\n\t\t */\n\t\tvisibleProviders: {\n\t\t\timmediate: true,\n\t\t\thandler(list) {\n\t\t\t\tif (this.mode !== 'tabbed') {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconst ids = (list || []).map((p) => p.id)\n\t\t\t\tif (ids.length === 0) {\n\t\t\t\t\tthis.activeId = ''\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (!ids.includes(this.activeId)) {\n\t\t\t\t\tthis.activeId = ids[0]\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t},\n\n\tmethods: {\n\t\tt,\n\n\t\t/**\n\t\t * Whether a provider is eligible for the current surface. A\n\t\t * descriptor with a `surfaces` allowlist must include it; without\n\t\t * one, every provider is eligible (matches CnIntegrationWidgetGrid).\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {boolean}\n\t\t */\n\t\teligibleForSurface(provider) {\n\t\t\tconst surfaces = provider.surfaces\n\t\t\tif (Array.isArray(surfaces) && surfaces.length > 0) {\n\t\t\t\treturn surfaces.includes(this.surface)\n\t\t\t}\n\t\t\treturn true\n\t\t},\n\n\t\t/**\n\t\t * Resolve whether the backing app for a provider is available +\n\t\t * configured. See file docblock for the source order.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {boolean}\n\t\t */\n\t\tisAvailable(provider) {\n\t\t\treturn resolveProviderAvailability(provider, { isAppInstalled }).available\n\t\t},\n\n\t\t/**\n\t\t * The unavailable reason ('missing-app' | 'not-configured' |\n\t\t * 'unknown') for empty-state copy.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {string}\n\t\t */\n\t\tunavailableReason(provider) {\n\t\t\treturn resolveProviderAvailability(provider, { isAppInstalled }).reason\n\t\t},\n\n\t\t/**\n\t\t * The content component for a leaf panel. Resolved through the\n\t\t * registry composable so a lib-owned id renders THIS bundle's copy\n\t\t * of its `tab`; the entry's stored object is only used for a\n\t\t * consumer-custom id, which lives in the consumer's own bundle.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {object|null} Vue component, or null.\n\t\t */\n\t\tleafComponent(provider) {\n\t\t\tif (!provider || typeof provider.id !== 'string') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst local = typeof this.resolveRegistryTab === 'function'\n\t\t\t\t? this.resolveRegistryTab(provider.id)\n\t\t\t\t: null\n\t\t\treturn local || provider.tab || null\n\t\t},\n\n\t\t/**\n\t\t * Props forwarded to a leaf's content component. Matches the\n\t\t * shared shape consumed by the bespoke `Cn<X>Tab` components.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {object}\n\t\t */\n\t\tleafProps(provider) {\n\t\t\treturn {\n\t\t\t\tintegrationId: provider.id,\n\t\t\t\tregister: this.resolvedRegister,\n\t\t\t\tschema: this.resolvedSchema,\n\t\t\t\tobjectId: this.objectId !== '' && this.objectId !== null && this.objectId !== undefined\n\t\t\t\t\t? String(this.objectId)\n\t\t\t\t\t: '',\n\n\t\t\t\tobjectType: this.objectType,\n\t\t\t\tapiBase: this.apiBase,\n\t\t\t\tsurface: this.leafSurface || this.surface,\n\t\t\t\t...(this.objectLabel ? { objectLabel: this.objectLabel } : {}),\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether a leaf is a mount-mode leaf (ADR-066): it mounts its own\n\t\t * framework into a host element instead of being a component.\n\t\t *\n\t\t * @param {object} provider The descriptor.\n\t\t * @return {boolean}\n\t\t * @spec openspec/changes/integration-widget-mounts-leaves/specs/integration-widget-mounts-leaves/spec.md#requirement-a-single-integration-widget-mounts-a-mount-mode-leaf\n\t\t */\n\t\tisMountLeaf(provider) {\n\t\t\treturn Boolean(provider)\n\t\t\t\t&& provider.renderMode === 'mount'\n\t\t\t\t&& typeof provider.mount === 'function'\n\t\t\t\t&& typeof provider.unmount === 'function'\n\t\t},\n\n\t\t/**\n\t\t * What a mount-mode leaf is mounted with: the record context, also as\n\t\t * `integrationContext`, and the record's label.\n\t\t *\n\t\t * @param {object} provider The descriptor.\n\t\t * @return {object} The mount props.\n\t\t */\n\t\tleafMountProps(provider) {\n\t\t\tconst props = this.leafProps(provider)\n\t\t\treturn {\n\t\t\t\t...props,\n\t\t\t\tintegrationContext: { register: props.register, schema: props.schema, objectId: props.objectId },\n\t\t\t}\n\t\t},\n\n\t\tselectTab(id) {\n\t\t\tthis.activeId = id\n\t\t},\n\n\t\t/**\n\t\t * Roving-tabindex keyboard nav per the ARIA tablist pattern:\n\t\t * Left/Right move + activate, Home/End jump to the ends.\n\t\t *\n\t\t * @param {KeyboardEvent} event Key event.\n\t\t * @param {number} index Index of the focused tab.\n\t\t * @return {void}\n\t\t */\n\t\tonTabKeydown(event, index) {\n\t\t\tconst list = this.visibleProviders\n\t\t\tif (list.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlet next = -1\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') {\n\t\t\t\tnext = (index + 1) % list.length\n\t\t\t} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {\n\t\t\t\tnext = (index - 1 + list.length) % list.length\n\t\t\t} else if (event.key === 'Home') {\n\t\t\t\tnext = 0\n\t\t\t} else if (event.key === 'End') {\n\t\t\t\tnext = list.length - 1\n\t\t\t}\n\t\t\tif (next === -1) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tevent.preventDefault()\n\t\t\tconst provider = list[next]\n\t\t\tthis.activeId = provider.id\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tconst el = this.$el && this.$el.querySelector(`#${this.tabId(provider)}`)\n\t\t\t\tif (el && typeof el.focus === 'function') {\n\t\t\t\t\tel.focus()\n\t\t\t\t}\n\t\t\t})\n\t\t},\n\n\t\ttabId(provider) {\n\t\t\treturn `cn-iw-tab-${provider.id}`\n\t\t},\n\n\t\tpanelId(provider) {\n\t\t\treturn `cn-iw-panel-${provider.id}`\n\t\t},\n\n\t\t/**\n\t\t * Accent tint for the active tab — a left border + subtle bg in\n\t\t * the app's brand colour. Inactive tabs get no accent var.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {object} Style object.\n\t\t */\n\t\ttabAccentStyle(provider) {\n\t\t\tif (provider.id !== this.activeId || !provider.accentColor) {\n\t\t\t\treturn {}\n\t\t\t}\n\t\t\treturn { '--cn-iw-accent': provider.accentColor }\n\t\t},\n\n\t\t/**\n\t\t * Accent tint for the single-mode header.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {object} Style object.\n\t\t */\n\t\taccentStyle(provider) {\n\t\t\tif (!provider.accentColor) {\n\t\t\t\treturn {}\n\t\t\t}\n\t\t\treturn { '--cn-iw-accent': provider.accentColor }\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-integration-widget {\n\twidth: 100%;\n\tdisplay: flex;\n\tflex-direction: column;\n\tmin-height: 0;\n}\n\n.cn-integration-widget__tabbar {\n\tdisplay: flex;\n\talign-items: flex-end;\n\tgap: 8px;\n\tborder-bottom: 1px solid var(--color-border);\n\tmargin-bottom: 12px;\n}\n\n.cn-integration-widget__tabs {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\tgap: 2px;\n\tflex: 1;\n\tmin-width: 0;\n}\n\n.cn-integration-widget__tab {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 8px 12px;\n\tbackground: transparent;\n\tborder: none;\n\tborder-bottom: 2px solid transparent;\n\tborder-radius: var(--border-radius) var(--border-radius) 0 0;\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.95em;\n\tcursor: pointer;\n}\n\n.cn-integration-widget__tab:hover {\n\tbackground: var(--color-background-hover);\n\tcolor: var(--color-main-text);\n}\n\n.cn-integration-widget__tab:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: -2px;\n}\n\n.cn-integration-widget__tab--active {\n\tcolor: var(--color-main-text);\n\tfont-weight: 600;\n\tborder-bottom-color: var(--cn-iw-accent, var(--color-primary-element));\n}\n\n.cn-integration-widget__tab-icon {\n\tcolor: var(--cn-iw-accent, currentColor);\n\tflex-shrink: 0;\n}\n\n.cn-integration-widget__tab-label {\n\twhite-space: nowrap;\n}\n\n.cn-integration-widget__header {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 8px;\n\tpadding: 8px 4px;\n\tmargin-bottom: 8px;\n\tborder-bottom: 2px solid var(--cn-iw-accent, var(--color-border));\n}\n\n.cn-integration-widget__header-icon {\n\tcolor: var(--cn-iw-accent, var(--color-main-text));\n\tflex-shrink: 0;\n}\n\n.cn-integration-widget__header-label {\n\tfont-weight: 600;\n\tcolor: var(--color-main-text);\n}\n\n.cn-integration-widget__header-actions {\n\tmargin-inline-start: auto;\n\tflex-shrink: 0;\n}\n\n.cn-integration-widget__panel {\n\tflex: 1;\n\tmin-height: 0;\n}\n\n.cn-integration-widget__panel:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: -2px;\n}\n</style>\n"],"names":["_createElementBlock","_createBlock","_createElementVNode","_toDisplayString","_createVNode"],"mappings":";;;;;AA0KS,EAAA,KAAA,EAAA,+BAAA;;AAOE,MAAA,UAAA,GAAA,EAAA,KAAK,EAAC,qCAA2B,GAAA;AAClC,MAAA,UAAA,GAAA,EAAA,KAAK,EAAC,uCAAC,GAAA;AASR,MAAA,UAAA,GAAA,EAAA,KAAA,EAAA,8BAAA,GAAA;;;;;;;;;;;;;;;uBApBFA;AAAA,IAAA,KAAI,EAAC,uBAAuB;AAAE,IAAA,cAAQ,EAAA,MAAA,CAAA,OAAA;AAAA,IAAA,WAAA,EAAA,QAAA,CAAA,IAAA;;KAEnC,QAAA,CAAA,IAAA,KAAA,QAAA;;WACD,QAAA,CAAA,cAAA;;kCAgBI,CAAA,QAAA,EAAA;;;;AAbD,kBAAA,CAAA,QAAA,CAAA,cAAa,CAAA,IAAA;oCADpBC,WAIc,CAAA,iBAAA,EAAA;;wBAFZ,IAAI,EAAE,QAAc,CAAA,cAAA,CAAC,IAAI;AACzB,wBAAA,IAAI,EAAE,EAAE;AACT,wBAAA,KAAK,EAAC,oCAAO;;;AACd,kBAAAC,kBAAA,CAAuC,MAAvC,EAAA,UAAuC,EAAAC,eAAA,CAAA,QAAA,CAAA,cAAA,CAAA,KAAA,CAAA,EAAA,CAAA,YAAA;AACvC,kBAAAD,kBAAA,CAOM,OAPN,UAOM,EAAA;oBANLE,WAKkC,CAAA,wBAAA,EAAA;AAJhC,sBAAA,gBAAgB,EAAE,MAAc,CAAA,gBAAA;AAChC,sBAAA,QAAQ,EAAE,QAAa,CAAA,gBAAA;sBACvB,KAAK,EAAE,QAAc,CAAA,cAAA,CAAC,KAAK;AAC3B,sBAAA,OAAM,YAAA,QAAA,CAAA,gBAAA,CAAA,CAAA;AACP,sBAAA,UAAU,EAAC,uBAAsB;;;;AAGpC,gBAAAF,kBAAA,QAAA;AAEQ,kBAAA,CAAA,CAAA,QAAA,CAAA,WAAA,CAAA,QAAA,CAAA,cAAA,CAAA;oCADPD;;AAEC,wBAAA,QAAA,EAAA,QAAA,CAAA,cAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"CnIntegrationWidget.vue3.js","sources":["../../../../src/components/CnIntegrationWidget/CnIntegrationWidget.vue?vue&type=template&id=1b6b243b&lang.js"],"sourcesContent":["<!--\n CnIntegrationWidget — tabbed, app-faithful integration widget for\n OpenRegister object detail pages and dashboards.\n\n Supersedes the generic Phase E pair (CnIntegrationCard /\n CnIntegrationWidgetGrid) which rendered one one-size surface that\n erased each integrated app's visual identity. This widget gives every\n integration its own tab (app icon + label + accent), composing the\n existing bespoke per-leaf content component (`provider.tab`), PLUS a\n single-integration mode for manifest multi-placement.\n\n TWO MODES\n ---------\n - Tabbed (default): a tab strip with one tab per integration available\n for `surface` (optionally narrowed by `include`), sorted by `.order`.\n The active tab renders that leaf's content. Unavailable integrations\n still get a tab; clicking shows an NcEmptyContent set-up state.\n - Single (`:only=\"deck\"`): NO tab strip — just one integration's\n content under a compact header (app icon + label). This is what an\n app manifest places several times on one detail page (ADR-024).\n\n AVAILABILITY (Phase J-B)\n ------------------------\n Per-integration availability is sourced, in order:\n 1. descriptor `available` (boolean) when the descriptor carries it,\n 2. else the OCS capability\n `openregister.integrations.providers[].available`\n (getCapabilities()), distinguishing missing-app from\n not-configured via a `reason` field when present,\n 3. else `isAppInstalled(requiredApp)` (OC.appswebroots / capabilities).\n When unavailable, the leaf renders NcEmptyContent: app icon, a\n \"{App} not available\" / \"not configured\" name, a short description, and\n an NcButton linking to the leaf's setup docs\n (`https://openregister.conduction.nl/docs/Integrations/{id}/`).\n\n DESCRIPTOR CONTRACT (filled by the per-app waves)\n -------------------------------------------------\n In addition to the registry's existing fields, the widget reads:\n - icon (MDI name string) — the app icon, rendered via CnIcon.\n - accentColor (hex) — per-app brand tint for the active tab / header.\n - appName (string) — backing-app display name for empty copy\n (defaults to `label`).\n - docsUrl (string) — setup-docs link (defaults to the\n openregister.conduction.nl path derived from `id`).\n - surfaces (string[]) — optional surface allowlist (eligible\n everywhere when omitted).\n These are normalised in `src/integrations/registry.js`; the per-app\n fidelity waves refine accentColor/appName per leaf.\n\n MANIFEST PLACEMENT (ADR-024)\n ----------------------------\n An app manifest references this component by name and passes config as\n props, e.g. to place two single-integration widgets on a detail page:\n\n \"widgets\": [\n { \"component\": \"CnIntegrationWidget\",\n \"props\": { \"only\": \"deck\", \"surface\": \"detail-page\" } },\n { \"component\": \"CnIntegrationWidget\",\n \"props\": { \"only\": \"calendar\", \"surface\": \"detail-page\" } }\n ]\n\n The host wires `register` / `schema` / `objectId` from the object\n context. Omit `only` (and optionally pass `include`) for the tabbed\n surface.\n\n See ADR-019 (registry), ADR-022 (sidebar tab contract), ADR-024\n (manifest widget placement), ADR-004 (component composition).\n\n @example\n Tabbed, all detail-page integrations:\n <CnIntegrationWidget\n register=\"decidesk\"\n schema=\"meeting\"\n object-id=\"obj-1\" />\n\n @example\n Single-integration, manifest-placeable:\n <CnIntegrationWidget\n only=\"deck\"\n register=\"decidesk\"\n schema=\"meeting\"\n object-id=\"obj-1\" />\n-->\n<template>\n\t<div class=\"cn-integration-widget\" :data-surface=\"surface\" :data-mode=\"mode\">\n\t\t<!-- SINGLE MODE: one integration, no tab strip. -->\n\t\t<template v-if=\"mode === 'single'\">\n\t\t\t<div v-if=\"singleProvider\" class=\"cn-integration-widget__single\">\n\t\t\t\t<header class=\"cn-integration-widget__header\" :style=\"accentStyle(singleProvider)\">\n\t\t\t\t\t<CnIcon\n\t\t\t\t\t\tv-if=\"singleProvider.icon\"\n\t\t\t\t\t\t:name=\"singleProvider.icon\"\n\t\t\t\t\t\t:size=\"20\"\n\t\t\t\t\t\tclass=\"cn-integration-widget__header-icon\" />\n\t\t\t\t\t<span class=\"cn-integration-widget__header-label\">{{ singleProvider.label }}</span>\n\t\t\t\t\t<div class=\"cn-integration-widget__header-actions\">\n\t\t\t\t\t\t<CnActionsMenu\n\t\t\t\t\t\t\t:documentationUrl=\"documentationUrl\"\n\t\t\t\t\t\t\t:widgetId=\"resolvedWidgetId\"\n\t\t\t\t\t\t\t:title=\"singleProvider.label\"\n\t\t\t\t\t\t\t:surface=\"`widget:${resolvedWidgetId}`\"\n\t\t\t\t\t\t\ttestidBase=\"cn-integration-widget\" />\n\t\t\t\t\t</div>\n\t\t\t\t</header>\n\t\t\t\t<div class=\"cn-integration-widget__panel\">\n\t\t\t\t\t<CnIntegrationWidgetEmpty\n\t\t\t\t\t\tv-if=\"!isAvailable(singleProvider)\"\n\t\t\t\t\t\t:provider=\"singleProvider\"\n\t\t\t\t\t\t:reason=\"unavailableReason(singleProvider)\" />\n\t\t\t\t\t<CnLeafMountHost\n\t\t\t\t\t\tv-else-if=\"isMountLeaf(singleProvider)\"\n\t\t\t\t\t\t:provider=\"singleProvider\"\n\t\t\t\t\t\t:mountProps=\"leafMountProps(singleProvider)\" />\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"leafComponent(singleProvider)\"\n\t\t\t\t\t\tv-else\n\t\t\t\t\t\tv-bind=\"leafProps(singleProvider)\" />\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t\t<!-- The leaf is not registered: its app is absent or did not load. -->\n\t\t\t<CnIntegrationWidgetEmpty\n\t\t\t\tv-else-if=\"appName\"\n\t\t\t\t:provider=\"absentProvider\"\n\t\t\t\treason=\"missing-app\"\n\t\t\t\tdata-testid=\"cn-integration-widget-absent\" />\n\t\t\t<NcEmptyContent v-else :name=\"unknownLeafLabel\" />\n\t\t</template>\n\n\t\t<!-- TABBED MODE: tab strip + active panel. -->\n\t\t<template v-else>\n\t\t\t<NcEmptyContent\n\t\t\t\tv-if=\"visibleProviders.length === 0\"\n\t\t\t\t:name=\"noIntegrationsLabel\" />\n\t\t\t<template v-else>\n\t\t\t\t<div class=\"cn-integration-widget__tabbar\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"cn-integration-widget__tabs\"\n\t\t\t\t\t\trole=\"tablist\"\n\t\t\t\t\t\t:aria-label=\"tabListLabel\">\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\tv-for=\"(provider, idx) in visibleProviders\"\n\t\t\t\t\t\t\t:id=\"tabId(provider)\"\n\t\t\t\t\t\t\t:key=\"provider.id\"\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\trole=\"tab\"\n\t\t\t\t\t\t\tclass=\"cn-integration-widget__tab\"\n\t\t\t\t\t\t\t:class=\"{ 'cn-integration-widget__tab--active': provider.id === activeId }\"\n\t\t\t\t\t\t\t:style=\"tabAccentStyle(provider)\"\n\t\t\t\t\t\t\t:aria-selected=\"provider.id === activeId ? 'true' : 'false'\"\n\t\t\t\t\t\t\t:tabindex=\"provider.id === activeId ? 0 : -1\"\n\t\t\t\t\t\t\t:aria-controls=\"panelId(provider)\"\n\t\t\t\t\t\t\t:data-testid=\"`cn-integration-widget-tab-${provider.id}`\"\n\t\t\t\t\t\t\t@click=\"selectTab(provider.id)\"\n\t\t\t\t\t\t\t@keydown=\"onTabKeydown($event, idx)\">\n\t\t\t\t\t\t\t<CnIcon\n\t\t\t\t\t\t\t\tv-if=\"provider.icon\"\n\t\t\t\t\t\t\t\t:name=\"provider.icon\"\n\t\t\t\t\t\t\t\t:size=\"18\"\n\t\t\t\t\t\t\t\tclass=\"cn-integration-widget__tab-icon\" />\n\t\t\t\t\t\t\t<span class=\"cn-integration-widget__tab-label\">{{ provider.label }}</span>\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</div>\n\t\t\t\t\t<div class=\"cn-integration-widget__header-actions\">\n\t\t\t\t\t\t<CnActionsMenu\n\t\t\t\t\t\t\t:documentationUrl=\"documentationUrl\"\n\t\t\t\t\t\t\t:widgetId=\"resolvedWidgetId\"\n\t\t\t\t\t\t\t:title=\"headerTitle\"\n\t\t\t\t\t\t\t:surface=\"`widget:${resolvedWidgetId}`\"\n\t\t\t\t\t\t\ttestidBase=\"cn-integration-widget\" />\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div\n\t\t\t\t\tv-if=\"activeProvider\"\n\t\t\t\t\t:id=\"panelId(activeProvider)\"\n\t\t\t\t\tclass=\"cn-integration-widget__panel\"\n\t\t\t\t\trole=\"tabpanel\"\n\t\t\t\t\t:aria-labelledby=\"tabId(activeProvider)\"\n\t\t\t\t\ttabindex=\"0\">\n\t\t\t\t\t<CnIntegrationWidgetEmpty\n\t\t\t\t\t\tv-if=\"!isAvailable(activeProvider)\"\n\t\t\t\t\t\t:provider=\"activeProvider\"\n\t\t\t\t\t\t:reason=\"unavailableReason(activeProvider)\" />\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"leafComponent(activeProvider)\"\n\t\t\t\t\t\tv-else\n\t\t\t\t\t\tv-bind=\"leafProps(activeProvider)\" />\n\t\t\t\t</div>\n\t\t\t</template>\n\t\t</template>\n\t</div>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcEmptyContent } from '@nextcloud/vue'\nimport CnIcon from '../CnIcon/CnIcon.vue'\nimport CnLeafMountHost from '../CnLeafMountHost/CnLeafMountHost.vue'\nimport CnIntegrationWidgetEmpty from './CnIntegrationWidgetEmpty.vue'\nimport { useIntegrationRegistry } from '../../composables/useIntegrationRegistry.js'\nimport { registerIntegrationIcons } from '../../integrations/icons.js'\nimport { isAppInstalled } from '../../utils/appInstalled.js'\nimport { CnActionsMenu } from '../CnActionsMenu/index.js'\nimport { resolveProviderAvailability } from './availability.js'\n\n// Ensure every descriptor's MDI icon resolves through CnIcon regardless\n// of what the host app registered at bootstrap — otherwise tabs fall\n// back to the generic HelpCircleOutline glyph. Idempotent.\nregisterIntegrationIcons()\n\nconst VALID_SURFACES = ['user-dashboard', 'app-dashboard', 'detail-page', 'single-entity']\n\n/**\n * CnIntegrationWidget — tabbed integration widget + single-integration\n * mode. See the file-level docblock for the contract.\n */\nexport default {\n\tname: 'CnIntegrationWidget',\n\n\tcomponents: { NcEmptyContent, CnIcon, CnIntegrationWidgetEmpty, CnActionsMenu, CnLeafMountHost },\n\n\tprops: {\n\t\t/**\n\t\t * Optional registry override (used in tests so registrations\n\t\t * don't leak across cases). Defaults to the singleton.\n\t\t */\n\t\tregistry: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * OpenRegister register id — a slug/uuid string, or the register\n\t\t * OBJECT a detail page injects. Accepts both for the same reason\n\t\t * `schema` does; see below.\n\t\t */\n\t\tregister: { type: [String, Object], default: '' },\n\t\t/**\n\t\t * OpenRegister schema — a slug/uuid string, or the schema OBJECT the\n\t\t * detail-page widget grid injects.\n\t\t *\n\t\t * This used to be `type: String`, and a detail page handed it the\n\t\t * resolved object instead. Vue reported the mismatch as a dev-only\n\t\t * warning (\"Expected String with value \\\"[object Object]\\\", got\n\t\t * Object\") and then passed the object through unchanged, so the leaf\n\t\t * built `/apps/openregister/api/schemas/[object Object]` and got a 404\n\t\t * — an unreadable URL rather than a named failure, and silent in\n\t\t * production where the warning is compiled out.\n\t\t *\n\t\t * `CnAuditTrailWidget` already accepted both shapes and collapsed the\n\t\t * object to its slug; this mirrors that so the two widgets cannot\n\t\t * disagree about what a detail page may hand them.\n\t\t */\n\t\tschema: { type: [String, Object], default: '' },\n\t\t/** Parent object id. Forwarded to each leaf. */\n\t\tobjectId: { type: [String, Number], default: '' },\n\t\t/**\n\t\t * Single-integration mode: render ONLY this leaf id with no tab\n\t\t * strip. The manifest multi-placement entry point (ADR-024).\n\t\t */\n\t\tonly: { type: String, default: '' },\n\t\t/**\n\t\t * Rendering surface — filters integrations by `surfaces.includes()`\n\t\t * when a descriptor declares a `surfaces` allowlist.\n\t\t */\n\t\tsurface: {\n\t\t\ttype: String,\n\t\t\tdefault: 'detail-page',\n\t\t\tvalidator: (s) => VALID_SURFACES.includes(s),\n\t\t},\n\n\t\t/**\n\t\t * Explicit allowlist of leaf ids to show as tabs. When omitted,\n\t\t * all registered integrations eligible for `surface` are shown.\n\t\t *\n\t\t * @type {string[]|null}\n\t\t */\n\t\tinclude: { type: Array, default: null },\n\t\t/** Base API URL forwarded to each leaf content component. */\n\t\tapiBase: { type: String, default: '/apps/openregister/api' },\n\t\t/** Optional object type forwarded to each leaf content component. */\n\t\tobjectType: { type: String, default: '' },\n\t\t/**\n\t\t * Documentation link target for the header's shared Actions menu.\n\t\t * When non-empty a \"Documentation\" item opens it in a new tab;\n\t\t * empty (the default) hides the item.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tdocumentationUrl: { type: String, default: '' },\n\t\t/**\n\t\t * Stable id carried on the Actions-menu refresh / request-feature\n\t\t * payloads and surface string. When empty the header title is\n\t\t * slugified as a fallback (see `resolvedWidgetId`).\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\twidgetId: { type: String, default: '' },\n\n\t\t/**\n\t\t * The record's display name, handed to the leaf as `objectLabel` (a\n\t\t * leaf titles its panel or its dialog with it).\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tobjectLabel: { type: String, default: '' },\n\n\t\t/**\n\t\t * Extra props for the leaf, set by the placement (a manifest widget's\n\t\t * `props.placementProps`), for example the kinds a finance leaf shows. The\n\t\t * record's own identity always wins over a key of the same name.\n\t\t *\n\t\t * @type {object}\n\t\t */\n\t\tplacementProps: { type: Object, default: () => ({}) },\n\n\t\t/**\n\t\t * The surface the leaf itself is told it renders on, when it differs\n\t\t * from `surface` (a leaf placed in a dialog that draws its sidebar-tab\n\t\t * look: `sidebar-tab`). Empty passes `surface`.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tleafSurface: { type: String, default: '' },\n\n\t\t/**\n\t\t * The app that provides the `only` leaf, named in the empty state when\n\t\t * the leaf is not registered (\"Decidiq not available\"). Without it an\n\t\t * unregistered leaf shows the plain \"not available\" line.\n\t\t *\n\t\t * @type {string}\n\t\t */\n\t\tappName: { type: String, default: '' },\n\n\t\t/** The icon of that empty state (an MDI name). */\n\t\tappIcon: { type: String, default: '' },\n\t},\n\n\tsetup(props) {\n\t\tconst { integrations, resolveTab } = useIntegrationRegistry(props.registry || undefined)\n\t\t// `resolveTab` swaps a lib-owned entry for THIS bundle's component\n\t\t// (openregister#1958); the entry's own `tab` may belong to another\n\t\t// bundle's Vue and then renders its nested components as literal\n\t\t// unknown elements. See `leafComponent`.\n\t\treturn { registryIntegrations: integrations, resolveRegistryTab: resolveTab }\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\t/** Currently selected tab id (tabbed mode only). */\n\t\t\tactiveId: '',\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/** 'single' when `only` is set, else 'tabbed'. */\n\t\tmode() {\n\t\t\treturn this.only ? 'single' : 'tabbed'\n\t\t},\n\n\t\t/**\n\t\t * The register SLUG. A register object collapses to its slug/name/id,\n\t\t * mirroring `CnAuditTrailWidget.resolvedRegister`.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedRegister() {\n\t\t\tconst r = this.register || ''\n\t\t\treturn typeof r === 'string' ? r : (r && (r.slug || r.name || r.id)) || ''\n\t\t},\n\n\t\t/**\n\t\t * The schema SLUG. A schema object collapses to its slug/name/id.\n\t\t *\n\t\t * Leaves interpolate this straight into an OpenRegister URL, so an\n\t\t * object reaching them produces `/api/schemas/[object Object]` and a\n\t\t * 404 that names nothing. Collapsing here means every leaf receives\n\t\t * the string it already documents, and a caller that hands over the\n\t\t * whole schema object — which the detail-page widget grid does — is\n\t\t * served rather than silently broken.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedSchema() {\n\t\t\tconst s = this.schema || ''\n\t\t\treturn typeof s === 'string' ? s : (s && (s.slug || s.name || s.id)) || ''\n\t\t},\n\n\t\t/**\n\t\t * The provider for single mode (resolved from `only`), or null\n\t\t * when the id is unknown / not eligible for the surface.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tsingleProvider() {\n\t\t\tif (!this.only) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst all = this.registryIntegrations || []\n\t\t\tfor (const provider of all) {\n\t\t\t\tif (provider.id === this.only && this.eligibleForSurface(provider)) {\n\t\t\t\t\treturn provider\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * Integrations shown as tabs: eligible for the surface, narrowed\n\t\t * by `include` when set, sorted by the registry's `order` (the\n\t\t * snapshot is already order-sorted, so we preserve that).\n\t\t *\n\t\t * @return {object[]}\n\t\t */\n\t\tvisibleProviders() {\n\t\t\tconst include = Array.isArray(this.include) ? this.include : null\n\t\t\treturn (this.registryIntegrations || []).filter((provider) => {\n\t\t\t\tif (!this.eligibleForSurface(provider)) {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\tif (include !== null && !include.includes(provider.id)) {\n\t\t\t\t\treturn false\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The currently active provider in tabbed mode.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tactiveProvider() {\n\t\t\tconst list = this.visibleProviders\n\t\t\tfor (const provider of list) {\n\t\t\t\tif (provider.id === this.activeId) {\n\t\t\t\t\treturn provider\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn list.length > 0 ? list[0] : null\n\t\t},\n\n\t\tnoIntegrationsLabel() {\n\t\t\treturn t('nextcloud-vue', 'No integrations available yet.')\n\t\t},\n\n\t\t/**\n\t\t * The stand-in descriptor the empty state draws for an `only` leaf\n\t\t * that is not registered.\n\t\t *\n\t\t * @return {{id: string, appName: string, icon: string}}\n\t\t * @spec openspec/changes/integration-widget-mounts-leaves/specs/integration-widget-mounts-leaves/spec.md#requirement-a-single-integration-widget-mounts-a-mount-mode-leaf\n\t\t */\n\t\tabsentProvider() {\n\t\t\treturn { id: this.only, appName: this.appName, icon: this.appIcon }\n\t\t},\n\n\t\tunknownLeafLabel() {\n\t\t\treturn t('nextcloud-vue', 'This integration is not available.')\n\t\t},\n\n\t\ttabListLabel() {\n\t\t\treturn t('nextcloud-vue', 'Integrations')\n\t\t},\n\n\t\t/**\n\t\t * Label shown by the active header — the single-mode provider's\n\t\t * label, else the active tab's label, else the generic tab-list\n\t\t * label. Fed to the shared Actions menu as its `title`.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\theaderTitle() {\n\t\t\tif (this.mode === 'single') {\n\t\t\t\treturn this.singleProvider ? this.singleProvider.label : this.unknownLeafLabel\n\t\t\t}\n\t\t\treturn this.activeProvider ? this.activeProvider.label : this.tabListLabel\n\t\t},\n\n\t\t/**\n\t\t * Stable widget id for the Actions menu: the explicit `widgetId`\n\t\t * prop when set, else a slugified `headerTitle` (lowercase,\n\t\t * non-alphanumerics → '-'), falling back to 'integration'.\n\t\t *\n\t\t * @return {string}\n\t\t */\n\t\tresolvedWidgetId() {\n\t\t\tif (this.widgetId) {\n\t\t\t\treturn this.widgetId\n\t\t\t}\n\t\t\tconst slug = String(this.headerTitle || '')\n\t\t\t\t.toLowerCase()\n\t\t\t\t.replace(/[^a-z0-9]+/g, '-')\n\t\t\t\t.replace(/^-+|-+$/g, '')\n\t\t\treturn slug || 'integration'\n\t\t},\n\t},\n\n\twatch: {\n\t\t/**\n\t\t * Keep the active tab valid when the provider set changes (late\n\t\t * registration, surface/include change). Re-anchor to the first\n\t\t * provider if the current active id disappears.\n\t\t */\n\t\tvisibleProviders: {\n\t\t\timmediate: true,\n\t\t\thandler(list) {\n\t\t\t\tif (this.mode !== 'tabbed') {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconst ids = (list || []).map((p) => p.id)\n\t\t\t\tif (ids.length === 0) {\n\t\t\t\t\tthis.activeId = ''\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif (!ids.includes(this.activeId)) {\n\t\t\t\t\tthis.activeId = ids[0]\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t},\n\n\tmethods: {\n\t\tt,\n\n\t\t/**\n\t\t * Whether a provider is eligible for the current surface. A\n\t\t * descriptor with a `surfaces` allowlist must include it; without\n\t\t * one, every provider is eligible (matches CnIntegrationWidgetGrid).\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {boolean}\n\t\t */\n\t\teligibleForSurface(provider) {\n\t\t\tconst surfaces = provider.surfaces\n\t\t\tif (Array.isArray(surfaces) && surfaces.length > 0) {\n\t\t\t\treturn surfaces.includes(this.surface)\n\t\t\t}\n\t\t\treturn true\n\t\t},\n\n\t\t/**\n\t\t * Resolve whether the backing app for a provider is available +\n\t\t * configured. See file docblock for the source order.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {boolean}\n\t\t */\n\t\tisAvailable(provider) {\n\t\t\treturn resolveProviderAvailability(provider, { isAppInstalled }).available\n\t\t},\n\n\t\t/**\n\t\t * The unavailable reason ('missing-app' | 'not-configured' |\n\t\t * 'unknown') for empty-state copy.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {string}\n\t\t */\n\t\tunavailableReason(provider) {\n\t\t\treturn resolveProviderAvailability(provider, { isAppInstalled }).reason\n\t\t},\n\n\t\t/**\n\t\t * The content component for a leaf panel. Resolved through the\n\t\t * registry composable so a lib-owned id renders THIS bundle's copy\n\t\t * of its `tab`; the entry's stored object is only used for a\n\t\t * consumer-custom id, which lives in the consumer's own bundle.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {object|null} Vue component, or null.\n\t\t */\n\t\tleafComponent(provider) {\n\t\t\tif (!provider || typeof provider.id !== 'string') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst local = typeof this.resolveRegistryTab === 'function'\n\t\t\t\t? this.resolveRegistryTab(provider.id)\n\t\t\t\t: null\n\t\t\treturn local || provider.tab || null\n\t\t},\n\n\t\t/**\n\t\t * Props forwarded to a leaf's content component. Matches the\n\t\t * shared shape consumed by the bespoke `Cn<X>Tab` components.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {object}\n\t\t */\n\t\tleafProps(provider) {\n\t\t\treturn {\n\t\t\t\t...this.placementProps,\n\t\t\t\tintegrationId: provider.id,\n\t\t\t\tregister: this.resolvedRegister,\n\t\t\t\tschema: this.resolvedSchema,\n\t\t\t\tobjectId: this.objectId !== '' && this.objectId !== null && this.objectId !== undefined\n\t\t\t\t\t? String(this.objectId)\n\t\t\t\t\t: '',\n\n\t\t\t\tobjectType: this.objectType,\n\t\t\t\tapiBase: this.apiBase,\n\t\t\t\tsurface: this.leafSurface || this.surface,\n\t\t\t\t...(this.objectLabel ? { objectLabel: this.objectLabel } : {}),\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Whether a leaf is a mount-mode leaf (ADR-066): it mounts its own\n\t\t * framework into a host element instead of being a component.\n\t\t *\n\t\t * @param {object} provider The descriptor.\n\t\t * @return {boolean}\n\t\t * @spec openspec/changes/integration-widget-mounts-leaves/specs/integration-widget-mounts-leaves/spec.md#requirement-a-single-integration-widget-mounts-a-mount-mode-leaf\n\t\t */\n\t\tisMountLeaf(provider) {\n\t\t\treturn Boolean(provider)\n\t\t\t\t&& provider.renderMode === 'mount'\n\t\t\t\t&& typeof provider.mount === 'function'\n\t\t\t\t&& typeof provider.unmount === 'function'\n\t\t},\n\n\t\t/**\n\t\t * What a mount-mode leaf is mounted with: the record context, also as\n\t\t * `integrationContext`, and the record's label.\n\t\t *\n\t\t * @param {object} provider The descriptor.\n\t\t * @return {object} The mount props.\n\t\t */\n\t\tleafMountProps(provider) {\n\t\t\tconst props = this.leafProps(provider)\n\t\t\treturn {\n\t\t\t\t...props,\n\t\t\t\tintegrationContext: { register: props.register, schema: props.schema, objectId: props.objectId },\n\t\t\t}\n\t\t},\n\n\t\tselectTab(id) {\n\t\t\tthis.activeId = id\n\t\t},\n\n\t\t/**\n\t\t * Roving-tabindex keyboard nav per the ARIA tablist pattern:\n\t\t * Left/Right move + activate, Home/End jump to the ends.\n\t\t *\n\t\t * @param {KeyboardEvent} event Key event.\n\t\t * @param {number} index Index of the focused tab.\n\t\t * @return {void}\n\t\t */\n\t\tonTabKeydown(event, index) {\n\t\t\tconst list = this.visibleProviders\n\t\t\tif (list.length === 0) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tlet next = -1\n\t\t\tif (event.key === 'ArrowRight' || event.key === 'ArrowDown') {\n\t\t\t\tnext = (index + 1) % list.length\n\t\t\t} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {\n\t\t\t\tnext = (index - 1 + list.length) % list.length\n\t\t\t} else if (event.key === 'Home') {\n\t\t\t\tnext = 0\n\t\t\t} else if (event.key === 'End') {\n\t\t\t\tnext = list.length - 1\n\t\t\t}\n\t\t\tif (next === -1) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tevent.preventDefault()\n\t\t\tconst provider = list[next]\n\t\t\tthis.activeId = provider.id\n\t\t\tthis.$nextTick(() => {\n\t\t\t\tconst el = this.$el && this.$el.querySelector(`#${this.tabId(provider)}`)\n\t\t\t\tif (el && typeof el.focus === 'function') {\n\t\t\t\t\tel.focus()\n\t\t\t\t}\n\t\t\t})\n\t\t},\n\n\t\ttabId(provider) {\n\t\t\treturn `cn-iw-tab-${provider.id}`\n\t\t},\n\n\t\tpanelId(provider) {\n\t\t\treturn `cn-iw-panel-${provider.id}`\n\t\t},\n\n\t\t/**\n\t\t * Accent tint for the active tab — a left border + subtle bg in\n\t\t * the app's brand colour. Inactive tabs get no accent var.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {object} Style object.\n\t\t */\n\t\ttabAccentStyle(provider) {\n\t\t\tif (provider.id !== this.activeId || !provider.accentColor) {\n\t\t\t\treturn {}\n\t\t\t}\n\t\t\treturn { '--cn-iw-accent': provider.accentColor }\n\t\t},\n\n\t\t/**\n\t\t * Accent tint for the single-mode header.\n\t\t *\n\t\t * @param {object} provider Normalised registry entry.\n\t\t * @return {object} Style object.\n\t\t */\n\t\taccentStyle(provider) {\n\t\t\tif (!provider.accentColor) {\n\t\t\t\treturn {}\n\t\t\t}\n\t\t\treturn { '--cn-iw-accent': provider.accentColor }\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-integration-widget {\n\twidth: 100%;\n\tdisplay: flex;\n\tflex-direction: column;\n\tmin-height: 0;\n}\n\n.cn-integration-widget__tabbar {\n\tdisplay: flex;\n\talign-items: flex-end;\n\tgap: 8px;\n\tborder-bottom: 1px solid var(--color-border);\n\tmargin-bottom: 12px;\n}\n\n.cn-integration-widget__tabs {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\tgap: 2px;\n\tflex: 1;\n\tmin-width: 0;\n}\n\n.cn-integration-widget__tab {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 8px 12px;\n\tbackground: transparent;\n\tborder: none;\n\tborder-bottom: 2px solid transparent;\n\tborder-radius: var(--border-radius) var(--border-radius) 0 0;\n\tcolor: var(--color-text-maxcontrast);\n\tfont-size: 0.95em;\n\tcursor: pointer;\n}\n\n.cn-integration-widget__tab:hover {\n\tbackground: var(--color-background-hover);\n\tcolor: var(--color-main-text);\n}\n\n.cn-integration-widget__tab:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: -2px;\n}\n\n.cn-integration-widget__tab--active {\n\tcolor: var(--color-main-text);\n\tfont-weight: 600;\n\tborder-bottom-color: var(--cn-iw-accent, var(--color-primary-element));\n}\n\n.cn-integration-widget__tab-icon {\n\tcolor: var(--cn-iw-accent, currentColor);\n\tflex-shrink: 0;\n}\n\n.cn-integration-widget__tab-label {\n\twhite-space: nowrap;\n}\n\n.cn-integration-widget__header {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 8px;\n\tpadding: 8px 4px;\n\tmargin-bottom: 8px;\n\tborder-bottom: 2px solid var(--cn-iw-accent, var(--color-border));\n}\n\n.cn-integration-widget__header-icon {\n\tcolor: var(--cn-iw-accent, var(--color-main-text));\n\tflex-shrink: 0;\n}\n\n.cn-integration-widget__header-label {\n\tfont-weight: 600;\n\tcolor: var(--color-main-text);\n}\n\n.cn-integration-widget__header-actions {\n\tmargin-inline-start: auto;\n\tflex-shrink: 0;\n}\n\n.cn-integration-widget__panel {\n\tflex: 1;\n\tmin-height: 0;\n}\n\n.cn-integration-widget__panel:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: -2px;\n}\n</style>\n"],"names":["_createElementBlock","_createBlock","_createElementVNode","_toDisplayString","_createVNode"],"mappings":";;;;;AA0KS,EAAA,KAAA,EAAA,+BAAA;;AAOE,MAAA,UAAA,GAAA,EAAA,KAAK,EAAC,qCAA2B,GAAA;AAClC,MAAA,UAAA,GAAA,EAAA,KAAK,EAAC,uCAAC,GAAA;AASR,MAAA,UAAA,GAAA,EAAA,KAAA,EAAA,8BAAA,GAAA;;;;;;;;;;;;;;;uBApBFA;AAAA,IAAA,KAAI,EAAC,uBAAuB;AAAE,IAAA,cAAQ,EAAA,MAAA,CAAA,OAAA;AAAA,IAAA,WAAA,EAAA,QAAA,CAAA,IAAA;;KAEnC,QAAA,CAAA,IAAA,KAAA,QAAA;;WACD,QAAA,CAAA,cAAA;;kCAgBI,CAAA,QAAA,EAAA;;;;AAbD,kBAAA,CAAA,QAAA,CAAA,cAAa,CAAA,IAAA;oCADpBC,WAIc,CAAA,iBAAA,EAAA;;wBAFZ,IAAI,EAAE,QAAc,CAAA,cAAA,CAAC,IAAI;AACzB,wBAAA,IAAI,EAAE,EAAE;AACT,wBAAA,KAAK,EAAC,oCAAO;;;AACd,kBAAAC,kBAAA,CAAuC,MAAvC,EAAA,UAAuC,EAAAC,eAAA,CAAA,QAAA,CAAA,cAAA,CAAA,KAAA,CAAA,EAAA,CAAA,YAAA;AACvC,kBAAAD,kBAAA,CAOM,OAPN,UAOM,EAAA;oBANLE,WAKkC,CAAA,wBAAA,EAAA;AAJhC,sBAAA,gBAAgB,EAAE,MAAc,CAAA,gBAAA;AAChC,sBAAA,QAAQ,EAAE,QAAa,CAAA,gBAAA;sBACvB,KAAK,EAAE,QAAc,CAAA,cAAA,CAAC,KAAK;AAC3B,sBAAA,OAAM,YAAA,QAAA,CAAA,gBAAA,CAAA,CAAA;AACP,sBAAA,UAAU,EAAC,uBAAsB;;;;AAGpC,gBAAAF,kBAAA,QAAA;AAEQ,kBAAA,CAAA,CAAA,QAAA,CAAA,WAAA,CAAA,QAAA,CAAA,cAAA,CAAA;oCADPD;;AAEC,wBAAA,QAAA,EAAA,QAAA,CAAA,cAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -2,7 +2,7 @@ import script from './CnObjectPartiesWidget.vue2.js';
2
2
  import { render } from './CnObjectPartiesWidget.vue3.js';
3
3
 
4
4
  script.render = render;
5
- script.__scopeId = "data-v-196e7ade";
5
+ script.__scopeId = "data-v-0c4e5c0c";
6
6
  const __sfc_main = script;
7
7
 
8
8
  export { __sfc_main as default };
@@ -1,15 +1,20 @@
1
1
  import axios__default from '@nextcloud/axios';
2
- import { translatePlural, translate } from '@nextcloud/l10n';
2
+ import { subscribe, unsubscribe, emit } from '@nextcloud/event-bus';
3
+ import { translate, translatePlural } from '@nextcloud/l10n';
3
4
  import { generateUrl } from '@nextcloud/router';
4
- import { NcEmptyContent, NcLoadingIcon, NcNoteCard } from '@nextcloud/vue';
5
+ import { NcButton, NcEmptyContent, NcLoadingIcon, NcNoteCard } from '@nextcloud/vue';
5
6
  import EmailOutline from 'vue-material-design-icons/EmailOutline.vue';
6
- import '@nextcloud/event-bus';
7
+ import Plus from 'vue-material-design-icons/Plus.vue';
8
+ import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue';
9
+ import __sfc_main from '../../dialogs/CnConfirmDialog.vue.js';
10
+ import __sfc_main$1 from '../../dialogs/CnPartyAddDialog.vue.js';
7
11
  import 'vue';
8
12
  import { interpolateUrlTokens } from '../../utils/interpolateUrlTokens.js';
9
13
  import innerHeading from '../../mixins/innerHeading.js';
10
14
  import { conditionalLine } from '../../utils/conditionalLine.js';
11
15
  import { initialsOf } from '../../utils/initials.js';
12
- import { rolesInOrder, roleLabel, indicatorsOf, indicatorVerdict, kindCode, representedByMap, documentsOfParty } from '../../utils/objectParties.js';
16
+ import { rolesInOrder, kindCode, roleLabel, indicatorsOf, indicatorVerdict, newPartyRecord, savedUuid, representedByMap, documentsOfParty } from '../../utils/objectParties.js';
17
+ import { fetchCallerScope } from '../../utils/objectPermissions.js';
13
18
  import { readPath } from '../../utils/readPath.js';
14
19
 
15
20
  let uid = 0;
@@ -53,6 +58,16 @@ function defaultLabels() {
53
58
  * }
54
59
  * ```
55
60
  *
61
+ * `manage` lets a reader change the parties through OpenRegister's party
62
+ * API, which asks the record's update right on every write: `add` (`{ label?,
63
+ * dialogTitle?, kind?, sources, create?, primary?, confirmLabel? }`) opens a
64
+ * dialog to pick somebody known or describe somebody new (`create: {
65
+ * register, schema, label?, fields, compose? }`, see `newPartyRecord`), choose
66
+ * a role and optionally make them the primary party; `remove` (`{ title?,
67
+ * message?, confirmLabel? }`, `{name}` and `{role}` in the message) takes a
68
+ * party off in one role. Both are hidden when OpenRegister says the reader
69
+ * may not update the schema; a refusal is shown, never swallowed.
70
+ *
56
71
  * `presentation: "primary"` draws only the primary party, as a side card
57
72
  * does: initials, name and role label, its correspondence e-mail, and
58
73
  * `link` (`{ label, href? | tab? }`, `{total}` = the number of parties) at
@@ -66,7 +81,7 @@ function defaultLabels() {
66
81
  var script = {
67
82
  name: 'CnObjectPartiesWidget',
68
83
 
69
- components: { EmailOutline, NcEmptyContent, NcLoadingIcon, NcNoteCard },
84
+ components: { CnConfirmDialog: __sfc_main, CnPartyAddDialog: __sfc_main$1, EmailOutline, NcButton, NcEmptyContent, NcLoadingIcon, NcNoteCard, Plus, TrashCanOutline },
70
85
 
71
86
  mixins: [innerHeading],
72
87
 
@@ -85,7 +100,8 @@ var script = {
85
100
  * party, `{party}` filled in), `correspondence` (`{ endpoint, path?,
86
101
  * senderField?, recipientsField? }`), `labels`, `presentation`
87
102
  * (`"primary"`: only the primary party) and `link` (`{ label,
88
- * href? | tab? }`, the primary presentation's foot link).
103
+ * href? | tab? }`, the primary presentation's foot link) and
104
+ * `manage` (`{ add?, remove? }`, adding and removing parties).
89
105
  */
90
106
  content: {
91
107
  type: Object,
@@ -123,6 +139,10 @@ var script = {
123
139
  kinds: null,
124
140
  extra: {},
125
141
  documents: [],
142
+ mayUpdate: true,
143
+ adding: false,
144
+ removing: null,
145
+ manageError: '',
126
146
  }
127
147
  },
128
148
 
@@ -132,6 +152,58 @@ var script = {
132
152
  return rolesInOrder(this.listing, this.translatedMap(this.content?.roleLabels))
133
153
  },
134
154
 
155
+ /** @return {object} The `manage` declaration, or an empty one. */
156
+ manage() {
157
+ const manage = this.content?.manage;
158
+ return manage && typeof manage === 'object' ? manage : {}
159
+ },
160
+
161
+ /** @return {boolean} Whether the add button shows. */
162
+ canAdd() {
163
+ return Boolean(this.manage.add) && this.mayUpdate && !this.isPrimaryPresentation && !this.loading && !this.failed
164
+ },
165
+
166
+ /** @return {boolean} Whether each party offers its remove button. */
167
+ canRemove() {
168
+ return Boolean(this.manage.remove) && this.mayUpdate && !this.isPrimaryPresentation
169
+ },
170
+
171
+ /** @return {Array<{key: string, label: string}>} The roles a new party may hold, in the reader's words. */
172
+ addRoles() {
173
+ const declared = Array.isArray(this.listing?.roles) ? this.listing.roles : [];
174
+ const kind = this.manage.add?.kind;
175
+ const accepted = kind ? (this.listing?.kinds || []).find((entry) => kindCode(entry) === kind)?.roles : null;
176
+ const keys = Array.isArray(accepted) && accepted.length > 0 ? accepted : declared.map((role) => role?.key).filter(Boolean);
177
+ const own = this.translatedMap(this.content?.roleLabels);
178
+ return keys.map((key) => ({ key, label: own[key] || this.cnTranslate(roleLabel(key, declared, {})) }))
179
+ },
180
+
181
+ /** @return {Array<object>} The picker sources, their words translated by the picker. */
182
+ addSources() {
183
+ return Array.isArray(this.manage.add?.sources) ? this.manage.add.sources : []
184
+ },
185
+
186
+ /** @return {Array<object>} The new-person fields, labels translated. */
187
+ createFields() {
188
+ const fields = this.manage.add?.create?.fields;
189
+ return (Array.isArray(fields) ? fields : [])
190
+ .filter((field) => field && field.key)
191
+ .map((field) => ({ ...field, label: this.cnTranslate(field.label || field.key) }))
192
+ },
193
+
194
+ /** @return {string} The question the remove dialog asks. */
195
+ removeMessage() {
196
+ if (!this.removing) {
197
+ return ''
198
+ }
199
+ const name = this.nameOf(this.removing.party);
200
+ const role = this.removing.group.label;
201
+ const own = this.manage.remove?.message;
202
+ return own
203
+ ? this.cnTranslate(own).replaceAll('{name}', name).replaceAll('{role}', role)
204
+ : translate('nextcloud-vue', 'Remove {name} as {role}?', { name, role }, undefined, { escape: false })
205
+ },
206
+
135
207
  /** @return {boolean} Whether only the primary party is drawn. */
136
208
  isPrimaryPresentation() {
137
209
  return this.content?.presentation === 'primary'
@@ -237,7 +309,21 @@ var script = {
237
309
  },
238
310
  },
239
311
 
312
+ mounted() {
313
+ this.onPageRefresh = (payload) => {
314
+ if (payload?.source !== this.idPrefix) {
315
+ this.load();
316
+ }
317
+ };
318
+ subscribe('cn:page:refresh', this.onPageRefresh);
319
+ },
320
+
321
+ beforeUnmount() {
322
+ unsubscribe('cn:page:refresh', this.onPageRefresh);
323
+ },
324
+
240
325
  methods: {
326
+ t: translate,
241
327
  initialsOf,
242
328
  kindCode,
243
329
 
@@ -252,6 +338,115 @@ var script = {
252
338
  return own !== '' ? this.cnTranslate(own) : defaultLabels()[key]
253
339
  },
254
340
 
341
+ /**
342
+ * A word of the `manage` declaration, translated, else the default.
343
+ *
344
+ * @param {string} part `add` or `remove`.
345
+ * @param {string} key The word's key.
346
+ * @param {string} fallback The default.
347
+ * @return {string}
348
+ */
349
+ manageWord(part, key, fallback) {
350
+ const own = this.manage[part]?.[key];
351
+ return typeof own === 'string' && own !== '' ? this.cnTranslate(own) : fallback
352
+ },
353
+
354
+ /**
355
+ * The remove button's accessible name.
356
+ *
357
+ * @param {object} party The listing row.
358
+ * @param {object} group The role group.
359
+ * @return {string}
360
+ */
361
+ removeLabel(party, group) {
362
+ return translate('nextcloud-vue', 'Remove {name} as {role}', { name: this.nameOf(party), role: group.label }, undefined, { escape: false })
363
+ },
364
+
365
+ /**
366
+ * Why a write failed, in the reader's words.
367
+ *
368
+ * @param {unknown} error The axios error.
369
+ * @return {string}
370
+ */
371
+ refusalOf(error) {
372
+ if (error?.response?.status === 403) {
373
+ return translate('nextcloud-vue', 'You may not change who is on this record.')
374
+ }
375
+ const said = error?.response?.data?.error;
376
+ return typeof said === 'string' && said !== '' ? said : translate('nextcloud-vue', 'The change could not be saved. Nothing was changed.')
377
+ },
378
+
379
+ /** @return {string} The record's parties URL. */
380
+ partiesUrl() {
381
+ return this.urlOf(this.content?.rolesSource || '/apps/openregister/api/objects/@register/@schema/@objectId/parties')
382
+ },
383
+
384
+ /** @return {Promise<void>} Whether OpenRegister lets the reader update this schema; unknown counts as yes, the server decides. */
385
+ async loadMayUpdate() {
386
+ if (!this.manage.add && !this.manage.remove) {
387
+ return
388
+ }
389
+ const slug = (value) => (value && typeof value === 'object' ? String(value.slug || value.id || '') : String(value || ''));
390
+ const scope = await fetchCallerScope({ register: slug(this.register), schema: slug(this.schema) });
391
+ this.mayUpdate = !scope || !Array.isArray(scope.actions) || scope.actions.includes('update');
392
+ },
393
+
394
+ /**
395
+ * Create the person when they are new, then give them the role; a
396
+ * primary party is set through the primary route, which records the
397
+ * replacement.
398
+ *
399
+ * @param {{mode: string, partyUuid: string|null, values: object, role: string, primary: boolean}} choice The dialog's choice.
400
+ * @return {Promise<void>}
401
+ */
402
+ async addParty(choice) {
403
+ try {
404
+ let partyUuid = choice.partyUuid;
405
+ if (choice.mode === 'new') {
406
+ const create = this.manage.add?.create || {};
407
+ const { data } = await axios__default.post(
408
+ generateUrl(`/apps/openregister/api/objects/${encodeURIComponent(create.register || '')}/${encodeURIComponent(create.schema || '')}`),
409
+ newPartyRecord(create, choice.values),
410
+ );
411
+ partyUuid = savedUuid(data);
412
+ if (partyUuid === '') {
413
+ throw new Error(translate('nextcloud-vue', 'The new person was not saved.'))
414
+ }
415
+ }
416
+ if (choice.primary) {
417
+ await axios__default.put(generateUrl(`${this.partiesUrl()}/primary`), { partyUuid, role: choice.role });
418
+ } else {
419
+ await axios__default.post(generateUrl(this.partiesUrl()), { partyUuid, role: choice.role });
420
+ }
421
+ this.adding = false;
422
+ this.changed();
423
+ } catch (error) {
424
+ this.$refs.addDialog?.fail(error?.response ? this.refusalOf(error) : (error?.message || this.refusalOf(error)));
425
+ }
426
+ },
427
+
428
+ /** @return {Promise<void>} Take the chosen party off in the chosen role. */
429
+ async removeParty() {
430
+ const { party, group } = this.removing || {};
431
+ if (!party) {
432
+ return
433
+ }
434
+ try {
435
+ await axios__default.delete(generateUrl(`${this.partiesUrl()}/${encodeURIComponent(party.partyUuid)}`), { params: { role: group.key } });
436
+ this.removing = null;
437
+ this.changed();
438
+ } catch (error) {
439
+ this.$refs.removeDialog?.setResult({ error: this.refusalOf(error) });
440
+ }
441
+ },
442
+
443
+ /** @return {void} Read the parties again and tell the page (tab counts, the side card). */
444
+ changed() {
445
+ this.manageError = '';
446
+ this.load();
447
+ emit('cn:page:refresh', { source: this.idPrefix });
448
+ },
449
+
255
450
  /**
256
451
  * A map of manifest words, translated.
257
452
  *
@@ -321,6 +516,7 @@ var script = {
321
516
  return
322
517
  }
323
518
  this.listing = listing;
519
+ await this.loadMayUpdate();
324
520
  if (this.isPrimaryPresentation) {
325
521
  this.loading = false;
326
522
  return