@conduction/nextcloud-vue 2.62.0 → 2.63.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.
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue.js +10 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue.js.map +1 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue2.js +189 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue2.js.map +1 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue3.js +72 -0
- package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue3.js.map +1 -0
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +271 -32
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +99 -9
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +39 -18
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +53 -37
- package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +2 -2
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js +10 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js.map +1 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +293 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +189 -0
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -0
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +216 -8
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +118 -50
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +2 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
- package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js +3 -1
- package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js.map +1 -1
- package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js +1 -1
- package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +234 -9
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +96 -57
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +51 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +5 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/selfModeActions.js +3 -3
- package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +36 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js +121 -12
- package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js.map +1 -1
- package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js +3 -1
- package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +326 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +112 -26
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js +10 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +337 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js +102 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js +34 -0
- package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js.map +1 -0
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +131 -8
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js +40 -38
- package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +8 -6
- package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +19 -0
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js +1 -1
- package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
- package/dist/esm/composables/useDependencyCheck.js +65 -0
- package/dist/esm/composables/useDependencyCheck.js.map +1 -0
- package/dist/esm/composables/useWalkthrough.js +218 -6
- package/dist/esm/composables/useWalkthrough.js.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +43 -0
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +43 -0
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +449 -0
- package/dist/esm/store/useObjectStore.js +8 -0
- package/dist/esm/store/useObjectStore.js.map +1 -1
- package/dist/esm/utils/columnFilters.js +311 -0
- package/dist/esm/utils/columnFilters.js.map +1 -0
- package/dist/esm/utils/groupAutocomplete.js +123 -0
- package/dist/esm/utils/groupAutocomplete.js.map +1 -0
- package/dist/esm/utils/libraryWidgetKeys.js +2 -0
- package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
- package/dist/esm/utils/pickerOptions.js +198 -0
- package/dist/esm/utils/pickerOptions.js.map +1 -0
- package/dist/esm/utils/schema.js +83 -7
- package/dist/esm/utils/schema.js.map +1 -1
- package/dist/esm/utils/timelineEvents.js +174 -0
- package/dist/esm/utils/timelineEvents.js.map +1 -0
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/esm/utils/walkthroughSignals.js +88 -0
- package/dist/esm/utils/walkthroughSignals.js.map +1 -0
- package/dist/nextcloud-vue.cjs.js +9369 -5193
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +449 -0
- package/l10n/en.json +43 -0
- package/l10n/nl.json +43 -0
- package/package.json +1 -1
- package/src/components/CnAdminActionCard/CnAdminActionCard.vue +234 -0
- package/src/components/CnAdminActionCard/index.js +4 -0
- package/src/components/CnAppNav/CnAppNav.vue +271 -32
- package/src/components/CnAppRoot/CnAppRoot.vue +129 -15
- package/src/components/CnChoiceCards/CnChoiceCards.vue +56 -31
- package/src/components/CnDashboardPage/CnDashboardPage.vue +2 -2
- package/src/components/CnDataTable/CnColumnFilterPopover.vue +524 -0
- package/src/components/CnDataTable/CnDataTable.md +3 -0
- package/src/components/CnDataTable/CnDataTable.vue +260 -4
- package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +2 -2
- package/src/components/CnFieldHelper/CnFieldHelper.vue +4 -2
- package/src/components/CnFormDialog/CnFormDialog.vue +290 -31
- package/src/components/CnIndexPage/CnIndexPage.vue +55 -2
- package/src/components/CnIndexPage/selfModeActions.js +3 -3
- package/src/components/CnObjectListWidget/CnObjectListWidget.vue +39 -1
- package/src/components/CnResourceSelect/CnResourceSelect.vue +123 -12
- package/src/components/CnSetupWizard/CnSetupWizard.vue +392 -3
- package/src/components/CnTimelineWidget/CnTimelineWidget.vue +477 -0
- package/src/components/CnTimelineWidget/dashboardRegistration.js +32 -0
- package/src/components/CnTimelineWidget/index.js +4 -0
- package/src/components/CnWalkthrough/CnWalkthrough.vue +143 -10
- package/src/components/CnWidgetGrid/builtInWidgets.js +2 -0
- package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
- package/src/components/CnWizardDialog/CnWizardDialog.vue +20 -1
- package/src/components/index.js +2 -0
- package/src/composables/useDependencyCheck.js +61 -0
- package/src/composables/useWalkthrough.js +217 -5
- package/src/css/dashboard.css +49 -0
- package/src/css/detail-page.css +19 -0
- package/src/css/table.css +81 -0
- package/src/index.js +2 -0
- package/src/schemas/app-manifest-v2.schema.json +23 -1
- package/src/store/useObjectStore.js +8 -0
- package/src/utils/columnFilters.js +324 -0
- package/src/utils/groupAutocomplete.js +119 -0
- package/src/utils/libraryWidgetKeys.js +2 -0
- package/src/utils/pickerOptions.js +194 -0
- package/src/utils/schema.js +83 -7
- package/src/utils/timelineEvents.js +171 -0
- package/src/utils/validateManifestV2.compiled.js +2 -2
- package/src/utils/walkthroughSignals.js +94 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: EUPL-1.2 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl> -->
|
|
3
|
+
<template>
|
|
4
|
+
<CnConfigurationCard class="cn-admin-action-card" :title="title" data-testid="cn-admin-action-card">
|
|
5
|
+
<p v-if="description" class="cn-admin-action-card__description">
|
|
6
|
+
{{ description }}
|
|
7
|
+
</p>
|
|
8
|
+
<!-- @slot default Extra content between the explanation and the button. -->
|
|
9
|
+
<slot />
|
|
10
|
+
<div class="cn-admin-action-card__footer">
|
|
11
|
+
<NcButton
|
|
12
|
+
:variant="buttonVariant"
|
|
13
|
+
:disabled="running || disabled"
|
|
14
|
+
data-testid="cn-admin-action-card-run"
|
|
15
|
+
@click="run">
|
|
16
|
+
<template v-if="running" #icon>
|
|
17
|
+
<NcLoadingIcon :size="20" />
|
|
18
|
+
</template>
|
|
19
|
+
{{ running ? runningLabel : resolvedButtonLabel }}
|
|
20
|
+
</NcButton>
|
|
21
|
+
</div>
|
|
22
|
+
<!-- role=status so a screen reader hears the outcome without moving focus. -->
|
|
23
|
+
<div role="status" aria-live="polite">
|
|
24
|
+
<NcNoteCard
|
|
25
|
+
v-if="result"
|
|
26
|
+
:type="result.success ? 'success' : 'error'"
|
|
27
|
+
data-testid="cn-admin-action-card-result">
|
|
28
|
+
{{ result.message }}
|
|
29
|
+
</NcNoteCard>
|
|
30
|
+
</div>
|
|
31
|
+
</CnConfigurationCard>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<script>
|
|
35
|
+
import { translate as t } from '@nextcloud/l10n'
|
|
36
|
+
import { NcButton, NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
|
|
37
|
+
import CnConfigurationCard from '../CnConfigurationCard/CnConfigurationCard.vue'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* CnAdminActionCard — one maintenance action on an admin settings page.
|
|
41
|
+
*
|
|
42
|
+
* A card with a title, a short explanation and one button. The click POSTs
|
|
43
|
+
* an app action, the button spins while it runs, and the server's message
|
|
44
|
+
* shows below it as success or error. Use it for work an admin starts by
|
|
45
|
+
* hand, such as repairing the register or re-importing schemas. That work
|
|
46
|
+
* never belongs in the setup wizard.
|
|
47
|
+
*
|
|
48
|
+
* By default it posts to the app's setup action contract,
|
|
49
|
+
* `/apps/{appId}/api/setup/action/{action}`, the same endpoint the setup
|
|
50
|
+
* wizard uses. Pass `url` to post somewhere else.
|
|
51
|
+
*
|
|
52
|
+
* ```vue
|
|
53
|
+
* <CnAdminActionCard
|
|
54
|
+
* app-id="pipelinq"
|
|
55
|
+
* action="provision"
|
|
56
|
+
* :title="t('pipelinq', 'Repair the register')"
|
|
57
|
+
* :description="t('pipelinq', 'Creates missing schemas again. Your data stays as it is.')"
|
|
58
|
+
* :button-label="t('pipelinq', 'Repair')" />
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* @spec openspec/changes/setup-wizard-card-load-and-dependency-gate/specs/cn-setup-wizard/spec.md#requirement-an-admin-action-card-runs-one-server-action
|
|
62
|
+
*/
|
|
63
|
+
export default {
|
|
64
|
+
name: 'CnAdminActionCard',
|
|
65
|
+
|
|
66
|
+
components: {
|
|
67
|
+
CnConfigurationCard,
|
|
68
|
+
NcButton,
|
|
69
|
+
NcLoadingIcon,
|
|
70
|
+
NcNoteCard,
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
props: {
|
|
74
|
+
/** Card heading. Pass it translated. */
|
|
75
|
+
title: {
|
|
76
|
+
type: String,
|
|
77
|
+
required: true,
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/** What the action does, in one or two short sentences. Pass it translated. */
|
|
81
|
+
description: {
|
|
82
|
+
type: String,
|
|
83
|
+
default: '',
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/** The Nextcloud app id; builds the default action URL. */
|
|
87
|
+
appId: {
|
|
88
|
+
type: String,
|
|
89
|
+
default: '',
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/** The action id, posted to `/apps/{appId}/api/setup/action/{action}`. */
|
|
93
|
+
action: {
|
|
94
|
+
type: String,
|
|
95
|
+
default: '',
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* An app path to POST to instead of the setup action contract, e.g.
|
|
100
|
+
* `/apps/pipelinq/api/settings/repair`. Passed through `generateUrl`.
|
|
101
|
+
*/
|
|
102
|
+
url: {
|
|
103
|
+
type: String,
|
|
104
|
+
default: '',
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/** Request body to send with the POST. */
|
|
108
|
+
payload: {
|
|
109
|
+
type: Object,
|
|
110
|
+
default: () => ({}),
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/** Button label. Defaults to "Run". */
|
|
114
|
+
buttonLabel: {
|
|
115
|
+
type: String,
|
|
116
|
+
default: '',
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/** Label next to the spinner while the action runs. */
|
|
120
|
+
runningLabel: {
|
|
121
|
+
type: String,
|
|
122
|
+
default: () => t('nextcloud-vue', 'Running…'),
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/** NcButton variant for the action button. */
|
|
126
|
+
buttonVariant: {
|
|
127
|
+
type: String,
|
|
128
|
+
default: 'secondary',
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/** Disable the button, e.g. while a precondition is not met. */
|
|
132
|
+
disabled: {
|
|
133
|
+
type: Boolean,
|
|
134
|
+
default: false,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
emits: ['result'],
|
|
139
|
+
|
|
140
|
+
data() {
|
|
141
|
+
return {
|
|
142
|
+
running: false,
|
|
143
|
+
/** @type {{ success: boolean, message: string }|null} */
|
|
144
|
+
result: null,
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
computed: {
|
|
149
|
+
resolvedButtonLabel() {
|
|
150
|
+
return this.buttonLabel || t('nextcloud-vue', 'Run')
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The app path the button posts to: `url` when set, else the setup
|
|
155
|
+
* action contract. Empty when neither is configured.
|
|
156
|
+
*
|
|
157
|
+
* @return {string}
|
|
158
|
+
*/
|
|
159
|
+
actionPath() {
|
|
160
|
+
if (this.url) {
|
|
161
|
+
return this.url
|
|
162
|
+
}
|
|
163
|
+
if (this.appId && this.action) {
|
|
164
|
+
return `/apps/${this.appId}/api/setup/action/${this.action}`
|
|
165
|
+
}
|
|
166
|
+
return ''
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
methods: {
|
|
171
|
+
/**
|
|
172
|
+
* Run the action: POST, spin while it runs, show the result.
|
|
173
|
+
*
|
|
174
|
+
* @return {Promise<void>}
|
|
175
|
+
* @spec openspec/changes/setup-wizard-card-load-and-dependency-gate/specs/cn-setup-wizard/spec.md#requirement-an-admin-action-card-runs-one-server-action
|
|
176
|
+
*/
|
|
177
|
+
async run() {
|
|
178
|
+
if (this.running) {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
if (!this.actionPath) {
|
|
182
|
+
this.result = { success: false, message: t('nextcloud-vue', 'This action is not configured.') }
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
this.running = true
|
|
186
|
+
this.result = null
|
|
187
|
+
let data
|
|
188
|
+
try {
|
|
189
|
+
const [{ default: axios }, { generateUrl }] = await Promise.all([
|
|
190
|
+
import('@nextcloud/axios'),
|
|
191
|
+
import('@nextcloud/router'),
|
|
192
|
+
])
|
|
193
|
+
const response = await axios.post(generateUrl(this.actionPath), this.payload)
|
|
194
|
+
data = response && response.data
|
|
195
|
+
this.result = {
|
|
196
|
+
success: !!data && data.success !== false,
|
|
197
|
+
message: (data && data.message) || t('nextcloud-vue', 'Done.'),
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
data = err && err.response ? err.response.data : null
|
|
201
|
+
this.result = { success: false, message: this.errorMessage(err) }
|
|
202
|
+
} finally {
|
|
203
|
+
this.running = false
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* @event result The action finished.
|
|
207
|
+
* @type {{ success: boolean, message: string, data: object|null }}
|
|
208
|
+
*/
|
|
209
|
+
this.$emit('result', { ...this.result, data })
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
errorMessage(err) {
|
|
213
|
+
const data = err && err.response && err.response.data
|
|
214
|
+
if (data && (data.message || data.error)) {
|
|
215
|
+
return data.message || data.error
|
|
216
|
+
}
|
|
217
|
+
return (err && err.message) || t('nextcloud-vue', 'Something went wrong.')
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
</script>
|
|
222
|
+
|
|
223
|
+
<style scoped>
|
|
224
|
+
.cn-admin-action-card__description {
|
|
225
|
+
margin: 0 0 12px;
|
|
226
|
+
color: var(--color-text-maxcontrast);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.cn-admin-action-card__footer {
|
|
230
|
+
display: flex;
|
|
231
|
+
gap: 8px;
|
|
232
|
+
margin-block: 8px;
|
|
233
|
+
}
|
|
234
|
+
</style>
|
|
@@ -394,6 +394,44 @@ function byManifestOrder(a, b) {
|
|
|
394
394
|
return a.order - b.order
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
/** A `:name` page-path segment: name, optional `(regexp)`, optional modifier. */
|
|
398
|
+
const PARAM_SEGMENT = /^:(\w+)(\([^)]*\))?([?*+])?$/
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether a `PARAM_SEGMENT` match may be left out (`?` or `*`).
|
|
402
|
+
*
|
|
403
|
+
* @param {Array<string>} param The match.
|
|
404
|
+
* @return {boolean} True for an optional segment.
|
|
405
|
+
*/
|
|
406
|
+
function isOptionalParam(param) {
|
|
407
|
+
return param[3] === '?' || param[3] === '*'
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Whether a route param value fills its segment.
|
|
412
|
+
*
|
|
413
|
+
* @param {unknown} value The value.
|
|
414
|
+
* @return {boolean} False for undefined, null and the empty string.
|
|
415
|
+
*/
|
|
416
|
+
function isFilled(value) {
|
|
417
|
+
return value !== undefined && value !== null && value !== ''
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Decode one percent-encoded path segment, or keep it as is when it is not
|
|
422
|
+
* valid percent-encoding.
|
|
423
|
+
*
|
|
424
|
+
* @param {string} segment The segment.
|
|
425
|
+
* @return {string} The decoded segment.
|
|
426
|
+
*/
|
|
427
|
+
function decodeSegment(segment) {
|
|
428
|
+
try {
|
|
429
|
+
return decodeURIComponent(segment)
|
|
430
|
+
} catch {
|
|
431
|
+
return segment
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
397
435
|
export default {
|
|
398
436
|
name: 'CnAppNav',
|
|
399
437
|
|
|
@@ -924,7 +962,9 @@ export default {
|
|
|
924
962
|
* `/expenses/:id`) light up — and auto-expand — their index entry
|
|
925
963
|
* even though the detail route name is not in the menu. Longest
|
|
926
964
|
* prefix disambiguates nested namespaces (e.g. `/pos` vs
|
|
927
|
-
* `/pos/refunds`).
|
|
965
|
+
* `/pos/refunds`). An entry with `params` matches by its page path
|
|
966
|
+
* with those params filled in (see `pageMatchDepth`). Returns null
|
|
967
|
+
* when nothing matches.
|
|
928
968
|
*
|
|
929
969
|
* @return {string|null}
|
|
930
970
|
*/
|
|
@@ -945,20 +985,15 @@ export default {
|
|
|
945
985
|
return routeName ?? null
|
|
946
986
|
}
|
|
947
987
|
let best = null
|
|
948
|
-
let
|
|
988
|
+
let bestDepth = 0
|
|
949
989
|
for (const it of flat) {
|
|
950
990
|
if (!it.route) {
|
|
951
991
|
continue
|
|
952
992
|
}
|
|
953
|
-
const
|
|
954
|
-
if (
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
if (path === pagePath || path.startsWith(pagePath + '/')) {
|
|
958
|
-
if (pagePath.length > bestLen) {
|
|
959
|
-
best = it.route
|
|
960
|
-
bestLen = pagePath.length
|
|
961
|
-
}
|
|
993
|
+
const depth = this.pageMatchDepth(it, path)
|
|
994
|
+
if (depth > bestDepth) {
|
|
995
|
+
best = it.route
|
|
996
|
+
bestDepth = depth
|
|
962
997
|
}
|
|
963
998
|
}
|
|
964
999
|
return best ?? routeName ?? null
|
|
@@ -1007,7 +1042,9 @@ export default {
|
|
|
1007
1042
|
* that route, else the first in menu order.
|
|
1008
1043
|
*
|
|
1009
1044
|
* Null whenever the existing rule already marks an entry, so a list
|
|
1010
|
-
* with an entry without a `query` behaves as it did.
|
|
1045
|
+
* with an entry without a `query` behaves as it did. An entry with
|
|
1046
|
+
* `params` is a candidate only when the current path sits below its
|
|
1047
|
+
* own filled-in page path, so another param value is never marked.
|
|
1011
1048
|
*
|
|
1012
1049
|
* @return {object|null} The entry to mark, or null.
|
|
1013
1050
|
* @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-a-page-below-a-list-marks-one-menu-entry
|
|
@@ -1017,7 +1054,9 @@ export default {
|
|
|
1017
1054
|
if (!active || this.$route?.name === active) {
|
|
1018
1055
|
return null
|
|
1019
1056
|
}
|
|
1020
|
-
const
|
|
1057
|
+
const path = this.$route?.path
|
|
1058
|
+
const entries = this.flatEntries.filter((entry) => !entry.href && entry.route === active
|
|
1059
|
+
&& (!this.hasParams(entry) || this.pageMatchDepth(entry, path) > 0))
|
|
1021
1060
|
if (entries.length === 0 || entries.some((entry) => this.isActiveByRule(entry))) {
|
|
1022
1061
|
return null
|
|
1023
1062
|
}
|
|
@@ -1067,6 +1106,8 @@ export default {
|
|
|
1067
1106
|
// stays out of `data()`: a reactive Set mutated inside render would
|
|
1068
1107
|
// re-trigger the render effect.
|
|
1069
1108
|
this._autoCountWarned = new Set()
|
|
1109
|
+
// Same latch, for warnUnfilledRouteParam() (`itemTo()` runs during render).
|
|
1110
|
+
this._unfilledParamWarned = new Set()
|
|
1070
1111
|
},
|
|
1071
1112
|
|
|
1072
1113
|
methods: {
|
|
@@ -1275,7 +1316,19 @@ export default {
|
|
|
1275
1316
|
.sort(byManifestOrder)
|
|
1276
1317
|
},
|
|
1277
1318
|
|
|
1319
|
+
/**
|
|
1320
|
+
* The label an entry shows: run through the translate function, or
|
|
1321
|
+
* as written when the entry sets `translateLabel: false` (a
|
|
1322
|
+
* user-authored title that may equal a translation key). Every
|
|
1323
|
+
* place the nav shows an entry's label reads it from here.
|
|
1324
|
+
*
|
|
1325
|
+
* @param {object} item Menu entry.
|
|
1326
|
+
* @return {string} The label to render as text.
|
|
1327
|
+
*/
|
|
1278
1328
|
resolveLabel(item) {
|
|
1329
|
+
if (item.translateLabel === false) {
|
|
1330
|
+
return item.label
|
|
1331
|
+
}
|
|
1279
1332
|
return this.effectiveTranslate(item.label)
|
|
1280
1333
|
},
|
|
1281
1334
|
|
|
@@ -1286,7 +1339,10 @@ export default {
|
|
|
1286
1339
|
* work" and "Queue" on one list). The route name alone lit both. An
|
|
1287
1340
|
* entry with a `query` is active only when the address carries that
|
|
1288
1341
|
* query; an entry without one steps back when a sibling's query
|
|
1289
|
-
* matches, so exactly one of them is marked.
|
|
1342
|
+
* matches, so exactly one of them is marked. `params` work the
|
|
1343
|
+
* same way against the route's params, so of several entries on a
|
|
1344
|
+
* `/items/:slug` route only the one whose `slug` is in the address
|
|
1345
|
+
* is marked.
|
|
1290
1346
|
*
|
|
1291
1347
|
* On a page below a list where that rule marks nothing, the one
|
|
1292
1348
|
* entry `subRouteParent` picks is active instead.
|
|
@@ -1305,9 +1361,9 @@ export default {
|
|
|
1305
1361
|
},
|
|
1306
1362
|
|
|
1307
1363
|
/**
|
|
1308
|
-
* The route and
|
|
1309
|
-
* page below a list. `subRouteParent` asks this to see whether
|
|
1310
|
-
* rule already marks an entry.
|
|
1364
|
+
* The route, query and params rule on its own, without the fallback
|
|
1365
|
+
* for a page below a list. `subRouteParent` asks this to see whether
|
|
1366
|
+
* the rule already marks an entry.
|
|
1311
1367
|
*
|
|
1312
1368
|
* @param {object} item Menu entry.
|
|
1313
1369
|
* @return {boolean} True when the rule marks the entry.
|
|
@@ -1321,17 +1377,155 @@ export default {
|
|
|
1321
1377
|
if (item.route !== this.activeRouteName) {
|
|
1322
1378
|
return false
|
|
1323
1379
|
}
|
|
1324
|
-
if (
|
|
1325
|
-
return this.
|
|
1380
|
+
if (this.isNarrowed(item)) {
|
|
1381
|
+
return this.narrowingMatches(item)
|
|
1326
1382
|
}
|
|
1327
|
-
|
|
1328
|
-
for (const entry of this.visibleItems) {
|
|
1329
|
-
siblings.push(entry, ...this.visibleChildren(entry))
|
|
1330
|
-
}
|
|
1331
|
-
return !siblings.some((entry) => entry !== item
|
|
1383
|
+
return !this.flatEntries.some((entry) => entry !== item
|
|
1332
1384
|
&& entry.route === item.route
|
|
1333
|
-
&&
|
|
1334
|
-
&& this.
|
|
1385
|
+
&& this.isNarrowed(entry)
|
|
1386
|
+
&& this.narrowingMatches(entry))
|
|
1387
|
+
},
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Whether an entry narrows its route with a `query` or `params`.
|
|
1391
|
+
*
|
|
1392
|
+
* @param {object} item Menu entry.
|
|
1393
|
+
* @return {boolean} True when the entry declares either.
|
|
1394
|
+
*/
|
|
1395
|
+
isNarrowed(item) {
|
|
1396
|
+
return Boolean(item.query && typeof item.query === 'object') || this.hasParams(item)
|
|
1397
|
+
},
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* Whether the address carries every `query` key and every `params`
|
|
1401
|
+
* key the entry declares.
|
|
1402
|
+
*
|
|
1403
|
+
* @param {object} item Menu entry that narrows its route.
|
|
1404
|
+
* @return {boolean} True when everything it declares matches.
|
|
1405
|
+
*/
|
|
1406
|
+
narrowingMatches(item) {
|
|
1407
|
+
const queryOk = !(item.query && typeof item.query === 'object') || this.queryMatches(item)
|
|
1408
|
+
const paramsOk = !this.hasParams(item) || this.paramsMatch(item)
|
|
1409
|
+
return queryOk && paramsOk
|
|
1410
|
+
},
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Whether an entry declares a non-empty `params` object.
|
|
1414
|
+
*
|
|
1415
|
+
* @param {object} item Menu entry.
|
|
1416
|
+
* @return {boolean} True when it has route params.
|
|
1417
|
+
*/
|
|
1418
|
+
hasParams(item) {
|
|
1419
|
+
return Boolean(item?.params) && typeof item.params === 'object' && Object.keys(item.params).length > 0
|
|
1420
|
+
},
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Whether the route's params carry every key of an entry's
|
|
1424
|
+
* `params`. Values are compared as strings.
|
|
1425
|
+
*
|
|
1426
|
+
* @param {object} item Menu entry with `params`.
|
|
1427
|
+
* @return {boolean} True when every declared key matches.
|
|
1428
|
+
*/
|
|
1429
|
+
paramsMatch(item) {
|
|
1430
|
+
const current = this.$route?.params ?? {}
|
|
1431
|
+
return Object.keys(item.params).every((key) => String(current[key] ?? '') === String(item.params[key]))
|
|
1432
|
+
},
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* The segments of an entry's page path with its `params` filled in,
|
|
1436
|
+
* as plain values. A missing optional `:name?` segment is dropped;
|
|
1437
|
+
* null when a required one has no param, or without a page.
|
|
1438
|
+
*
|
|
1439
|
+
* @param {object} item Menu entry with `params`.
|
|
1440
|
+
* @return {Array<string>|null} The segments, or null.
|
|
1441
|
+
*/
|
|
1442
|
+
filledPageSegments(item) {
|
|
1443
|
+
const pagePath = this.pageForItem(item)?.route
|
|
1444
|
+
if (!pagePath) {
|
|
1445
|
+
return null
|
|
1446
|
+
}
|
|
1447
|
+
const segments = []
|
|
1448
|
+
for (const part of pagePath.split('/').filter(Boolean)) {
|
|
1449
|
+
const param = PARAM_SEGMENT.exec(part)
|
|
1450
|
+
if (!param) {
|
|
1451
|
+
segments.push(part)
|
|
1452
|
+
continue
|
|
1453
|
+
}
|
|
1454
|
+
const value = item.params?.[param[1]]
|
|
1455
|
+
if (isFilled(value)) {
|
|
1456
|
+
segments.push(String(value))
|
|
1457
|
+
} else if (!isOptionalParam(param)) {
|
|
1458
|
+
return null
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
return segments
|
|
1462
|
+
},
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* The required route param the router cannot fill when it resolves
|
|
1466
|
+
* an entry's link on its manifest page. vue-router fills a named
|
|
1467
|
+
* link's required params from the current route, then overlays every
|
|
1468
|
+
* key the entry declares, empty or null included. So a declared key
|
|
1469
|
+
* decides on its own value, and only an undeclared one falls back
|
|
1470
|
+
* to the current route. Null without a known page, or when every
|
|
1471
|
+
* required segment is filled. Reads `$route`, so it follows
|
|
1472
|
+
* navigation.
|
|
1473
|
+
*
|
|
1474
|
+
* @param {object} item Menu entry.
|
|
1475
|
+
* @return {string|null} The param name, or null.
|
|
1476
|
+
*/
|
|
1477
|
+
unfilledRouteParam(item) {
|
|
1478
|
+
const pagePath = this.pageForItem(item)?.route
|
|
1479
|
+
if (!pagePath) {
|
|
1480
|
+
return null
|
|
1481
|
+
}
|
|
1482
|
+
const declared = item.params && typeof item.params === 'object' ? item.params : {}
|
|
1483
|
+
const current = this.$route?.params ?? {}
|
|
1484
|
+
for (const part of pagePath.split('/')) {
|
|
1485
|
+
const param = PARAM_SEGMENT.exec(part)
|
|
1486
|
+
if (!param || isOptionalParam(param)) {
|
|
1487
|
+
continue
|
|
1488
|
+
}
|
|
1489
|
+
const value = param[1] in declared ? declared[param[1]] : current[param[1]]
|
|
1490
|
+
if (!isFilled(value)) {
|
|
1491
|
+
return param[1]
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return null
|
|
1495
|
+
},
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* How deep a path sits on an entry's page path: the page path's
|
|
1499
|
+
* segment count when the path is that page or below it, else 0.
|
|
1500
|
+
* A `/` page path, or one with an unfilled `:name`, never matches.
|
|
1501
|
+
*
|
|
1502
|
+
* Without `params` the page path is compared as written. With
|
|
1503
|
+
* `params`, segments are compared decoded, because the router
|
|
1504
|
+
* percent-encodes param values in the path and the entry holds them
|
|
1505
|
+
* plain.
|
|
1506
|
+
*
|
|
1507
|
+
* @param {object} item Menu entry.
|
|
1508
|
+
* @param {string|undefined} path The path to test.
|
|
1509
|
+
* @return {number} The depth of the match, or 0.
|
|
1510
|
+
*/
|
|
1511
|
+
pageMatchDepth(item, path) {
|
|
1512
|
+
if (!path) {
|
|
1513
|
+
return 0
|
|
1514
|
+
}
|
|
1515
|
+
if (!this.hasParams(item)) {
|
|
1516
|
+
const pagePath = this.pageForItem(item)?.route
|
|
1517
|
+
if (!pagePath || pagePath === '/' || pagePath.includes(':')) {
|
|
1518
|
+
return 0
|
|
1519
|
+
}
|
|
1520
|
+
const below = path === pagePath || path.startsWith(pagePath + '/')
|
|
1521
|
+
return below ? pagePath.split('/').filter(Boolean).length : 0
|
|
1522
|
+
}
|
|
1523
|
+
const segments = this.filledPageSegments(item)
|
|
1524
|
+
if (!segments || segments.length === 0) {
|
|
1525
|
+
return 0
|
|
1526
|
+
}
|
|
1527
|
+
const current = path.split('/').filter(Boolean).map(decodeSegment)
|
|
1528
|
+
return segments.every((segment, index) => current[index] === segment) ? segments.length : 0
|
|
1335
1529
|
},
|
|
1336
1530
|
|
|
1337
1531
|
/**
|
|
@@ -1454,6 +1648,27 @@ export default {
|
|
|
1454
1648
|
console.warn(`[CnAppNav] Menu entry "${item.id}" declares count: "auto" but has no resolvable index-type page with register + schema config — no badge will render.`)
|
|
1455
1649
|
},
|
|
1456
1650
|
|
|
1651
|
+
/**
|
|
1652
|
+
* One-shot `console.warn` per menu entry whose link cannot fill a
|
|
1653
|
+
* required route param. Keyed by `id`, else route and label, so
|
|
1654
|
+
* entries without an id each get their own warning.
|
|
1655
|
+
*
|
|
1656
|
+
* @param {{ id?: string, route: string, label?: string }} item Menu entry descriptor.
|
|
1657
|
+
* @param {string} param The unfilled param name.
|
|
1658
|
+
* @return {void}
|
|
1659
|
+
* @private
|
|
1660
|
+
*/
|
|
1661
|
+
warnUnfilledRouteParam(item, param) {
|
|
1662
|
+
const key = item.id ?? `${item.route}|${item.label ?? ''}`
|
|
1663
|
+
if (this._unfilledParamWarned.has(key)) {
|
|
1664
|
+
return
|
|
1665
|
+
}
|
|
1666
|
+
this._unfilledParamWarned.add(key)
|
|
1667
|
+
const name = item.id ?? item.label ?? item.route
|
|
1668
|
+
// eslint-disable-next-line no-console
|
|
1669
|
+
console.warn(`[CnAppNav] Menu entry "${name}" cannot fill the required route param "${param}" of page "${item.route}" from its params or the current route — the entry renders without a link.`)
|
|
1670
|
+
},
|
|
1671
|
+
|
|
1457
1672
|
/**
|
|
1458
1673
|
* Look up an item's resolved page (`pages[]` entry whose `id`
|
|
1459
1674
|
* matches the menu item's `route`) — used to decide whether the
|
|
@@ -1476,11 +1691,17 @@ export default {
|
|
|
1476
1691
|
* items (`action: "user-settings"`) and `href` items return
|
|
1477
1692
|
* `null` so the entry is NOT a vue-router link: action items
|
|
1478
1693
|
* fall through to the click handler, `href` items render a real
|
|
1479
|
-
* anchor via `itemHref`. Route items return a named route
|
|
1694
|
+
* anchor via `itemHref`. Route items return a named route that
|
|
1695
|
+
* carries the item's `params` and `query` when it declares them.
|
|
1696
|
+
*
|
|
1697
|
+
* An item whose manifest page has a required segment that neither
|
|
1698
|
+
* its `params` nor the current route fill also returns null, with a
|
|
1699
|
+
* one-time warning: the router throws resolving such a link, which
|
|
1700
|
+
* would take the whole navigation down rather than that one entry.
|
|
1480
1701
|
*
|
|
1481
1702
|
* @param {object} item Menu item being rendered.
|
|
1482
|
-
* @return {object|null} A `{ name }` route object,
|
|
1483
|
-
* action / href / route-less items.
|
|
1703
|
+
* @return {object|null} A `{ name, params?, query? }` route object,
|
|
1704
|
+
* or null for action / href / route-less / unfillable items.
|
|
1484
1705
|
*/
|
|
1485
1706
|
itemTo(item) {
|
|
1486
1707
|
if (item.action) {
|
|
@@ -1492,9 +1713,22 @@ export default {
|
|
|
1492
1713
|
if (!item.route) {
|
|
1493
1714
|
return null
|
|
1494
1715
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1716
|
+
const unfilled = this.unfilledRouteParam(item)
|
|
1717
|
+
if (unfilled) {
|
|
1718
|
+
this.warnUnfilledRouteParam(item, unfilled)
|
|
1719
|
+
return null
|
|
1720
|
+
}
|
|
1721
|
+
const to = { name: item.route }
|
|
1722
|
+
// Params fill a parameterised route (one entry per catalog →
|
|
1723
|
+
// /catalogs/:slug); query deep-links to a pre-filtered index page
|
|
1724
|
+
// (one entry per case type → Cases?caseType=…).
|
|
1725
|
+
if (this.hasParams(item)) {
|
|
1726
|
+
to.params = item.params
|
|
1727
|
+
}
|
|
1728
|
+
if (item.query) {
|
|
1729
|
+
to.query = item.query
|
|
1730
|
+
}
|
|
1731
|
+
return to
|
|
1498
1732
|
},
|
|
1499
1733
|
|
|
1500
1734
|
/**
|
|
@@ -1646,6 +1880,11 @@ export default {
|
|
|
1646
1880
|
this.cnReplayWalkthrough(item.tourId)
|
|
1647
1881
|
return
|
|
1648
1882
|
}
|
|
1883
|
+
// An entry left without a link (see itemTo) renders a `#` anchor;
|
|
1884
|
+
// following it would change the address, so the click goes nowhere.
|
|
1885
|
+
if (!item.href && this.unfilledRouteParam(item) && event && typeof event.preventDefault === 'function') {
|
|
1886
|
+
event.preventDefault()
|
|
1887
|
+
}
|
|
1649
1888
|
if (this.visibleChildren(item).length === 0) {
|
|
1650
1889
|
return
|
|
1651
1890
|
}
|