js-kompo-utils 1.0.17 → 1.0.18
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/.vexp/.gitattributes +2 -0
- package/.vexp/manifest.json +56 -0
- package/.vscode/settings.json +28 -0
- package/components/VlDropdown.vue +1 -1
- package/components/VlInfiniteLoad.vue +110 -0
- package/components/VlLazyHierarchy.vue +799 -0
- package/components/VlLazyTree.vue +220 -0
- package/docs/plans/2026-04-18-vl-lazy-hierarchy-generic-render.md +2093 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2093 @@
|
|
|
1
|
+
# VlLazyHierarchy — Generic Config-Driven Render Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Ship a fully generic lazy-loaded tree component whose every visual detail — node content, pills, role buttons, level colors, row actions — is composed in PHP and delivered to the Vue component as a nested kompo element tree per node. Specialized hierarchies (team/role switcher, and any future hierarchical selector) are built purely by writing a PHP `LazyHierarchySourceInterface` + a node-composition factory; no `.vue` file is ever touched.
|
|
6
|
+
|
|
7
|
+
**Architecture:**
|
|
8
|
+
- `VlLazyHierarchy.vue` is a pure infrastructure component. It handles state (tree cache, expansion, pagination, search, modes, dropdown/inline display), fetches payloads from two server endpoints, and renders each node's `render` field via kompo's dynamic `<component :is="'Vl' + vueComponent">` mechanism — identical to `VlCollapsible`'s `titleEl`/`elements` pattern.
|
|
9
|
+
- The server emits nodes with only five fields — `{id, parentId, hasChildren, isCurrent, render}` — where `render` is itself a serialized kompo element tree (a `_Flex` composing `_Html` labels, colored pills, and `_Button`s with their own `->post()->run('() => location.reload()')` actions).
|
|
10
|
+
- The team/role switcher provides a concrete `NodeFactory` + `Source` + `Element` triplet that recomposes the exact current visual output using utility classes on composed elements — no cross-repo CSS.
|
|
11
|
+
|
|
12
|
+
**Tech Stack:** Vue 2 (kompo convention), PHP 8.1+ / Laravel, kompo framework. Three repos edited:
|
|
13
|
+
- `C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils` — Vue component rewrite
|
|
14
|
+
- `C:/Users/jkend/Documents/Projects/kompo/utils` — PHP `LazyHierarchy` element trim
|
|
15
|
+
- `C:/Users/jkend/Documents/Projects/kompo/auth` — team-switcher factory/provider/element rewrite
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Design Summary
|
|
20
|
+
|
|
21
|
+
A hierarchy node is a wire object:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
{
|
|
25
|
+
id: string // "team-123"
|
|
26
|
+
parentId: string | null
|
|
27
|
+
hasChildren: bool
|
|
28
|
+
isCurrent: bool // drives --current highlight in chrome
|
|
29
|
+
render: KompoElement // composed PHP tree — name, pills, buttons
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`VlLazyHierarchy.vue` renders each row as:
|
|
34
|
+
|
|
35
|
+
```vue
|
|
36
|
+
<div class="lazy-hierarchy-node" :class="{ '--current': node.isCurrent }">
|
|
37
|
+
<div class="lazy-hierarchy-node__row" :style="indentStyle">
|
|
38
|
+
<button v-if="hasChildren" class="__toggle" @click="toggle">[chevron]</button>
|
|
39
|
+
<span v-else class="__toggle-spacer"></span>
|
|
40
|
+
<component v-bind="kompoElementAttributes(node.render, item)" />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Interactive elements (role pills, go buttons) are standard kompo `_Button` elements; their HTTP and post-success reload are handled by kompo's built-in action machinery, not by a hierarchy-level action dispatcher.
|
|
46
|
+
|
|
47
|
+
Level palette (for the team switcher, applied via Tailwind-ish utility classes on `_Html` pills):
|
|
48
|
+
- National → `bg-[rgb(255,180,0)]` (yellow)
|
|
49
|
+
- District → `bg-[rgb(0,146,67)]` (green)
|
|
50
|
+
- Group / Team → `bg-[rgb(0,44,222)]` (blue)
|
|
51
|
+
- Unit → `bg-[rgb(130,55,255)]` (purple)
|
|
52
|
+
- Committee → `bg-[rgb(255,180,0)]` (yellow)
|
|
53
|
+
- Default → `bg-[var(--lazy-hierarchy-primary)]`
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Task 1 — Rewrite `VlLazyHierarchy.vue`
|
|
58
|
+
|
|
59
|
+
**Files:**
|
|
60
|
+
- Rewrite: `C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils/components/VlLazyHierarchy.vue` (full replacement)
|
|
61
|
+
|
|
62
|
+
**Step 1: Replace the whole file with the content below**
|
|
63
|
+
|
|
64
|
+
```vue
|
|
65
|
+
<template>
|
|
66
|
+
<component :is="tag" v-bind="rootAttrs" :class="rootClasses" ref="root" @keyup.esc="closePanel">
|
|
67
|
+
<slot v-bind="slotProps">
|
|
68
|
+
<button v-if="isDropdown" type="button" class="lazy-hierarchy__trigger" :class="uiClasses.trigger" @click="togglePanel">
|
|
69
|
+
<span class="lazy-hierarchy__trigger-label">{{ triggerLabel }}</span>
|
|
70
|
+
<svg class="lazy-hierarchy__trigger-icon" :class="{ 'lazy-hierarchy__trigger-icon--open': open }" viewBox="0 0 20 20" aria-hidden="true">
|
|
71
|
+
<path d="M5 7.5l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
72
|
+
</svg>
|
|
73
|
+
</button>
|
|
74
|
+
|
|
75
|
+
<div v-if="panelOpen" class="lazy-hierarchy__panel" :class="uiClasses.panel">
|
|
76
|
+
<div v-if="hasControls" class="lazy-hierarchy__top">
|
|
77
|
+
<div v-if="searchEnabled" class="vlInputWrapper lazy-hierarchy__search-wrap" :class="uiClasses.searchWrapper">
|
|
78
|
+
<input v-model="search" class="vlFormControl lazy-hierarchy__search" :class="uiClasses.searchInput" type="search" :placeholder="searchPlaceholder" />
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div v-if="modeOptionsList.length" class="vlRadio lazy-hierarchy__modes" :class="uiClasses.modes">
|
|
82
|
+
<div
|
|
83
|
+
v-for="modeOption in modeOptionsList"
|
|
84
|
+
:key="modeOption.value"
|
|
85
|
+
class="lazy-hierarchy__mode"
|
|
86
|
+
:class="[uiClasses.mode, { 'lazy-hierarchy__mode--active': mode === modeOption.value }]"
|
|
87
|
+
@click="mode = modeOption.value"
|
|
88
|
+
>
|
|
89
|
+
<div class="vlInputWrapper lazy-hierarchy__radio-control">
|
|
90
|
+
<input v-model="mode" type="radio" :value="modeOption.value" :id="modeRadioId(modeOption.value)" />
|
|
91
|
+
<span class="lazy-hierarchy__radio-dot" aria-hidden="true"></span>
|
|
92
|
+
</div>
|
|
93
|
+
<label class="vlFormLabel lazy-hierarchy__mode-label" :for="modeRadioId(modeOption.value)">{{ modeOption.label }}</label>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div class="lazy-hierarchy__body mini-scroll" :class="uiClasses.body">
|
|
99
|
+
<div v-if="isTreeLoading && !hasNodes" class="lazy-hierarchy__status">
|
|
100
|
+
<span class="lazy-hierarchy__status-label">
|
|
101
|
+
<span class="lazy-hierarchy__spinner" aria-hidden="true"></span>
|
|
102
|
+
<span>{{ labels.loading }}</span>
|
|
103
|
+
</span>
|
|
104
|
+
<span v-for="index in 5" :key="index" class="lazy-hierarchy__skeleton-row" aria-hidden="true">
|
|
105
|
+
<span class="lazy-hierarchy__skeleton-name"></span>
|
|
106
|
+
<span class="lazy-hierarchy__skeleton-pill"></span>
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
<div v-else-if="error" class="lazy-hierarchy__status lazy-hierarchy__status--error">{{ labels.error }}</div>
|
|
110
|
+
<div v-else-if="bootstrapped && rootNodeIds.length === 0" class="lazy-hierarchy__status">{{ labels.empty }}</div>
|
|
111
|
+
|
|
112
|
+
<vl-lazy-tree v-else v-bind="treeProps" @toggle="toggleNode" @load-more="loadMoreFromTree">
|
|
113
|
+
<template v-slot:node="item">
|
|
114
|
+
<div class="lazy-hierarchy-node" :class="{ 'lazy-hierarchy-node--current': item.node.isCurrent }">
|
|
115
|
+
<div class="lazy-hierarchy-node__row" :class="uiClasses.nodeRow" :style="item.indentStyle">
|
|
116
|
+
<button
|
|
117
|
+
v-if="item.hasChildren"
|
|
118
|
+
type="button"
|
|
119
|
+
class="lazy-hierarchy-node__toggle"
|
|
120
|
+
:aria-expanded="item.isExpanded ? 'true' : 'false'"
|
|
121
|
+
@click.stop="item.toggle"
|
|
122
|
+
>
|
|
123
|
+
<svg class="lazy-hierarchy-node__chevron" :class="{ 'lazy-hierarchy-node__chevron--open': item.isExpanded }" viewBox="0 0 20 20" aria-hidden="true">
|
|
124
|
+
<path d="M7.5 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
125
|
+
</svg>
|
|
126
|
+
</button>
|
|
127
|
+
<span v-else class="lazy-hierarchy-node__toggle-spacer"></span>
|
|
128
|
+
|
|
129
|
+
<component v-bind="kompoElementAttributes(item.node.render, item)" class="lazy-hierarchy-node__content" />
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</template>
|
|
133
|
+
|
|
134
|
+
<template v-slot:loading="item">
|
|
135
|
+
<div :key="item.key" class="lazy-hierarchy-node__loading" :style="item.indentStyle">
|
|
136
|
+
<span class="lazy-hierarchy__skeleton-row" aria-hidden="true">
|
|
137
|
+
<span class="lazy-hierarchy__skeleton-name"></span>
|
|
138
|
+
<span class="lazy-hierarchy__skeleton-pill"></span>
|
|
139
|
+
</span>
|
|
140
|
+
</div>
|
|
141
|
+
</template>
|
|
142
|
+
|
|
143
|
+
<template v-slot:load-more="item">
|
|
144
|
+
<vl-infinite-load
|
|
145
|
+
:key="item.key"
|
|
146
|
+
class="lazy-hierarchy__show-more"
|
|
147
|
+
:button-class="uiClasses.showMore"
|
|
148
|
+
:loading="item.isLoading"
|
|
149
|
+
:disabled="item.isLoading"
|
|
150
|
+
:label="labels.showMore"
|
|
151
|
+
:loading-label="labels.loading"
|
|
152
|
+
@visible="item.loadMore"
|
|
153
|
+
/>
|
|
154
|
+
</template>
|
|
155
|
+
</vl-lazy-tree>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</slot>
|
|
159
|
+
</component>
|
|
160
|
+
</template>
|
|
161
|
+
|
|
162
|
+
<script>
|
|
163
|
+
import Other from 'vue-kompo/js/form/mixins/Other'
|
|
164
|
+
import VlInfiniteLoad from './VlInfiniteLoad.vue'
|
|
165
|
+
import VlLazyTree from './VlLazyTree.vue'
|
|
166
|
+
|
|
167
|
+
const ROOT_KEY = 'root'
|
|
168
|
+
|
|
169
|
+
const configValue = (vm, key) => vm.vkompo && vm.vkompo.config ? vm.vkompo.config[key] : undefined
|
|
170
|
+
|
|
171
|
+
export default {
|
|
172
|
+
name: 'VlLazyHierarchy',
|
|
173
|
+
inheritAttrs: false,
|
|
174
|
+
mixins: [Other],
|
|
175
|
+
components: { VlInfiniteLoad, VlLazyTree },
|
|
176
|
+
props: {
|
|
177
|
+
vkompo: { type: Object, default: null },
|
|
178
|
+
kompoid: { type: String, default: '' },
|
|
179
|
+
kompoinfo: { type: String, default: '' },
|
|
180
|
+
tag: { type: String, default: 'div' },
|
|
181
|
+
rootKey: { type: String, default: ROOT_KEY },
|
|
182
|
+
parentParam: { type: String, default: 'parent_id' },
|
|
183
|
+
cursorParam: { type: String, default: 'cursor' },
|
|
184
|
+
limit: { type: Number, default: 20 },
|
|
185
|
+
lookahead: { type: Number, default: 80 },
|
|
186
|
+
autoLoad: { type: Boolean, default: true },
|
|
187
|
+
deferLoad: { type: Boolean, default: false },
|
|
188
|
+
baseIndent: { type: Number, default: 1 },
|
|
189
|
+
indentStep: { type: Number, default: 0.9 },
|
|
190
|
+
maxIndentDepth: { type: Number, default: 5 },
|
|
191
|
+
},
|
|
192
|
+
data() {
|
|
193
|
+
const modes = configValue(this, 'modes') || {}
|
|
194
|
+
const modeOptions = modes.options || {}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
open: false,
|
|
198
|
+
mode: modes.default || Object.keys(modeOptions)[0] || '',
|
|
199
|
+
search: '',
|
|
200
|
+
appliedSearch: '',
|
|
201
|
+
searchTimer: null,
|
|
202
|
+
nodesById: {},
|
|
203
|
+
childrenByParent: { [this.resolvedRootKey]: [] },
|
|
204
|
+
pagingByParent: {},
|
|
205
|
+
expanded: {},
|
|
206
|
+
loadingParents: {},
|
|
207
|
+
loadingInitial: false,
|
|
208
|
+
bootstrapped: false,
|
|
209
|
+
bootstrapPromise: null,
|
|
210
|
+
error: '',
|
|
211
|
+
requestSeq: 0,
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
computed: {
|
|
215
|
+
rootAttrs() {
|
|
216
|
+
return {
|
|
217
|
+
...this.$attrs,
|
|
218
|
+
...(this.vkompo && this.vkompo.id ? { id: this.vkompo.id } : {}),
|
|
219
|
+
style: [this.vkompo?.style, this.$attrs.style].filter(Boolean).join('; '),
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
rootClasses() {
|
|
223
|
+
return ['vl-lazy-hierarchy', 'lazy-hierarchy', `lazy-hierarchy--${this.displayMode}`, this.vkompo?.class].filter(Boolean)
|
|
224
|
+
},
|
|
225
|
+
displayMode() { return this.configOr('displayMode', 'inline') },
|
|
226
|
+
isDropdown() { return this.displayMode !== 'inline' },
|
|
227
|
+
panelOpen() { return this.open || !this.isDropdown },
|
|
228
|
+
hasControls() { return this.searchEnabled || this.modeOptionsList.length > 0 },
|
|
229
|
+
triggerLabel() { return this.configValue('triggerLabel') || '' },
|
|
230
|
+
searchConfig() { return this.configOr('search', {}) },
|
|
231
|
+
searchEnabled() { return !!this.searchConfig.enabled },
|
|
232
|
+
searchPlaceholder() { return this.searchConfig.placeholder || this.labels.searchPlaceholder },
|
|
233
|
+
searchParam() { return this.searchConfig.param || 'search' },
|
|
234
|
+
searchDebounce() { return this.searchConfig.debounce || 350 },
|
|
235
|
+
modeConfig() { return this.configOr('modes', {}) },
|
|
236
|
+
modeParam() { return this.modeConfig.param || 'mode' },
|
|
237
|
+
modeOptionsList() {
|
|
238
|
+
return Object.entries(this.modeConfig.options || {}).map(([value, label]) => ({ value, label }))
|
|
239
|
+
},
|
|
240
|
+
labels() {
|
|
241
|
+
const labels = this.configOr('labels', {})
|
|
242
|
+
return {
|
|
243
|
+
searchPlaceholder: labels.searchPlaceholder || 'Search',
|
|
244
|
+
loading: labels.loading || 'Loading...',
|
|
245
|
+
empty: labels.empty || 'No items found',
|
|
246
|
+
error: labels.error || 'Unable to load items',
|
|
247
|
+
showMore: labels.showMore || 'Show more',
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
uiClasses() { return this.configOr('classes', {}) },
|
|
251
|
+
resolvedRootKey() { return this.configOr('rootKey', this.rootKey) },
|
|
252
|
+
resolvedBootstrapUrl() { return this.configValue('bootstrapUrl') },
|
|
253
|
+
resolvedNodesUrl() { return this.configValue('nodesUrl') },
|
|
254
|
+
resolvedRequestParams() {
|
|
255
|
+
const params = { ...(this.configValue('params') || {}) }
|
|
256
|
+
if (this.searchEnabled) params[this.searchParam] = this.appliedSearch
|
|
257
|
+
if (this.modeOptionsList.length && this.mode) params[this.modeParam] = this.mode
|
|
258
|
+
return params
|
|
259
|
+
},
|
|
260
|
+
resolvedLimit() { return this.configValue('limit') || this.limit },
|
|
261
|
+
resolvedLookahead() { return this.configValue('lookahead') || this.lookahead },
|
|
262
|
+
resolvedBaseIndent() { return this.configOr('baseIndent', this.baseIndent) },
|
|
263
|
+
resolvedIndentStep() { return this.configOr('indentStep', this.indentStep) },
|
|
264
|
+
resolvedMaxIndentDepth() { return this.configOr('maxIndentDepth', this.maxIndentDepth) },
|
|
265
|
+
resolvedParentParam() { return this.configValue('parentParam') || this.parentParam },
|
|
266
|
+
resolvedCursorParam() { return this.configValue('cursorParam') || this.cursorParam },
|
|
267
|
+
resolvedAutoLoad() {
|
|
268
|
+
const c = this.configValue('autoLoad')
|
|
269
|
+
return c === undefined ? this.autoLoad : !!c
|
|
270
|
+
},
|
|
271
|
+
resolvedDeferLoad() {
|
|
272
|
+
const c = this.configValue('deferLoad')
|
|
273
|
+
return c === undefined ? this.deferLoad : !!c
|
|
274
|
+
},
|
|
275
|
+
rootNodeIds() { return this.childrenByParent[this.resolvedRootKey] || [] },
|
|
276
|
+
hasNodes() { return Object.keys(this.nodesById).length > 0 },
|
|
277
|
+
isTreeLoading() { return this.loadingInitial || !!this.bootstrapPromise },
|
|
278
|
+
treeProps() {
|
|
279
|
+
return {
|
|
280
|
+
rootKey: this.resolvedRootKey,
|
|
281
|
+
rootNodeIds: this.rootNodeIds,
|
|
282
|
+
nodesById: this.nodesById,
|
|
283
|
+
childrenByParent: this.childrenByParent,
|
|
284
|
+
pagingByParent: this.pagingByParent,
|
|
285
|
+
expanded: this.expanded,
|
|
286
|
+
loadingParents: this.loadingParents,
|
|
287
|
+
baseIndent: this.resolvedBaseIndent,
|
|
288
|
+
indentStep: this.resolvedIndentStep,
|
|
289
|
+
maxIndentDepth: this.resolvedMaxIndentDepth,
|
|
290
|
+
loadingLabel: this.labels.loading,
|
|
291
|
+
showMoreLabel: this.labels.showMore,
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
slotProps() {
|
|
295
|
+
return {
|
|
296
|
+
...this.treeProps,
|
|
297
|
+
treeProps: this.treeProps,
|
|
298
|
+
rootNodeIds: this.rootNodeIds,
|
|
299
|
+
hasNodes: this.hasNodes,
|
|
300
|
+
isTreeLoading: this.isTreeLoading,
|
|
301
|
+
bootstrapped: this.bootstrapped,
|
|
302
|
+
error: this.error,
|
|
303
|
+
ensureBootstrap: this.ensureBootstrap,
|
|
304
|
+
reloadTree: this.reloadTree,
|
|
305
|
+
resetTree: this.resetTree,
|
|
306
|
+
toggleNode: this.toggleNode,
|
|
307
|
+
loadChildren: this.loadChildren,
|
|
308
|
+
loadMore: this.loadMore,
|
|
309
|
+
loadMoreRoot: this.loadMoreRoot,
|
|
310
|
+
loadMoreFromTree: this.loadMoreFromTree,
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
watch: {
|
|
315
|
+
mode() {
|
|
316
|
+
if (this.bootstrapped || this.bootstrapPromise) this.reloadTree()
|
|
317
|
+
},
|
|
318
|
+
search() {
|
|
319
|
+
clearTimeout(this.searchTimer)
|
|
320
|
+
this.searchTimer = setTimeout(() => {
|
|
321
|
+
this.appliedSearch = this.search
|
|
322
|
+
if (this.bootstrapped || this.bootstrapPromise) this.reloadTree()
|
|
323
|
+
}, this.searchDebounce)
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
mounted() {
|
|
327
|
+
document.addEventListener('click', this.onDocumentClick)
|
|
328
|
+
if (!this.resolvedAutoLoad) return
|
|
329
|
+
if (this.resolvedDeferLoad) { this.deferBootstrap(); return }
|
|
330
|
+
this.ensureBootstrap(true)
|
|
331
|
+
},
|
|
332
|
+
beforeDestroy() {
|
|
333
|
+
document.removeEventListener('click', this.onDocumentClick)
|
|
334
|
+
clearTimeout(this.searchTimer)
|
|
335
|
+
},
|
|
336
|
+
methods: {
|
|
337
|
+
configValue(key) { return configValue(this, key) },
|
|
338
|
+
configOr(key, fallback) {
|
|
339
|
+
const v = this.configValue(key)
|
|
340
|
+
return v === undefined ? fallback : v
|
|
341
|
+
},
|
|
342
|
+
modeRadioId(value) { return `lazy-hierarchy-${this._uid}-${value}` },
|
|
343
|
+
togglePanel() {
|
|
344
|
+
if (!this.isDropdown) return
|
|
345
|
+
this.open = !this.open
|
|
346
|
+
if (this.open) this.ensureBootstrap(true)
|
|
347
|
+
},
|
|
348
|
+
closePanel() { if (this.isDropdown) this.open = false },
|
|
349
|
+
onDocumentClick(event) {
|
|
350
|
+
if (!this.isDropdown || !this.open || !this.$refs.root || this.$refs.root.contains(event.target)) return
|
|
351
|
+
this.open = false
|
|
352
|
+
},
|
|
353
|
+
deferBootstrap() {
|
|
354
|
+
const run = () => this.ensureBootstrap(false)
|
|
355
|
+
if (window.requestIdleCallback) { window.requestIdleCallback(run, { timeout: 1200 }); return }
|
|
356
|
+
setTimeout(run, 250)
|
|
357
|
+
},
|
|
358
|
+
reloadTree(showLoading = true) {
|
|
359
|
+
this.resetTree()
|
|
360
|
+
return this.ensureBootstrap(showLoading)
|
|
361
|
+
},
|
|
362
|
+
resetTree() {
|
|
363
|
+
this.nodesById = {}
|
|
364
|
+
this.childrenByParent = { [this.resolvedRootKey]: [] }
|
|
365
|
+
this.pagingByParent = {}
|
|
366
|
+
this.expanded = {}
|
|
367
|
+
this.loadingParents = {}
|
|
368
|
+
this.bootstrapped = false
|
|
369
|
+
this.bootstrapPromise = null
|
|
370
|
+
this.error = ''
|
|
371
|
+
},
|
|
372
|
+
ensureBootstrap(showLoading = true) {
|
|
373
|
+
if (this.bootstrapped) return Promise.resolve()
|
|
374
|
+
if (this.bootstrapPromise) {
|
|
375
|
+
if (showLoading) this.loadingInitial = true
|
|
376
|
+
return this.bootstrapPromise
|
|
377
|
+
}
|
|
378
|
+
const seq = ++this.requestSeq
|
|
379
|
+
if (showLoading) this.loadingInitial = true
|
|
380
|
+
this.error = ''
|
|
381
|
+
this.bootstrapPromise = this.getJson(this.resolvedBootstrapUrl, this.baseParams())
|
|
382
|
+
.then(payload => {
|
|
383
|
+
if (seq !== this.requestSeq) return
|
|
384
|
+
this.mergePayload(payload)
|
|
385
|
+
this.bootstrapped = true
|
|
386
|
+
this.$emit('loaded', payload)
|
|
387
|
+
})
|
|
388
|
+
.catch(error => this.handleError(error, seq))
|
|
389
|
+
.finally(() => {
|
|
390
|
+
if (seq === this.requestSeq) {
|
|
391
|
+
this.loadingInitial = false
|
|
392
|
+
this.bootstrapPromise = null
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
return this.bootstrapPromise
|
|
396
|
+
},
|
|
397
|
+
baseParams(extra = {}) {
|
|
398
|
+
return { ...this.resolvedRequestParams, limit: this.resolvedLimit, lookahead: this.resolvedLookahead, ...extra }
|
|
399
|
+
},
|
|
400
|
+
toggleNode(node) {
|
|
401
|
+
if (!node || !node.id || !node.hasChildren) return
|
|
402
|
+
if (this.expanded[node.id]) { this.$set(this.expanded, node.id, false); return }
|
|
403
|
+
this.$set(this.expanded, node.id, true)
|
|
404
|
+
if (!Object.prototype.hasOwnProperty.call(this.childrenByParent, node.id)) this.loadChildren(node)
|
|
405
|
+
},
|
|
406
|
+
loadChildren(node) {
|
|
407
|
+
if (!node || this.loadingParents[node.id]) return Promise.resolve()
|
|
408
|
+
this.$set(this.loadingParents, node.id, true)
|
|
409
|
+
return this.getJson(this.resolvedNodesUrl, this.baseParams({ [this.resolvedParentParam]: node.teamId || node.id }))
|
|
410
|
+
.then(payload => this.mergePayload(payload))
|
|
411
|
+
.catch(error => this.handleError(error))
|
|
412
|
+
.finally(() => this.$set(this.loadingParents, node.id, false))
|
|
413
|
+
},
|
|
414
|
+
loadMore(node) {
|
|
415
|
+
if (!node || !node.id || this.loadingParents[node.id]) return Promise.resolve()
|
|
416
|
+
const paging = this.pagingByParent[node.id]
|
|
417
|
+
if (!paging || paging.nextCursor === null) return Promise.resolve()
|
|
418
|
+
this.$set(this.loadingParents, node.id, true)
|
|
419
|
+
return this.getJson(this.resolvedNodesUrl, this.baseParams({
|
|
420
|
+
[this.resolvedParentParam]: node.teamId || node.id,
|
|
421
|
+
[this.resolvedCursorParam]: paging.nextCursor,
|
|
422
|
+
}))
|
|
423
|
+
.then(payload => this.mergePayload(payload, node.id))
|
|
424
|
+
.catch(error => this.handleError(error))
|
|
425
|
+
.finally(() => this.$set(this.loadingParents, node.id, false))
|
|
426
|
+
},
|
|
427
|
+
loadMoreRoot() {
|
|
428
|
+
const rootKey = this.resolvedRootKey
|
|
429
|
+
const paging = this.pagingByParent[rootKey]
|
|
430
|
+
if (!paging || paging.nextCursor === null || this.loadingParents[rootKey]) return Promise.resolve()
|
|
431
|
+
this.$set(this.loadingParents, rootKey, true)
|
|
432
|
+
return this.getJson(this.resolvedNodesUrl, this.baseParams({ [this.resolvedCursorParam]: paging.nextCursor }))
|
|
433
|
+
.then(payload => this.mergePayload(payload, rootKey))
|
|
434
|
+
.catch(error => this.handleError(error))
|
|
435
|
+
.finally(() => this.$set(this.loadingParents, rootKey, false))
|
|
436
|
+
},
|
|
437
|
+
loadMoreFromTree(item) {
|
|
438
|
+
if (!item) return Promise.resolve()
|
|
439
|
+
return item.root ? this.loadMoreRoot() : this.loadMore(item.parentNode)
|
|
440
|
+
},
|
|
441
|
+
mergePayload(payload, appendParentKey = null) {
|
|
442
|
+
Object.entries(payload.nodes || {}).forEach(([id, node]) => { this.$set(this.nodesById, id, node) })
|
|
443
|
+
Object.entries(payload.children || {}).forEach(([parentKey, childIds]) => {
|
|
444
|
+
const existing = this.childrenByParent[parentKey] || []
|
|
445
|
+
const shouldAppend = appendParentKey === parentKey || (payload.paging && payload.paging[parentKey] && payload.paging[parentKey].append)
|
|
446
|
+
const next = shouldAppend ? this.unique([...existing, ...childIds]) : this.unique(childIds)
|
|
447
|
+
this.$set(this.childrenByParent, parentKey, next)
|
|
448
|
+
})
|
|
449
|
+
Object.entries(payload.paging || {}).forEach(([parentKey, paging]) => { this.$set(this.pagingByParent, parentKey, paging) })
|
|
450
|
+
;(payload.expandedIds || []).forEach(id => { this.$set(this.expanded, id, true) })
|
|
451
|
+
},
|
|
452
|
+
unique(values) { return [...new Set(values)] },
|
|
453
|
+
getJson(url, params = {}) {
|
|
454
|
+
const requestUrl = new URL(url, window.location.origin)
|
|
455
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
456
|
+
if (value !== null && value !== undefined && value !== '') requestUrl.searchParams.set(key, value)
|
|
457
|
+
})
|
|
458
|
+
return fetch(requestUrl.toString(), { headers: this.requestHeaders(), credentials: 'same-origin' }).then(this.parseResponse)
|
|
459
|
+
},
|
|
460
|
+
requestHeaders(extra = {}) {
|
|
461
|
+
const kompoInfo = this.kompoInfoHeader()
|
|
462
|
+
return {
|
|
463
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
464
|
+
...(kompoInfo ? { 'X-Kompo-Info': kompoInfo } : {}),
|
|
465
|
+
...extra,
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
kompoInfoHeader() {
|
|
469
|
+
if (this.kompoinfo) return this.kompoinfo
|
|
470
|
+
if (this.$_kompoInfo) return this.$_kompoInfo
|
|
471
|
+
if (this.kompoInfo) return this.kompoInfo
|
|
472
|
+
if (this.$kompo && this.kompoid && this.$_elKompoId) {
|
|
473
|
+
this.$kompo.vlGetKomponentInfo(this.kompoid, this.$_elKompoId)
|
|
474
|
+
if (this.kompoInfo) return this.kompoInfo
|
|
475
|
+
}
|
|
476
|
+
return ''
|
|
477
|
+
},
|
|
478
|
+
parseResponse(response) {
|
|
479
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
|
|
480
|
+
return response.json()
|
|
481
|
+
},
|
|
482
|
+
handleError(error, seq = this.requestSeq) {
|
|
483
|
+
if (seq !== this.requestSeq) return
|
|
484
|
+
this.error = error.message || this.labels.error
|
|
485
|
+
this.$emit('error', this.error)
|
|
486
|
+
},
|
|
487
|
+
kompoElementAttributes(component, item) {
|
|
488
|
+
return {
|
|
489
|
+
key: component.id || item.key,
|
|
490
|
+
is: this.vueTag(component),
|
|
491
|
+
vkompo: component,
|
|
492
|
+
kompoid: this.kompoid || this.$_elKompoId,
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
vueTag(component) { return 'Vl' + component.vueComponent },
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
</script>
|
|
499
|
+
|
|
500
|
+
<style>
|
|
501
|
+
.lazy-hierarchy {
|
|
502
|
+
--lazy-hierarchy-primary: var(--level1, var(--greenmain, var(--primary, currentColor)));
|
|
503
|
+
--lazy-hierarchy-primary-dark: var(--greendark, var(--primary-dark, var(--lazy-hierarchy-primary)));
|
|
504
|
+
--lazy-hierarchy-primary-soft: color-mix(in srgb, var(--lazy-hierarchy-primary) 9%, transparent);
|
|
505
|
+
--lazy-hierarchy-primary-soft-hover: color-mix(in srgb, var(--lazy-hierarchy-primary) 13%, transparent);
|
|
506
|
+
position: relative;
|
|
507
|
+
display: block;
|
|
508
|
+
color: var(--lazy-hierarchy-primary);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.lazy-hierarchy--dropdown { display: inline-flex; }
|
|
512
|
+
|
|
513
|
+
.lazy-hierarchy__trigger {
|
|
514
|
+
display: inline-flex;
|
|
515
|
+
align-items: center;
|
|
516
|
+
gap: 0.35rem;
|
|
517
|
+
border: 0;
|
|
518
|
+
background: transparent;
|
|
519
|
+
color: #1f2933;
|
|
520
|
+
font-size: 0.875rem;
|
|
521
|
+
font-weight: 600;
|
|
522
|
+
line-height: 1.2;
|
|
523
|
+
cursor: pointer;
|
|
524
|
+
padding: 0.25rem 0.35rem;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.lazy-hierarchy__trigger-label {
|
|
528
|
+
max-width: 12rem;
|
|
529
|
+
overflow: hidden;
|
|
530
|
+
text-overflow: ellipsis;
|
|
531
|
+
white-space: nowrap;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.lazy-hierarchy__trigger-icon {
|
|
535
|
+
width: 1rem;
|
|
536
|
+
height: 1rem;
|
|
537
|
+
flex: 0 0 1rem;
|
|
538
|
+
display: block;
|
|
539
|
+
color: var(--lazy-hierarchy-primary);
|
|
540
|
+
transform: translateY(0.05rem);
|
|
541
|
+
transform-origin: center;
|
|
542
|
+
transition: transform 0.2s ease;
|
|
543
|
+
}
|
|
544
|
+
.lazy-hierarchy__trigger-icon--open { transform: translateY(0.05rem) rotate(180deg); }
|
|
545
|
+
|
|
546
|
+
.lazy-hierarchy__panel {
|
|
547
|
+
position: absolute;
|
|
548
|
+
top: calc(100% + 0.45rem);
|
|
549
|
+
right: 0;
|
|
550
|
+
width: 33rem;
|
|
551
|
+
max-width: calc(100vw - 1rem);
|
|
552
|
+
max-height: min(28rem, calc(100vh - 4rem));
|
|
553
|
+
display: flex;
|
|
554
|
+
flex-direction: column;
|
|
555
|
+
background: #fff;
|
|
556
|
+
border: 1px solid #e7ece9;
|
|
557
|
+
border-radius: 8px;
|
|
558
|
+
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.11);
|
|
559
|
+
z-index: 940;
|
|
560
|
+
overflow: hidden;
|
|
561
|
+
}
|
|
562
|
+
.lazy-hierarchy--inline .lazy-hierarchy__panel {
|
|
563
|
+
position: static;
|
|
564
|
+
width: 100%;
|
|
565
|
+
max-width: none;
|
|
566
|
+
max-height: none;
|
|
567
|
+
border: 0;
|
|
568
|
+
box-shadow: none;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.lazy-hierarchy__top {
|
|
572
|
+
display: flex;
|
|
573
|
+
align-items: flex-start;
|
|
574
|
+
gap: 1.25rem;
|
|
575
|
+
flex: 0 0 auto;
|
|
576
|
+
padding: 1.5rem;
|
|
577
|
+
border-bottom: 1px solid #edf1ef;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.lazy-hierarchy__search-wrap { min-width: 0; flex: 1 1 auto; margin: 0; }
|
|
581
|
+
.lazy-hierarchy__search { width: 100%; min-height: 2.35rem; font-size: 0.8125rem; }
|
|
582
|
+
|
|
583
|
+
.lazy-hierarchy__modes { display: flex; flex-direction: column; gap: 0.5rem; flex: 0 0 auto; }
|
|
584
|
+
.lazy-hierarchy__mode {
|
|
585
|
+
display: flex;
|
|
586
|
+
align-items: center;
|
|
587
|
+
color: #334155;
|
|
588
|
+
font-size: 0.75rem;
|
|
589
|
+
line-height: 1;
|
|
590
|
+
cursor: pointer;
|
|
591
|
+
white-space: nowrap;
|
|
592
|
+
min-height: 1.35rem;
|
|
593
|
+
}
|
|
594
|
+
.lazy-hierarchy__radio-control {
|
|
595
|
+
width: 1.15rem !important;
|
|
596
|
+
min-width: 1.15rem !important;
|
|
597
|
+
max-width: 1.15rem !important;
|
|
598
|
+
height: 1.15rem !important;
|
|
599
|
+
min-height: 1.15rem !important;
|
|
600
|
+
max-height: 1.15rem !important;
|
|
601
|
+
flex: 0 0 1.15rem;
|
|
602
|
+
display: inline-block;
|
|
603
|
+
position: relative;
|
|
604
|
+
margin-right: 0.5rem;
|
|
605
|
+
padding: 0;
|
|
606
|
+
background: #fff;
|
|
607
|
+
border: 0;
|
|
608
|
+
border-radius: 9999px;
|
|
609
|
+
box-shadow: none;
|
|
610
|
+
overflow: hidden;
|
|
611
|
+
}
|
|
612
|
+
.lazy-hierarchy__radio-control::after { display: none; }
|
|
613
|
+
.lazy-hierarchy__mode input {
|
|
614
|
+
position: absolute;
|
|
615
|
+
inset: 0;
|
|
616
|
+
z-index: 2;
|
|
617
|
+
width: 100%;
|
|
618
|
+
height: 100%;
|
|
619
|
+
margin: 0;
|
|
620
|
+
padding: 0;
|
|
621
|
+
appearance: none;
|
|
622
|
+
border: 0;
|
|
623
|
+
border-radius: 9999px;
|
|
624
|
+
background: transparent;
|
|
625
|
+
color: var(--lazy-hierarchy-primary);
|
|
626
|
+
cursor: pointer;
|
|
627
|
+
opacity: 0;
|
|
628
|
+
}
|
|
629
|
+
.lazy-hierarchy__radio-dot {
|
|
630
|
+
position: absolute;
|
|
631
|
+
inset: 0;
|
|
632
|
+
border: 1px solid #d9e3df;
|
|
633
|
+
border-radius: 9999px;
|
|
634
|
+
background: #fff;
|
|
635
|
+
pointer-events: none;
|
|
636
|
+
}
|
|
637
|
+
.lazy-hierarchy__radio-dot::after {
|
|
638
|
+
content: "";
|
|
639
|
+
position: absolute;
|
|
640
|
+
inset: 0.32rem;
|
|
641
|
+
border-radius: 9999px;
|
|
642
|
+
background: transparent;
|
|
643
|
+
}
|
|
644
|
+
.lazy-hierarchy__mode input:checked + .lazy-hierarchy__radio-dot {
|
|
645
|
+
border-color: var(--lazy-hierarchy-primary);
|
|
646
|
+
background: var(--lazy-hierarchy-primary);
|
|
647
|
+
}
|
|
648
|
+
.lazy-hierarchy__mode input:checked + .lazy-hierarchy__radio-dot::after { background: #fff; }
|
|
649
|
+
.lazy-hierarchy__radio-control:focus-within { box-shadow: 0 0 0 3px var(--lazy-hierarchy-primary-soft-hover); }
|
|
650
|
+
.lazy-hierarchy__mode-label { margin: 0; color: inherit; font-size: 0.75rem; line-height: 1; }
|
|
651
|
+
.lazy-hierarchy__mode--active { color: var(--lazy-hierarchy-primary); font-weight: 700; }
|
|
652
|
+
|
|
653
|
+
.lazy-hierarchy__body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0.45rem 0 0.75rem; }
|
|
654
|
+
|
|
655
|
+
.lazy-hierarchy__status {
|
|
656
|
+
display: flex;
|
|
657
|
+
flex-direction: column;
|
|
658
|
+
align-items: center;
|
|
659
|
+
justify-content: center;
|
|
660
|
+
gap: 0.65rem;
|
|
661
|
+
padding: 1.1rem 0.65rem;
|
|
662
|
+
color: #64748b;
|
|
663
|
+
font-size: 0.8125rem;
|
|
664
|
+
text-align: center;
|
|
665
|
+
}
|
|
666
|
+
.lazy-hierarchy__status--error { color: #b42318; }
|
|
667
|
+
.lazy-hierarchy__status-label { display: inline-flex; align-items: center; gap: 0.5rem; }
|
|
668
|
+
|
|
669
|
+
.lazy-hierarchy__spinner {
|
|
670
|
+
width: 0.9rem;
|
|
671
|
+
height: 0.9rem;
|
|
672
|
+
border: 2px solid currentColor;
|
|
673
|
+
border-right-color: transparent;
|
|
674
|
+
border-radius: 9999px;
|
|
675
|
+
animation: lazy-hierarchy-spin 0.65s linear infinite;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.lazy-hierarchy__sr-only {
|
|
679
|
+
position: absolute;
|
|
680
|
+
width: 1px;
|
|
681
|
+
height: 1px;
|
|
682
|
+
padding: 0;
|
|
683
|
+
overflow: hidden;
|
|
684
|
+
clip: rect(0, 0, 0, 0);
|
|
685
|
+
white-space: nowrap;
|
|
686
|
+
border: 0;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
@keyframes lazy-hierarchy-spin { to { transform: rotate(360deg); } }
|
|
690
|
+
|
|
691
|
+
.lazy-hierarchy__skeleton-row {
|
|
692
|
+
width: 100%;
|
|
693
|
+
min-height: 2.35rem;
|
|
694
|
+
display: flex;
|
|
695
|
+
align-items: center;
|
|
696
|
+
gap: 0.45rem;
|
|
697
|
+
}
|
|
698
|
+
.lazy-hierarchy__skeleton-name,
|
|
699
|
+
.lazy-hierarchy__skeleton-pill {
|
|
700
|
+
display: inline-block;
|
|
701
|
+
height: 0.8rem;
|
|
702
|
+
border-radius: 9999px;
|
|
703
|
+
background: linear-gradient(90deg, #eef4f1 0%, #f8faf9 50%, #eef4f1 100%);
|
|
704
|
+
background-size: 180% 100%;
|
|
705
|
+
animation: lazy-hierarchy-skeleton 1.1s ease-in-out infinite;
|
|
706
|
+
}
|
|
707
|
+
.lazy-hierarchy__skeleton-name { flex: 1 1 auto; }
|
|
708
|
+
.lazy-hierarchy__skeleton-pill { width: 4.2rem; }
|
|
709
|
+
@keyframes lazy-hierarchy-skeleton {
|
|
710
|
+
0% { background-position: 100% 0; }
|
|
711
|
+
100% { background-position: -100% 0; }
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.lazy-hierarchy-node__row {
|
|
715
|
+
min-height: 2.55rem;
|
|
716
|
+
display: flex;
|
|
717
|
+
align-items: center;
|
|
718
|
+
gap: 0.45rem;
|
|
719
|
+
padding-top: 0.2rem;
|
|
720
|
+
padding-right: 1rem;
|
|
721
|
+
padding-bottom: 0.2rem;
|
|
722
|
+
color: var(--lazy-hierarchy-primary);
|
|
723
|
+
}
|
|
724
|
+
.lazy-hierarchy-node--current > .lazy-hierarchy-node__row { background: var(--lazy-hierarchy-primary-soft); }
|
|
725
|
+
.lazy-hierarchy-node--current > .lazy-hierarchy-node__row:hover { background: var(--lazy-hierarchy-primary-soft-hover); }
|
|
726
|
+
.lazy-hierarchy-node__row:hover { background: #f8faf9; }
|
|
727
|
+
|
|
728
|
+
.lazy-hierarchy-node__content { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 0.45rem; }
|
|
729
|
+
|
|
730
|
+
.lazy-hierarchy-node__toggle,
|
|
731
|
+
.lazy-hierarchy-node__toggle-spacer {
|
|
732
|
+
width: 1rem;
|
|
733
|
+
height: 1.35rem;
|
|
734
|
+
flex: 0 0 1rem;
|
|
735
|
+
}
|
|
736
|
+
.lazy-hierarchy-node__toggle {
|
|
737
|
+
display: inline-flex;
|
|
738
|
+
align-items: center;
|
|
739
|
+
justify-content: center;
|
|
740
|
+
border: 0;
|
|
741
|
+
background: transparent;
|
|
742
|
+
color: var(--lazy-hierarchy-primary-dark);
|
|
743
|
+
line-height: 1;
|
|
744
|
+
cursor: pointer;
|
|
745
|
+
}
|
|
746
|
+
.lazy-hierarchy-node__chevron {
|
|
747
|
+
width: 0.9rem;
|
|
748
|
+
height: 0.9rem;
|
|
749
|
+
flex: 0 0 0.9rem;
|
|
750
|
+
transition: transform 0.2s ease;
|
|
751
|
+
}
|
|
752
|
+
.lazy-hierarchy-node__chevron--open { transform: rotate(90deg); }
|
|
753
|
+
|
|
754
|
+
.lazy-hierarchy-node__loading {
|
|
755
|
+
display: flex;
|
|
756
|
+
align-items: center;
|
|
757
|
+
padding: 0.25rem 0.55rem;
|
|
758
|
+
color: #64748b;
|
|
759
|
+
font-size: 0.75rem;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.lazy-hierarchy__show-more {
|
|
763
|
+
display: flex;
|
|
764
|
+
width: calc(100% - 1rem);
|
|
765
|
+
margin: 0.55rem 0.5rem 0.35rem;
|
|
766
|
+
border: 1px solid #dbe4df;
|
|
767
|
+
border-radius: 6px;
|
|
768
|
+
background: #fff;
|
|
769
|
+
color: var(--lazy-hierarchy-primary);
|
|
770
|
+
cursor: pointer;
|
|
771
|
+
font-size: 0.75rem;
|
|
772
|
+
font-weight: 700;
|
|
773
|
+
padding: 0.5rem;
|
|
774
|
+
}
|
|
775
|
+
.lazy-hierarchy__show-more:hover { background: #f5fbf7; }
|
|
776
|
+
|
|
777
|
+
.lazy-hierarchy button:disabled { cursor: wait; opacity: 0.65; }
|
|
778
|
+
|
|
779
|
+
@media (max-width: 640px) {
|
|
780
|
+
.lazy-hierarchy__panel {
|
|
781
|
+
top: calc(100% + 0.35rem);
|
|
782
|
+
right: -0.25rem;
|
|
783
|
+
width: calc(100vw - 1rem);
|
|
784
|
+
}
|
|
785
|
+
.lazy-hierarchy__top { flex-direction: column; }
|
|
786
|
+
.lazy-hierarchy__modes { flex-direction: row; }
|
|
787
|
+
}
|
|
788
|
+
</style>
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
**Step 2: Run bundler sanity check**
|
|
792
|
+
|
|
793
|
+
Run: `cd "C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils" && npx webpack --mode development 2>&1 | tail -30`
|
|
794
|
+
Expected: bundle builds without syntax errors. If webpack isn't the build tool, check `package.json` scripts and run the equivalent (`npm run build` / `npm run dev`).
|
|
795
|
+
|
|
796
|
+
**Step 3: Verify the new file is syntactically valid by lint**
|
|
797
|
+
|
|
798
|
+
Run: if ESLint is configured (`.eslintrc` present), `npx eslint components/VlLazyHierarchy.vue`.
|
|
799
|
+
Expected: no parse errors. Style warnings acceptable.
|
|
800
|
+
|
|
801
|
+
---
|
|
802
|
+
|
|
803
|
+
## Task 2 — Trim `LazyHierarchy.php` to its generic surface
|
|
804
|
+
|
|
805
|
+
**Files:**
|
|
806
|
+
- Modify: `C:/Users/jkend/Documents/Projects/kompo/utils/src/Kompo/Elements/LazyHierarchy.php`
|
|
807
|
+
|
|
808
|
+
**Step 1: Remove `hierarchyAction()` method**
|
|
809
|
+
|
|
810
|
+
Open the file. Delete the entire method block (currently lines 205–217). This method exposed a framework-level action dispatcher that is no longer used — interactive elements inside each node's `render` carry their own actions.
|
|
811
|
+
|
|
812
|
+
**Step 2: Remove the initial `'classes' => []` / `'labels' => []` empty defaults that are now redundant**
|
|
813
|
+
|
|
814
|
+
Leave the `config([...])` call in `initialize()` intact but drop keys whose purpose no longer maps to any Vue-side consumer. Keep: `rootKey`, `parentParam`, `cursorParam`, `params`, `limit`, `lookahead`, `autoLoad`, `deferLoad`, `displayMode`, `classes`, `labels`. The component still reads all of those.
|
|
815
|
+
|
|
816
|
+
**Step 3: Confirm the full file reads**
|
|
817
|
+
|
|
818
|
+
```php
|
|
819
|
+
<?php
|
|
820
|
+
|
|
821
|
+
namespace Condoedge\Utils\Kompo\Elements;
|
|
822
|
+
|
|
823
|
+
use Condoedge\Utils\Services\LazyHierarchy\LazyHierarchyRegistry;
|
|
824
|
+
use Kompo\Elements\Block;
|
|
825
|
+
use Kompo\Routing\RouteFinder;
|
|
826
|
+
|
|
827
|
+
class LazyHierarchy extends Block
|
|
828
|
+
{
|
|
829
|
+
public $vueComponent = 'LazyHierarchy';
|
|
830
|
+
|
|
831
|
+
public function initialize($label = '')
|
|
832
|
+
{
|
|
833
|
+
parent::initialize($label);
|
|
834
|
+
|
|
835
|
+
$this->config([
|
|
836
|
+
'rootKey' => 'root',
|
|
837
|
+
'parentParam' => 'parent_id',
|
|
838
|
+
'cursorParam' => 'cursor',
|
|
839
|
+
'params' => [],
|
|
840
|
+
'limit' => 20,
|
|
841
|
+
'lookahead' => 80,
|
|
842
|
+
'autoLoad' => true,
|
|
843
|
+
'deferLoad' => false,
|
|
844
|
+
'displayMode' => 'inline',
|
|
845
|
+
'classes' => [],
|
|
846
|
+
'labels' => [],
|
|
847
|
+
]);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
public function source(string $sourceClass, array $store = [])
|
|
851
|
+
{
|
|
852
|
+
$sourceId = app(LazyHierarchyRegistry::class)->storeSource($sourceClass, $store);
|
|
853
|
+
|
|
854
|
+
return $this
|
|
855
|
+
->hierarchySource('utils.lazy-hierarchy.bootstrap', 'utils.lazy-hierarchy.nodes')
|
|
856
|
+
->hierarchyParam('_lazyHierarchyId', $sourceId)
|
|
857
|
+
->config(['sourceClass' => $sourceClass]);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
public function hierarchySource(string $bootstrapRoute, ?string $nodesRoute = null, $bootstrapParameters = null, $nodesParameters = null)
|
|
861
|
+
{
|
|
862
|
+
return $this->hierarchyUrls(
|
|
863
|
+
RouteFinder::guessRoute($bootstrapRoute, $bootstrapParameters),
|
|
864
|
+
RouteFinder::guessRoute($nodesRoute ?: $bootstrapRoute, $nodesParameters),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
public function hierarchyUrls(string $bootstrapUrl, string $nodesUrl)
|
|
869
|
+
{
|
|
870
|
+
return $this->config(['bootstrapUrl' => $bootstrapUrl, 'nodesUrl' => $nodesUrl]);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
public function hierarchyParams(array $params)
|
|
874
|
+
{
|
|
875
|
+
return $this->config(['params' => array_replace($this->config('params') ?: [], $params)]);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
public function hierarchyParam(string $key, mixed $value)
|
|
879
|
+
{
|
|
880
|
+
return $this->hierarchyParams([$key => $value]);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
public function hierarchyPaging(int $limit = 20, int $lookahead = 80)
|
|
884
|
+
{
|
|
885
|
+
return $this->config(['limit' => $limit, 'lookahead' => $lookahead]);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
public function hierarchyParamNames(string $parentParam = 'parent_id', string $cursorParam = 'cursor')
|
|
889
|
+
{
|
|
890
|
+
return $this->config(['parentParam' => $parentParam, 'cursorParam' => $cursorParam]);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
public function hierarchyRootKey(string $rootKey)
|
|
894
|
+
{
|
|
895
|
+
return $this->config(['rootKey' => $rootKey]);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
public function hierarchyIndent(float $base = 1, float $step = 0.9, int $maxDepth = 5)
|
|
899
|
+
{
|
|
900
|
+
return $this->config(['baseIndent' => $base, 'indentStep' => $step, 'maxIndentDepth' => $maxDepth]);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
public function hierarchyLabels(array $labels)
|
|
904
|
+
{
|
|
905
|
+
return $this->config(['labels' => array_replace($this->config('labels') ?: [], $labels)]);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
public function hierarchyClasses(array $classes)
|
|
909
|
+
{
|
|
910
|
+
return $this->config(['classes' => array_replace($this->config('classes') ?: [], $classes)]);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
public function loadDeferred(bool $defer = true)
|
|
914
|
+
{
|
|
915
|
+
return $this->config(['autoLoad' => true, 'deferLoad' => $defer]);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
public function loadImmediately()
|
|
919
|
+
{
|
|
920
|
+
return $this->loadDeferred(false);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
public function manualLoad()
|
|
924
|
+
{
|
|
925
|
+
return $this->config(['autoLoad' => false]);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
public function dropdown(?string $triggerLabel = null)
|
|
929
|
+
{
|
|
930
|
+
return $this->config(array_filter([
|
|
931
|
+
'displayMode' => 'dropdown',
|
|
932
|
+
'triggerLabel' => $triggerLabel,
|
|
933
|
+
'deferLoad' => true,
|
|
934
|
+
], fn($value) => $value !== null));
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
public function inline()
|
|
938
|
+
{
|
|
939
|
+
return $this->config(['displayMode' => 'inline']);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
public function searchable(?string $placeholder = null, string $param = 'search', int $debounce = 350)
|
|
943
|
+
{
|
|
944
|
+
return $this->config([
|
|
945
|
+
'search' => [
|
|
946
|
+
'enabled' => true,
|
|
947
|
+
'placeholder' => $placeholder ?: __('Search'),
|
|
948
|
+
'param' => $param,
|
|
949
|
+
'debounce' => $debounce,
|
|
950
|
+
],
|
|
951
|
+
]);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
public function modes(array $modes, string $param = 'mode', ?string $default = null)
|
|
955
|
+
{
|
|
956
|
+
return $this->config([
|
|
957
|
+
'modes' => [
|
|
958
|
+
'options' => $modes,
|
|
959
|
+
'param' => $param,
|
|
960
|
+
'default' => $default ?: array_key_first($modes),
|
|
961
|
+
],
|
|
962
|
+
]);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
public function triggerLabel(?string $label)
|
|
966
|
+
{
|
|
967
|
+
return $this->config(['triggerLabel' => $label]);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
**Step 4: Grep for any surviving `hierarchyAction` / `lazyLoad` callers**
|
|
973
|
+
|
|
974
|
+
Run: `grep -r "hierarchyAction\|->lazyLoad(" "C:/Users/jkend/Documents/Projects/kompo/"`
|
|
975
|
+
Expected: no matches (the team switcher's `switchAction` will be rewritten in Task 6). If an unrelated caller surfaces, document it in the session and update that caller to the new pattern.
|
|
976
|
+
|
|
977
|
+
---
|
|
978
|
+
|
|
979
|
+
## Task 3 — Introduce `HierarchyNodeContext` value object
|
|
980
|
+
|
|
981
|
+
**Files:**
|
|
982
|
+
- Create: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/HierarchyNodeContext.php`
|
|
983
|
+
|
|
984
|
+
A small immutable carrier for the intermediate team-plus-access state the provider needs for filtering and path logic. Separating it from the factory keeps the factory's sole responsibility "produce wire-format node with composed `render`".
|
|
985
|
+
|
|
986
|
+
**Step 1: Write the class**
|
|
987
|
+
|
|
988
|
+
```php
|
|
989
|
+
<?php
|
|
990
|
+
|
|
991
|
+
namespace Kompo\Auth\Teams;
|
|
992
|
+
|
|
993
|
+
class HierarchyNodeContext
|
|
994
|
+
{
|
|
995
|
+
public function __construct(
|
|
996
|
+
public readonly string $id,
|
|
997
|
+
public readonly int $teamId,
|
|
998
|
+
public readonly ?int $parentId,
|
|
999
|
+
public readonly string $teamName,
|
|
1000
|
+
public readonly ?string $parentName,
|
|
1001
|
+
public readonly bool $isCurrent,
|
|
1002
|
+
public readonly bool $isInCurrentPath,
|
|
1003
|
+
public readonly bool $isCommittee,
|
|
1004
|
+
public readonly bool $isSelectable,
|
|
1005
|
+
public readonly bool $hasChildren,
|
|
1006
|
+
public readonly int $childrenCount,
|
|
1007
|
+
public readonly int $committeeCount,
|
|
1008
|
+
public readonly string $levelLabel,
|
|
1009
|
+
public readonly string $levelKey,
|
|
1010
|
+
public readonly array $roles,
|
|
1011
|
+
public readonly ?array $switchRole,
|
|
1012
|
+
) {}
|
|
1013
|
+
|
|
1014
|
+
public function hasSwitchableRole(): bool
|
|
1015
|
+
{
|
|
1016
|
+
return !empty($this->roles) || $this->switchRole !== null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
public function isVisible(): bool
|
|
1020
|
+
{
|
|
1021
|
+
if ($this->isSelectable) {
|
|
1022
|
+
return $this->hasSwitchableRole() || $this->hasChildren;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return $this->hasChildren;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
public function switchOnlyRole(): ?array
|
|
1029
|
+
{
|
|
1030
|
+
if ($this->switchRole === null) {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
foreach ($this->roles as $role) {
|
|
1035
|
+
if (($role['id'] ?? null) === ($this->switchRole['id'] ?? null)) {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return $this->switchRole;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
**Step 2: Verify PHP syntax**
|
|
1046
|
+
|
|
1047
|
+
Run: `php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/HierarchyNodeContext.php"`
|
|
1048
|
+
Expected: `No syntax errors detected`.
|
|
1049
|
+
|
|
1050
|
+
---
|
|
1051
|
+
|
|
1052
|
+
## Task 4 — Rewrite `TeamRoleSwitcherNodeFactory.php` to emit `{id, parentId, hasChildren, isCurrent, render}`
|
|
1053
|
+
|
|
1054
|
+
**Files:**
|
|
1055
|
+
- Rewrite: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeFactory.php`
|
|
1056
|
+
|
|
1057
|
+
The factory now has two jobs: build a `HierarchyNodeContext` for provider-side filtering, and convert that context to the final wire payload by composing a kompo `_Flex` of `_Html` + `_Button` elements using utility classes for the exact current visual output.
|
|
1058
|
+
|
|
1059
|
+
**Step 1: Replace the file**
|
|
1060
|
+
|
|
1061
|
+
```php
|
|
1062
|
+
<?php
|
|
1063
|
+
|
|
1064
|
+
namespace Kompo\Auth\Teams;
|
|
1065
|
+
|
|
1066
|
+
class TeamRoleSwitcherNodeFactory
|
|
1067
|
+
{
|
|
1068
|
+
private const LEVEL_PILL_BASE = 'inline-block min-w-[3.8rem] px-3 py-1 text-white text-center text-xs font-medium rounded-full whitespace-nowrap';
|
|
1069
|
+
|
|
1070
|
+
private const LEVEL_PILL_BG = [
|
|
1071
|
+
'national' => 'bg-[rgb(255,180,0)]',
|
|
1072
|
+
'district' => 'bg-[rgb(0,146,67)]',
|
|
1073
|
+
'group' => 'bg-[rgb(0,44,222)]',
|
|
1074
|
+
'team' => 'bg-[rgb(0,44,222)]',
|
|
1075
|
+
'unit' => 'bg-[rgb(130,55,255)]',
|
|
1076
|
+
'committee' => 'bg-[rgb(255,180,0)]',
|
|
1077
|
+
];
|
|
1078
|
+
|
|
1079
|
+
private const ROLE_PILL_BASE = 'inline-block max-w-[6rem] overflow-hidden text-ellipsis px-3 py-1 text-xs font-medium rounded-full whitespace-nowrap border-0 cursor-pointer bg-transparent';
|
|
1080
|
+
|
|
1081
|
+
private const ROLE_PILL_CURRENT = 'bg-[var(--lazy-hierarchy-primary)] text-white';
|
|
1082
|
+
private const ROLE_PILL_DEFAULT = 'text-[var(--lazy-hierarchy-primary)] hover:bg-[color-mix(in_srgb,var(--lazy-hierarchy-primary)_8%,white)]';
|
|
1083
|
+
|
|
1084
|
+
private const GO_BUTTON_BASE = 'inline-flex items-center justify-center w-[1.65rem] h-[1.65rem] p-0 border-0 rounded-md bg-transparent text-[var(--lazy-hierarchy-primary)] cursor-pointer hover:bg-[color-mix(in_srgb,var(--lazy-hierarchy-primary)_8%,white)]';
|
|
1085
|
+
|
|
1086
|
+
private const GO_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[1.3rem] h-[1.3rem]"><circle cx="8" cy="7" r="3" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M3.5 18.2c0-3 2-4.8 4.5-4.8s4.5 1.8 4.5 4.8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /><path d="M14 8h5m0 0-2-2m2 2-2 2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>';
|
|
1087
|
+
|
|
1088
|
+
public function __construct(
|
|
1089
|
+
private TeamRoleSwitcherTeamRepository $teams,
|
|
1090
|
+
) {}
|
|
1091
|
+
|
|
1092
|
+
public function context(
|
|
1093
|
+
$team,
|
|
1094
|
+
array $access,
|
|
1095
|
+
string $mode,
|
|
1096
|
+
array $currentPathIds,
|
|
1097
|
+
int $childrenCount,
|
|
1098
|
+
int $committeeCount,
|
|
1099
|
+
$currentTeamRole = null,
|
|
1100
|
+
): HierarchyNodeContext {
|
|
1101
|
+
$teamId = (int) $team->id;
|
|
1102
|
+
$isCommittee = $this->teams->isCommittee($team);
|
|
1103
|
+
$isSelectable = $this->teams->isSelectableForMode($team, $mode);
|
|
1104
|
+
|
|
1105
|
+
return new HierarchyNodeContext(
|
|
1106
|
+
id: $this->nodeId($teamId),
|
|
1107
|
+
teamId: $teamId,
|
|
1108
|
+
parentId: $team->parent_team_id ? (int) $team->parent_team_id : null,
|
|
1109
|
+
teamName: (string) $team->team_name,
|
|
1110
|
+
parentName: $team->relationLoaded('parentTeam') ? $team->parentTeam?->team_name : null,
|
|
1111
|
+
isCurrent: $isSelectable && $currentTeamRole && $currentTeamRole->team_id == $teamId,
|
|
1112
|
+
isInCurrentPath: in_array($teamId, $currentPathIds, true),
|
|
1113
|
+
isCommittee: $isCommittee,
|
|
1114
|
+
isSelectable: $isSelectable,
|
|
1115
|
+
hasChildren: $childrenCount > 0,
|
|
1116
|
+
childrenCount: $childrenCount,
|
|
1117
|
+
committeeCount: $committeeCount,
|
|
1118
|
+
levelLabel: $this->teams->teamLevelLabel($team, $isCommittee),
|
|
1119
|
+
levelKey: $this->teams->teamLevelKey($team, $isCommittee),
|
|
1120
|
+
roles: $access['roles'] ?? [],
|
|
1121
|
+
switchRole: $access['switchRole'] ?? null,
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
public function toPayload(HierarchyNodeContext $ctx, string $switchUrl): array
|
|
1126
|
+
{
|
|
1127
|
+
return [
|
|
1128
|
+
'id' => $ctx->id,
|
|
1129
|
+
'teamId' => $ctx->teamId,
|
|
1130
|
+
'parentId' => $ctx->parentId,
|
|
1131
|
+
'hasChildren' => $ctx->hasChildren,
|
|
1132
|
+
'isCurrent' => $ctx->isCurrent,
|
|
1133
|
+
'render' => $this->composeRender($ctx, $switchUrl),
|
|
1134
|
+
];
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
public function nodeId(int $teamId): string
|
|
1138
|
+
{
|
|
1139
|
+
return 'team-' . $teamId;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private function composeRender(HierarchyNodeContext $ctx, string $switchUrl): array
|
|
1143
|
+
{
|
|
1144
|
+
$name = _Html(e($ctx->teamName))->class('lazy-hierarchy-node__name flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[0.8125rem] font-semibold');
|
|
1145
|
+
|
|
1146
|
+
$meta = _Flex(
|
|
1147
|
+
$this->levelPill($ctx),
|
|
1148
|
+
...$this->roleButtons($ctx, $switchUrl),
|
|
1149
|
+
$this->switchOnlyButton($ctx, $switchUrl),
|
|
1150
|
+
)
|
|
1151
|
+
->class('lazy-hierarchy-node__meta flex items-center justify-end gap-[0.55rem] max-w-[50%] ml-auto');
|
|
1152
|
+
|
|
1153
|
+
return _Flex($name, $meta)
|
|
1154
|
+
->class('flex items-center w-full gap-[0.45rem]')
|
|
1155
|
+
->jsonSerialize();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
private function levelPill(HierarchyNodeContext $ctx)
|
|
1159
|
+
{
|
|
1160
|
+
$bg = self::LEVEL_PILL_BG[$ctx->levelKey] ?? 'bg-[var(--lazy-hierarchy-primary)]';
|
|
1161
|
+
|
|
1162
|
+
return _Html(e($ctx->levelLabel))->class(self::LEVEL_PILL_BASE . ' ' . $bg);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private function roleButtons(HierarchyNodeContext $ctx, string $switchUrl): array
|
|
1166
|
+
{
|
|
1167
|
+
$elements = [];
|
|
1168
|
+
|
|
1169
|
+
foreach ($ctx->roles as $role) {
|
|
1170
|
+
$elements[] = _Flex(
|
|
1171
|
+
$this->roleNameButton($ctx, $role, $switchUrl),
|
|
1172
|
+
$this->goButton($ctx, $role, $switchUrl),
|
|
1173
|
+
)->class('inline-flex items-center gap-[0.45rem]');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return $elements;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
private function roleNameButton(HierarchyNodeContext $ctx, array $role, string $switchUrl)
|
|
1180
|
+
{
|
|
1181
|
+
$current = $role['isCurrent'] ?? false;
|
|
1182
|
+
$extra = $current ? self::ROLE_PILL_CURRENT : self::ROLE_PILL_DEFAULT;
|
|
1183
|
+
|
|
1184
|
+
return _Button($role['label'] ?? '')
|
|
1185
|
+
->class(self::ROLE_PILL_BASE . ' ' . $extra)
|
|
1186
|
+
->post($switchUrl, null, ['team_id' => $ctx->teamId, 'role_id' => $role['id'] ?? null])
|
|
1187
|
+
->run("() => location.reload()");
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
private function goButton(HierarchyNodeContext $ctx, array $role, string $switchUrl)
|
|
1191
|
+
{
|
|
1192
|
+
return _Button()
|
|
1193
|
+
->icon(self::GO_ICON_SVG)
|
|
1194
|
+
->class(self::GO_BUTTON_BASE)
|
|
1195
|
+
->attr(['title' => __('auth.switcher-switch-role'), 'aria-label' => __('auth.switcher-switch-role')])
|
|
1196
|
+
->post($switchUrl, null, ['team_id' => $ctx->teamId, 'role_id' => $role['id'] ?? null])
|
|
1197
|
+
->run("() => location.reload()");
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
private function switchOnlyButton(HierarchyNodeContext $ctx, string $switchUrl)
|
|
1201
|
+
{
|
|
1202
|
+
$role = $ctx->switchOnlyRole();
|
|
1203
|
+
|
|
1204
|
+
if ($role === null) {
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
$title = isset($role['label']) && $role['label'] !== ''
|
|
1209
|
+
? __('auth.switcher-switch-role') . ': ' . $role['label']
|
|
1210
|
+
: __('auth.switcher-switch-role');
|
|
1211
|
+
|
|
1212
|
+
return _Button()
|
|
1213
|
+
->icon(self::GO_ICON_SVG)
|
|
1214
|
+
->class(self::GO_BUTTON_BASE . ' lazy-hierarchy-node__go--standalone')
|
|
1215
|
+
->attr(['title' => $title, 'aria-label' => $title])
|
|
1216
|
+
->post($switchUrl, null, ['team_id' => $ctx->teamId, 'role_id' => $role['id'] ?? null])
|
|
1217
|
+
->run("() => location.reload()");
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
**Step 2: Verify PHP syntax**
|
|
1223
|
+
|
|
1224
|
+
Run: `php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeFactory.php"`
|
|
1225
|
+
Expected: `No syntax errors detected`.
|
|
1226
|
+
|
|
1227
|
+
**Step 3: Handle the fact that `_Flex(..., null, …)` may include a null**
|
|
1228
|
+
|
|
1229
|
+
`_Flex` with a `null` argument may error. The `switchOnlyButton` can return `null`; adjust the spread in `composeRender` to filter nulls before passing to `_Flex`:
|
|
1230
|
+
|
|
1231
|
+
Replace in `composeRender`:
|
|
1232
|
+
```php
|
|
1233
|
+
$meta = _Flex(
|
|
1234
|
+
$this->levelPill($ctx),
|
|
1235
|
+
...$this->roleButtons($ctx, $switchUrl),
|
|
1236
|
+
$this->switchOnlyButton($ctx, $switchUrl),
|
|
1237
|
+
)
|
|
1238
|
+
```
|
|
1239
|
+
with:
|
|
1240
|
+
```php
|
|
1241
|
+
$metaChildren = array_filter([
|
|
1242
|
+
$this->levelPill($ctx),
|
|
1243
|
+
...$this->roleButtons($ctx, $switchUrl),
|
|
1244
|
+
$this->switchOnlyButton($ctx, $switchUrl),
|
|
1245
|
+
]);
|
|
1246
|
+
|
|
1247
|
+
$meta = _Flex(...$metaChildren)
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
**Step 4: Verify the `_Flex()->jsonSerialize()` trick works**
|
|
1251
|
+
|
|
1252
|
+
The `render` field must serialize to the same nested structure kompo normally emits for a PHP element. `BaseElement::jsonSerialize()` produces the `{vueComponent, config, elements, class, …}` shape that `VlLazyHierarchy`'s `kompoElementAttributes` expects. Confirm this by looking at an existing caller such as `Collapsible::titleEl()` in `C:/Users/jkend/Documents/Projects/kompo/utils/src/Kompo/Elements/Collapsible.php` — locate where a rendered element is stored into config, and confirm whether `->jsonSerialize()`, `->preview()`, or just the object itself is stored.
|
|
1253
|
+
|
|
1254
|
+
If `titleEl` stores the element object directly (no `jsonSerialize()` call), change `composeRender` to `return _Flex($name, $meta)->class(...)` (returning the object) — PHP's `json_encode` on the whole payload will walk into it. If `titleEl` explicitly calls a serializer, mirror that.
|
|
1255
|
+
|
|
1256
|
+
---
|
|
1257
|
+
|
|
1258
|
+
## Task 5 — Update `TeamRoleSwitcherNodeProvider.php` to use the split factory
|
|
1259
|
+
|
|
1260
|
+
**Files:**
|
|
1261
|
+
- Modify: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeProvider.php`
|
|
1262
|
+
|
|
1263
|
+
The provider now walks teams, builds contexts, filters, then converts the surviving contexts to payload nodes via the factory. The filtering helpers `isVisibleNode` / `hasSwitchableRole` move into `HierarchyNodeContext` (already done in Task 3).
|
|
1264
|
+
|
|
1265
|
+
**Step 1: Replace the file's contents with:**
|
|
1266
|
+
|
|
1267
|
+
```php
|
|
1268
|
+
<?php
|
|
1269
|
+
|
|
1270
|
+
namespace Kompo\Auth\Teams;
|
|
1271
|
+
|
|
1272
|
+
use Condoedge\Utils\Kompo\LazyHierarchy\LazyHierarchyPayload;
|
|
1273
|
+
use Illuminate\Support\Collection;
|
|
1274
|
+
use Kompo\Auth\Teams\Contracts\TeamHierarchyInterface;
|
|
1275
|
+
use Kompo\Auth\Teams\Contracts\TeamRoleAccessResolverInterface;
|
|
1276
|
+
|
|
1277
|
+
class TeamRoleSwitcherNodeProvider
|
|
1278
|
+
{
|
|
1279
|
+
private const ROOT_KEY = LazyHierarchyPayload::ROOT_KEY;
|
|
1280
|
+
private const DEFAULT_LIMIT = 20;
|
|
1281
|
+
private const MAX_LIMIT = 50;
|
|
1282
|
+
private const LOOKAHEAD_DEPTH = 1;
|
|
1283
|
+
private const DEFAULT_LOOKAHEAD_BUDGET = 80;
|
|
1284
|
+
private const MAX_SCAN_PAGES = 6;
|
|
1285
|
+
private const MAX_SEARCH_SCAN_PAGES = 30;
|
|
1286
|
+
|
|
1287
|
+
public function __construct(
|
|
1288
|
+
private TeamRoleAccessResolverInterface $access,
|
|
1289
|
+
private TeamRoleSwitcherTeamRepository $teams,
|
|
1290
|
+
private TeamRoleSwitcherNodeFactory $nodes,
|
|
1291
|
+
private TeamHierarchyInterface $hierarchy,
|
|
1292
|
+
private string $switchUrl,
|
|
1293
|
+
) {}
|
|
1294
|
+
|
|
1295
|
+
public function bootstrap($user, int|string|null $profile, string $mode, int $limit, int $lookaheadBudget): array
|
|
1296
|
+
{
|
|
1297
|
+
$mode = $this->normalizeMode($mode);
|
|
1298
|
+
$limit = $this->normalizeLimit($limit);
|
|
1299
|
+
$lookaheadBudget = max($limit, min(120, $lookaheadBudget ?: self::DEFAULT_LOOKAHEAD_BUDGET));
|
|
1300
|
+
|
|
1301
|
+
$payload = $this->emptyPayload($mode);
|
|
1302
|
+
$nodeBudget = $lookaheadBudget;
|
|
1303
|
+
$currentPathIds = $this->currentPathIds();
|
|
1304
|
+
|
|
1305
|
+
$this->appendLevel(
|
|
1306
|
+
payload: $payload,
|
|
1307
|
+
user: $user,
|
|
1308
|
+
profile: $profile,
|
|
1309
|
+
mode: $mode,
|
|
1310
|
+
parentId: null,
|
|
1311
|
+
limit: $limit,
|
|
1312
|
+
cursor: null,
|
|
1313
|
+
lookaheadDepth: self::LOOKAHEAD_DEPTH,
|
|
1314
|
+
nodeBudget: $nodeBudget,
|
|
1315
|
+
currentPathIds: $currentPathIds,
|
|
1316
|
+
);
|
|
1317
|
+
|
|
1318
|
+
$this->appendCurrentPath($payload, $user, $profile, $mode, $limit, $currentPathIds, $nodeBudget);
|
|
1319
|
+
|
|
1320
|
+
return $payload->toArray();
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
public function children(
|
|
1324
|
+
$user,
|
|
1325
|
+
int|string|null $profile,
|
|
1326
|
+
string $mode,
|
|
1327
|
+
?int $parentId,
|
|
1328
|
+
int $limit,
|
|
1329
|
+
?int $cursor,
|
|
1330
|
+
int $lookaheadBudget,
|
|
1331
|
+
): array {
|
|
1332
|
+
$mode = $this->normalizeMode($mode);
|
|
1333
|
+
$limit = $this->normalizeLimit($limit);
|
|
1334
|
+
$nodeBudget = max($limit, min(120, $lookaheadBudget ?: self::DEFAULT_LOOKAHEAD_BUDGET));
|
|
1335
|
+
$payload = $this->emptyPayload($mode, $parentId === null);
|
|
1336
|
+
|
|
1337
|
+
$this->appendLevel(
|
|
1338
|
+
payload: $payload,
|
|
1339
|
+
user: $user,
|
|
1340
|
+
profile: $profile,
|
|
1341
|
+
mode: $mode,
|
|
1342
|
+
parentId: $parentId,
|
|
1343
|
+
limit: $limit,
|
|
1344
|
+
cursor: $cursor,
|
|
1345
|
+
lookaheadDepth: self::LOOKAHEAD_DEPTH,
|
|
1346
|
+
nodeBudget: $nodeBudget,
|
|
1347
|
+
currentPathIds: $this->currentPathIds(),
|
|
1348
|
+
append: (int) ($cursor ?? 0) > 0,
|
|
1349
|
+
);
|
|
1350
|
+
|
|
1351
|
+
return $payload->toArray();
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
public function search($user, int|string|null $profile, string $mode, string $search, int $limit): array
|
|
1355
|
+
{
|
|
1356
|
+
$mode = $this->normalizeMode($mode);
|
|
1357
|
+
$limit = $this->normalizeLimit($limit);
|
|
1358
|
+
$payload = $this->emptyPayload($mode);
|
|
1359
|
+
$search = trim($search);
|
|
1360
|
+
|
|
1361
|
+
if ($search === '') {
|
|
1362
|
+
return $this->bootstrap($user, $profile, $mode, $limit, self::DEFAULT_LOOKAHEAD_BUDGET);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
$currentPathIds = $this->currentPathIds();
|
|
1366
|
+
$contexts = collect();
|
|
1367
|
+
$offset = 0;
|
|
1368
|
+
$scanPages = 0;
|
|
1369
|
+
$take = max($limit * 3, self::MAX_LIMIT);
|
|
1370
|
+
|
|
1371
|
+
while ($contexts->count() < $limit && $scanPages < self::MAX_SEARCH_SCAN_PAGES) {
|
|
1372
|
+
$teams = $this->teams->search($mode, $search, $take, $offset);
|
|
1373
|
+
|
|
1374
|
+
if ($teams->isEmpty()) {
|
|
1375
|
+
break;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
$decorated = $this->decorateTeams($teams, $user, $profile, $mode, $currentPathIds)
|
|
1379
|
+
->filter(fn(HierarchyNodeContext $ctx) => $ctx->isSelectable && $ctx->hasSwitchableRole());
|
|
1380
|
+
|
|
1381
|
+
$contexts = $contexts->concat($decorated)->take($limit)->values();
|
|
1382
|
+
$offset += $teams->count();
|
|
1383
|
+
$scanPages++;
|
|
1384
|
+
|
|
1385
|
+
if ($teams->count() < $take) {
|
|
1386
|
+
break;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
foreach ($contexts as $ctx) {
|
|
1391
|
+
$node = $this->nodes->toPayload($ctx, $this->switchUrl);
|
|
1392
|
+
$payload->addNode($node)->appendChild(self::ROOT_KEY, $node['id']);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
$payload->setPaging(self::ROOT_KEY, null, $contexts->count(), $limit);
|
|
1396
|
+
|
|
1397
|
+
return $payload->toArray();
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
private function appendLevel(
|
|
1401
|
+
LazyHierarchyPayload $payload,
|
|
1402
|
+
$user,
|
|
1403
|
+
int|string|null $profile,
|
|
1404
|
+
string $mode,
|
|
1405
|
+
?int $parentId,
|
|
1406
|
+
int $limit,
|
|
1407
|
+
?int $cursor,
|
|
1408
|
+
int $lookaheadDepth,
|
|
1409
|
+
int &$nodeBudget,
|
|
1410
|
+
array $currentPathIds,
|
|
1411
|
+
bool $append = false,
|
|
1412
|
+
): void {
|
|
1413
|
+
if ($nodeBudget <= 0) {
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
$parentKey = $this->parentKey($parentId);
|
|
1418
|
+
$page = $this->visibleChildrenPage($user, $profile, $mode, $parentId, $limit, $cursor, $currentPathIds);
|
|
1419
|
+
$nodeIds = [];
|
|
1420
|
+
|
|
1421
|
+
foreach ($page['contexts'] as $ctx) {
|
|
1422
|
+
if ($nodeBudget <= 0) {
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
$node = $this->nodes->toPayload($ctx, $this->switchUrl);
|
|
1427
|
+
$payload->addNode($node);
|
|
1428
|
+
$nodeIds[] = $node['id'];
|
|
1429
|
+
$nodeBudget--;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
$payload
|
|
1433
|
+
->setChildren($parentKey, $nodeIds, $append)
|
|
1434
|
+
->setPaging($parentKey, $page['nextCursor'], $page['total'], $limit, $append);
|
|
1435
|
+
|
|
1436
|
+
if ($lookaheadDepth <= 0) {
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
foreach ($page['contexts'] as $ctx) {
|
|
1441
|
+
if ($nodeBudget <= 0) {
|
|
1442
|
+
break;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
if (!$ctx->hasChildren) {
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
$this->appendLevel(
|
|
1450
|
+
payload: $payload,
|
|
1451
|
+
user: $user,
|
|
1452
|
+
profile: $profile,
|
|
1453
|
+
mode: $mode,
|
|
1454
|
+
parentId: $ctx->teamId,
|
|
1455
|
+
limit: $limit,
|
|
1456
|
+
cursor: null,
|
|
1457
|
+
lookaheadDepth: $lookaheadDepth - 1,
|
|
1458
|
+
nodeBudget: $nodeBudget,
|
|
1459
|
+
currentPathIds: $currentPathIds,
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
private function visibleChildrenPage(
|
|
1465
|
+
$user,
|
|
1466
|
+
int|string|null $profile,
|
|
1467
|
+
string $mode,
|
|
1468
|
+
?int $parentId,
|
|
1469
|
+
int $limit,
|
|
1470
|
+
?int $cursor,
|
|
1471
|
+
array $currentPathIds,
|
|
1472
|
+
): array {
|
|
1473
|
+
$offset = max(0, (int) ($cursor ?? 0));
|
|
1474
|
+
$scanOffset = $offset;
|
|
1475
|
+
$visible = collect();
|
|
1476
|
+
$scanPages = 0;
|
|
1477
|
+
$total = $this->teams->childrenTotal($mode, $parentId);
|
|
1478
|
+
|
|
1479
|
+
while ($visible->count() < $limit && $scanPages < self::MAX_SCAN_PAGES) {
|
|
1480
|
+
$take = max($limit * 2, self::DEFAULT_LIMIT);
|
|
1481
|
+
$rawTeams = $this->teams->children($mode, $parentId, $take, $scanOffset);
|
|
1482
|
+
|
|
1483
|
+
if ($rawTeams->isEmpty()) {
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
$decorated = $this->decorateTeams($rawTeams, $user, $profile, $mode, $currentPathIds)
|
|
1488
|
+
->filter(fn(HierarchyNodeContext $ctx) => $ctx->isVisible());
|
|
1489
|
+
|
|
1490
|
+
$visible = $visible->concat($decorated)->take($limit)->values();
|
|
1491
|
+
$scanOffset += $rawTeams->count();
|
|
1492
|
+
$scanPages++;
|
|
1493
|
+
|
|
1494
|
+
if ($rawTeams->count() < $take) {
|
|
1495
|
+
break;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
return [
|
|
1500
|
+
'contexts' => $visible,
|
|
1501
|
+
'nextCursor' => $scanOffset < $total ? $scanOffset : null,
|
|
1502
|
+
'total' => $total,
|
|
1503
|
+
];
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
private function appendCurrentPath(
|
|
1507
|
+
LazyHierarchyPayload $payload,
|
|
1508
|
+
$user,
|
|
1509
|
+
int|string|null $profile,
|
|
1510
|
+
string $mode,
|
|
1511
|
+
int $limit,
|
|
1512
|
+
array $currentPathIds,
|
|
1513
|
+
int &$nodeBudget,
|
|
1514
|
+
): void {
|
|
1515
|
+
if (empty($currentPathIds)) {
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
$teamsById = $this->teams->findMany($currentPathIds)->keyBy('id');
|
|
1520
|
+
|
|
1521
|
+
if ($teamsById->isEmpty()) {
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
$currentTeam = $teamsById->get((int) end($currentPathIds));
|
|
1526
|
+
|
|
1527
|
+
if (!$currentTeam || !$this->teams->isSelectableForMode($currentTeam, $mode)) {
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
$contextsByTeamId = $this->decorateTeams($teamsById->values(), $user, $profile, $mode, $currentPathIds)
|
|
1532
|
+
->keyBy(fn(HierarchyNodeContext $ctx) => $ctx->teamId);
|
|
1533
|
+
|
|
1534
|
+
$currentCtx = $contextsByTeamId->get((int) end($currentPathIds));
|
|
1535
|
+
|
|
1536
|
+
if (!$currentCtx || !$currentCtx->hasSwitchableRole()) {
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
foreach ($currentPathIds as $index => $teamId) {
|
|
1541
|
+
$ctx = $contextsByTeamId->get($teamId);
|
|
1542
|
+
|
|
1543
|
+
if (!$ctx) {
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
$node = $this->nodes->toPayload($ctx, $this->switchUrl);
|
|
1548
|
+
$payload->addNode($node);
|
|
1549
|
+
|
|
1550
|
+
$parentId = $index === 0 ? null : (int) $currentPathIds[$index - 1];
|
|
1551
|
+
$payload->prependChild($this->parentKey($parentId), $node['id']);
|
|
1552
|
+
|
|
1553
|
+
if ($index < count($currentPathIds) - 1) {
|
|
1554
|
+
$payload->expand($node['id']);
|
|
1555
|
+
$this->appendLevel(
|
|
1556
|
+
payload: $payload,
|
|
1557
|
+
user: $user,
|
|
1558
|
+
profile: $profile,
|
|
1559
|
+
mode: $mode,
|
|
1560
|
+
parentId: $teamId,
|
|
1561
|
+
limit: $limit,
|
|
1562
|
+
cursor: null,
|
|
1563
|
+
lookaheadDepth: 0,
|
|
1564
|
+
nodeBudget: $nodeBudget,
|
|
1565
|
+
currentPathIds: $currentPathIds,
|
|
1566
|
+
);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
private function decorateTeams(Collection $teams, $user, int|string|null $profile, string $mode, array $currentPathIds): Collection
|
|
1572
|
+
{
|
|
1573
|
+
if ($teams->isEmpty()) {
|
|
1574
|
+
return collect();
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
$currentTeamRole = function_exists('currentTeamRole') ? currentTeamRole() : null;
|
|
1578
|
+
$accessByTeam = $this->access->resolveForCandidates($teams, $user, $profile, $mode, $currentTeamRole);
|
|
1579
|
+
$visibleChildrenCounts = $mode === TeamAccessHierarchyBuilder::MODE_TEAMS
|
|
1580
|
+
? $this->visibleDirectChildrenCounts($teams->pluck('id'), $user, $profile, $mode)
|
|
1581
|
+
: [];
|
|
1582
|
+
|
|
1583
|
+
return $teams->map(function ($team) use ($accessByTeam, $visibleChildrenCounts, $mode, $currentPathIds, $currentTeamRole) {
|
|
1584
|
+
$teamId = (int) $team->id;
|
|
1585
|
+
$isSelectable = $this->teams->isSelectableForMode($team, $mode);
|
|
1586
|
+
$access = $isSelectable ? ($accessByTeam[$teamId] ?? []) : [];
|
|
1587
|
+
$childrenCount = $mode === TeamAccessHierarchyBuilder::MODE_TEAMS
|
|
1588
|
+
? (int) ($visibleChildrenCounts[$teamId] ?? 0)
|
|
1589
|
+
: $this->teams->countDirectChildren($teamId, $mode);
|
|
1590
|
+
$committeeCount = $mode === TeamAccessHierarchyBuilder::MODE_COMMITTEES
|
|
1591
|
+
? $this->teams->countDirectCommittees($teamId)
|
|
1592
|
+
: 0;
|
|
1593
|
+
|
|
1594
|
+
return $this->nodes->context(
|
|
1595
|
+
team: $team,
|
|
1596
|
+
access: $access,
|
|
1597
|
+
mode: $mode,
|
|
1598
|
+
currentPathIds: $currentPathIds,
|
|
1599
|
+
childrenCount: $childrenCount,
|
|
1600
|
+
committeeCount: $committeeCount,
|
|
1601
|
+
currentTeamRole: $currentTeamRole,
|
|
1602
|
+
);
|
|
1603
|
+
})->values();
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
private function visibleDirectChildrenCounts(Collection $parentIds, $user, int|string|null $profile, string $mode): array
|
|
1607
|
+
{
|
|
1608
|
+
$parentIds = $parentIds->map(fn($id) => (int) $id)->filter()->unique()->values();
|
|
1609
|
+
|
|
1610
|
+
if ($parentIds->isEmpty()) {
|
|
1611
|
+
return [];
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
$query = $this->teams->teamQuery()->whereIn('teams.parent_team_id', $parentIds->all());
|
|
1615
|
+
$this->teams->applyModeFilter($query, $mode);
|
|
1616
|
+
$children = $query->get();
|
|
1617
|
+
|
|
1618
|
+
if ($children->isEmpty()) {
|
|
1619
|
+
return [];
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
$accessByChild = $this->access->resolveForCandidates($children, $user, $profile, $mode, includeCurrentRole: false);
|
|
1623
|
+
$activeRoleBranchIndex = $this->access->activeRoleBranchIndex($user, $profile, $mode);
|
|
1624
|
+
$counts = [];
|
|
1625
|
+
|
|
1626
|
+
foreach ($children as $child) {
|
|
1627
|
+
$childId = (int) $child->id;
|
|
1628
|
+
$parentId = $child->parent_team_id ? (int) $child->parent_team_id : null;
|
|
1629
|
+
|
|
1630
|
+
if (!$parentId) {
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
$childAccess = $this->teams->isSelectableForMode($child, $mode) ? ($accessByChild[$childId] ?? []) : [];
|
|
1635
|
+
|
|
1636
|
+
if (empty($childAccess['roles']) && empty($childAccess['switchRole']) && !$activeRoleBranchIndex->has($childId)) {
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
$counts[$parentId] = ($counts[$parentId] ?? 0) + 1;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
return $counts;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
private function currentPathIds(): array
|
|
1647
|
+
{
|
|
1648
|
+
$currentTeamId = currentTeamId();
|
|
1649
|
+
|
|
1650
|
+
if (!$currentTeamId) {
|
|
1651
|
+
return [];
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
return $this->hierarchy
|
|
1655
|
+
->getAncestorTeamIds($currentTeamId)
|
|
1656
|
+
->map(fn($id) => (int) $id)
|
|
1657
|
+
->values()
|
|
1658
|
+
->all();
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
private function emptyPayload(string $mode, bool $includeRoot = true): LazyHierarchyPayload
|
|
1662
|
+
{
|
|
1663
|
+
return LazyHierarchyPayload::make(['mode' => $mode], self::ROOT_KEY, $includeRoot);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
private function normalizeMode(?string $mode): string
|
|
1667
|
+
{
|
|
1668
|
+
return $mode === TeamAccessHierarchyBuilder::MODE_COMMITTEES
|
|
1669
|
+
? TeamAccessHierarchyBuilder::MODE_COMMITTEES
|
|
1670
|
+
: TeamAccessHierarchyBuilder::MODE_TEAMS;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
private function normalizeLimit(int $limit): int
|
|
1674
|
+
{
|
|
1675
|
+
return max(1, min(self::MAX_LIMIT, $limit ?: self::DEFAULT_LIMIT));
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
private function parentKey(?int $parentId): string
|
|
1679
|
+
{
|
|
1680
|
+
return $parentId ? $this->nodes->nodeId($parentId) : self::ROOT_KEY;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
```
|
|
1684
|
+
|
|
1685
|
+
**Step 2: The provider now takes `$switchUrl` in its constructor**
|
|
1686
|
+
|
|
1687
|
+
This changes its DI signature. The `TeamRoleSwitcherHierarchySource` instantiates the provider indirectly via the container. Since the provider is built by Laravel DI, arrange for `$switchUrl` to be resolved from the source's `$store`. Update the source (Task 6 below) to pass it through.
|
|
1688
|
+
|
|
1689
|
+
**Step 3: Verify PHP syntax**
|
|
1690
|
+
|
|
1691
|
+
Run: `php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherNodeProvider.php"`
|
|
1692
|
+
Expected: `No syntax errors detected`.
|
|
1693
|
+
|
|
1694
|
+
---
|
|
1695
|
+
|
|
1696
|
+
## Task 6 — Simplify `TeamRoleSwitcherElement.php` and wire `$switchUrl` into the source store
|
|
1697
|
+
|
|
1698
|
+
**Files:**
|
|
1699
|
+
- Rewrite: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherElement.php`
|
|
1700
|
+
- Modify: `C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherHierarchySource.php`
|
|
1701
|
+
|
|
1702
|
+
The element no longer carries `current`, `profile`, or domain class overrides because those values now flow through the source's `$store` and land directly in the composed node render trees. The element's only job is: wire the source, enable search, expose modes, pick dropdown vs inline, and set a trigger label.
|
|
1703
|
+
|
|
1704
|
+
**Step 1: Rewrite the element**
|
|
1705
|
+
|
|
1706
|
+
```php
|
|
1707
|
+
<?php
|
|
1708
|
+
|
|
1709
|
+
namespace Kompo\Auth\Teams;
|
|
1710
|
+
|
|
1711
|
+
use Condoedge\Utils\Kompo\Elements\LazyHierarchy;
|
|
1712
|
+
|
|
1713
|
+
class TeamRoleSwitcherElement extends LazyHierarchy
|
|
1714
|
+
{
|
|
1715
|
+
public function initialize($label = '')
|
|
1716
|
+
{
|
|
1717
|
+
parent::initialize($label);
|
|
1718
|
+
|
|
1719
|
+
$currentTeamRole = currentTeamRole();
|
|
1720
|
+
$profile = $currentTeamRole?->roleRelation?->profile ?? 1;
|
|
1721
|
+
$switchUrl = route('team-role-switcher.switch');
|
|
1722
|
+
|
|
1723
|
+
$this
|
|
1724
|
+
->source(TeamRoleSwitcherHierarchySource::class, ['switchUrl' => $switchUrl])
|
|
1725
|
+
->hierarchyPaging(20, 80)
|
|
1726
|
+
->dropdown($currentTeamRole?->getRoleName())
|
|
1727
|
+
->searchable(__('auth.search-placeholder'))
|
|
1728
|
+
->modes([
|
|
1729
|
+
TeamAccessHierarchyBuilder::MODE_TEAMS => __('auth.switcher-teams'),
|
|
1730
|
+
TeamAccessHierarchyBuilder::MODE_COMMITTEES => __('auth.switcher-committees'),
|
|
1731
|
+
], 'mode', TeamAccessHierarchyBuilder::MODE_TEAMS)
|
|
1732
|
+
->hierarchyParam('profile', $profile)
|
|
1733
|
+
->hierarchyLabels([
|
|
1734
|
+
'searchPlaceholder' => __('auth.search-placeholder'),
|
|
1735
|
+
'loading' => __('auth.switcher-loading'),
|
|
1736
|
+
'empty' => __('auth.switcher-empty'),
|
|
1737
|
+
'error' => __('auth.switcher-error'),
|
|
1738
|
+
'showMore' => __('auth.switcher-show-more'),
|
|
1739
|
+
])
|
|
1740
|
+
->class('kompo-team-role-switcher-shell');
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
```
|
|
1744
|
+
|
|
1745
|
+
**Step 2: Update `TeamRoleSwitcherHierarchySource` to pass `$switchUrl` from its `$store` into a fresh provider instance**
|
|
1746
|
+
|
|
1747
|
+
```php
|
|
1748
|
+
<?php
|
|
1749
|
+
|
|
1750
|
+
namespace Kompo\Auth\Teams;
|
|
1751
|
+
|
|
1752
|
+
use Condoedge\Utils\Contracts\LazyHierarchySourceInterface;
|
|
1753
|
+
use Illuminate\Http\Request;
|
|
1754
|
+
use Illuminate\Validation\Rule;
|
|
1755
|
+
|
|
1756
|
+
class TeamRoleSwitcherHierarchySource implements LazyHierarchySourceInterface
|
|
1757
|
+
{
|
|
1758
|
+
private TeamRoleSwitcherNodeProvider $provider;
|
|
1759
|
+
|
|
1760
|
+
public function __construct(
|
|
1761
|
+
TeamRoleAccessResolverInterface $access,
|
|
1762
|
+
TeamRoleSwitcherTeamRepository $teams,
|
|
1763
|
+
TeamRoleSwitcherNodeFactory $nodes,
|
|
1764
|
+
TeamHierarchyInterface $hierarchy,
|
|
1765
|
+
protected array $store = [],
|
|
1766
|
+
) {
|
|
1767
|
+
$switchUrl = (string) ($store['switchUrl'] ?? route('team-role-switcher.switch'));
|
|
1768
|
+
|
|
1769
|
+
$this->provider = new TeamRoleSwitcherNodeProvider(
|
|
1770
|
+
$access,
|
|
1771
|
+
$teams,
|
|
1772
|
+
$nodes,
|
|
1773
|
+
$hierarchy,
|
|
1774
|
+
$switchUrl,
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
public function bootstrap(Request $request): array
|
|
1779
|
+
{
|
|
1780
|
+
$data = $this->validatedListRequest($request);
|
|
1781
|
+
|
|
1782
|
+
if ($data['search'] !== '') {
|
|
1783
|
+
return $this->provider->search(
|
|
1784
|
+
$request->user(),
|
|
1785
|
+
$data['profile'],
|
|
1786
|
+
$data['mode'],
|
|
1787
|
+
$data['search'],
|
|
1788
|
+
$data['limit'],
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
return $this->provider->bootstrap(
|
|
1793
|
+
$request->user(),
|
|
1794
|
+
$data['profile'],
|
|
1795
|
+
$data['mode'],
|
|
1796
|
+
$data['limit'],
|
|
1797
|
+
$data['lookahead'],
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
public function children(Request $request): array
|
|
1802
|
+
{
|
|
1803
|
+
$data = $this->validatedListRequest($request, [
|
|
1804
|
+
'parent_id' => ['nullable', 'integer', 'min:1', 'exists:teams,id'],
|
|
1805
|
+
'cursor' => ['nullable', 'integer', 'min:0'],
|
|
1806
|
+
]);
|
|
1807
|
+
|
|
1808
|
+
return $this->provider->children(
|
|
1809
|
+
$request->user(),
|
|
1810
|
+
$data['profile'],
|
|
1811
|
+
$data['mode'],
|
|
1812
|
+
$data['parent_id'] ?? null,
|
|
1813
|
+
$data['limit'],
|
|
1814
|
+
$data['cursor'] ?? null,
|
|
1815
|
+
$data['lookahead'],
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
protected function validatedListRequest(Request $request, array $extraRules = []): array
|
|
1820
|
+
{
|
|
1821
|
+
abort_unless($request->user(), 403);
|
|
1822
|
+
|
|
1823
|
+
$data = $request->validate(array_merge([
|
|
1824
|
+
'mode' => ['nullable', Rule::in([
|
|
1825
|
+
TeamAccessHierarchyBuilder::MODE_TEAMS,
|
|
1826
|
+
TeamAccessHierarchyBuilder::MODE_COMMITTEES,
|
|
1827
|
+
])],
|
|
1828
|
+
'profile' => ['nullable', 'string', 'max:50'],
|
|
1829
|
+
'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
|
|
1830
|
+
'lookahead' => ['nullable', 'integer', 'min:20', 'max:120'],
|
|
1831
|
+
'search' => ['nullable', 'string', 'max:100'],
|
|
1832
|
+
], $extraRules));
|
|
1833
|
+
|
|
1834
|
+
return [
|
|
1835
|
+
'mode' => $data['mode'] ?? TeamAccessHierarchyBuilder::MODE_TEAMS,
|
|
1836
|
+
'profile' => $data['profile'] ?? currentTeamRole()?->roleRelation?->profile ?? 1,
|
|
1837
|
+
'limit' => (int) ($data['limit'] ?? 20),
|
|
1838
|
+
'lookahead' => (int) ($data['lookahead'] ?? 80),
|
|
1839
|
+
'search' => trim((string) ($data['search'] ?? '')),
|
|
1840
|
+
] + $data;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
```
|
|
1844
|
+
|
|
1845
|
+
Add the needed `use` statements at the top:
|
|
1846
|
+
```php
|
|
1847
|
+
use Kompo\Auth\Teams\Contracts\TeamHierarchyInterface;
|
|
1848
|
+
use Kompo\Auth\Teams\Contracts\TeamRoleAccessResolverInterface;
|
|
1849
|
+
```
|
|
1850
|
+
|
|
1851
|
+
**Step 3: Verify the `LazyHierarchyRegistry` instantiates the source with the store**
|
|
1852
|
+
|
|
1853
|
+
Open `C:/Users/jkend/Documents/Projects/kompo/utils/src/Services/LazyHierarchy/LazyHierarchyRegistry.php` and confirm that when it resolves a stored source class, it passes `$store` as a constructor argument (likely via `app()->makeWith($class, ['store' => $store])`). If the resolution path does not currently pass `$store`, update the registry to do so — the factory/source rewrite depends on it.
|
|
1854
|
+
|
|
1855
|
+
**Step 4: PHP lint all three files**
|
|
1856
|
+
|
|
1857
|
+
Run:
|
|
1858
|
+
```
|
|
1859
|
+
php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherElement.php"
|
|
1860
|
+
php -l "C:/Users/jkend/Documents/Projects/kompo/auth/src/Teams/TeamRoleSwitcherHierarchySource.php"
|
|
1861
|
+
php -l "C:/Users/jkend/Documents/Projects/kompo/utils/src/Services/LazyHierarchy/LazyHierarchyRegistry.php"
|
|
1862
|
+
```
|
|
1863
|
+
Expected: all three report `No syntax errors detected`.
|
|
1864
|
+
|
|
1865
|
+
---
|
|
1866
|
+
|
|
1867
|
+
## Task 7 — Feature test: payload shape & render composition
|
|
1868
|
+
|
|
1869
|
+
**Files:**
|
|
1870
|
+
- Create: `C:/Users/jkend/Documents/Projects/kompo/auth/tests/Feature/Teams/TeamRoleSwitcherHierarchyPayloadTest.php`
|
|
1871
|
+
|
|
1872
|
+
The Vue side doesn't have unit tests (manual browser verification in Task 8), but the server payload is the contract — write feature tests that assert exact wire shape for representative cases.
|
|
1873
|
+
|
|
1874
|
+
**Step 1: Write the failing test**
|
|
1875
|
+
|
|
1876
|
+
```php
|
|
1877
|
+
<?php
|
|
1878
|
+
|
|
1879
|
+
namespace Tests\Feature\Teams;
|
|
1880
|
+
|
|
1881
|
+
use Tests\TestCase;
|
|
1882
|
+
|
|
1883
|
+
class TeamRoleSwitcherHierarchyPayloadTest extends TestCase
|
|
1884
|
+
{
|
|
1885
|
+
public function test_bootstrap_returns_root_nodes_with_render_trees(): void
|
|
1886
|
+
{
|
|
1887
|
+
[$user, $nationalTeam] = $this->seedNationalAdmin();
|
|
1888
|
+
$this->actingAs($user);
|
|
1889
|
+
|
|
1890
|
+
$response = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
|
|
1891
|
+
'_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
|
|
1892
|
+
'mode' => 'teams',
|
|
1893
|
+
]));
|
|
1894
|
+
|
|
1895
|
+
$response->assertOk();
|
|
1896
|
+
$payload = $response->json();
|
|
1897
|
+
|
|
1898
|
+
$this->assertArrayHasKey('nodes', $payload);
|
|
1899
|
+
$this->assertArrayHasKey('children', $payload);
|
|
1900
|
+
$this->assertArrayHasKey('paging', $payload);
|
|
1901
|
+
|
|
1902
|
+
$rootIds = $payload['children']['root'] ?? [];
|
|
1903
|
+
$this->assertNotEmpty($rootIds, 'Root must have at least one node');
|
|
1904
|
+
|
|
1905
|
+
$node = $payload['nodes'][$rootIds[0]];
|
|
1906
|
+
$this->assertArrayHasKey('id', $node);
|
|
1907
|
+
$this->assertArrayHasKey('parentId', $node);
|
|
1908
|
+
$this->assertArrayHasKey('hasChildren', $node);
|
|
1909
|
+
$this->assertArrayHasKey('isCurrent', $node);
|
|
1910
|
+
$this->assertArrayHasKey('render', $node);
|
|
1911
|
+
|
|
1912
|
+
$render = $node['render'];
|
|
1913
|
+
$this->assertSame('Flex', $render['vueComponent']);
|
|
1914
|
+
$this->assertIsArray($render['elements']);
|
|
1915
|
+
$this->assertGreaterThanOrEqual(2, count($render['elements']), 'Render must contain at least name + meta');
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
public function test_national_row_contains_role_name_button(): void
|
|
1919
|
+
{
|
|
1920
|
+
[$user, $nationalTeam] = $this->seedNationalAdmin();
|
|
1921
|
+
$this->actingAs($user);
|
|
1922
|
+
|
|
1923
|
+
$payload = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
|
|
1924
|
+
'_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
|
|
1925
|
+
'mode' => 'teams',
|
|
1926
|
+
]))->json();
|
|
1927
|
+
|
|
1928
|
+
$node = $this->findNodeByTeamId($payload, $nationalTeam->id);
|
|
1929
|
+
$this->assertNotNull($node);
|
|
1930
|
+
|
|
1931
|
+
$buttonLabels = $this->collectButtonLabels($node['render']);
|
|
1932
|
+
$this->assertContains('Super admin', $buttonLabels, 'National row must render a "Super admin" role-name button');
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
public function test_unit_row_has_only_go_button_when_role_inherited(): void
|
|
1936
|
+
{
|
|
1937
|
+
[$user, , $unitTeam] = $this->seedNationalAdmin();
|
|
1938
|
+
$this->actingAs($user);
|
|
1939
|
+
|
|
1940
|
+
$payload = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
|
|
1941
|
+
'_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
|
|
1942
|
+
'mode' => 'teams',
|
|
1943
|
+
]))->json();
|
|
1944
|
+
|
|
1945
|
+
$node = $this->findNodeByTeamId($payload, $unitTeam->id);
|
|
1946
|
+
$this->assertNotNull($node);
|
|
1947
|
+
|
|
1948
|
+
$buttonLabels = $this->collectButtonLabels($node['render']);
|
|
1949
|
+
$this->assertEquals(
|
|
1950
|
+
[''],
|
|
1951
|
+
$buttonLabels,
|
|
1952
|
+
'Unit row must render exactly one button with empty label (go-only variant)'
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
public function test_current_node_has_is_current_true(): void
|
|
1957
|
+
{
|
|
1958
|
+
[$user, , $unitTeam] = $this->seedNationalAdmin();
|
|
1959
|
+
$this->actingAs($user);
|
|
1960
|
+
$this->setCurrentTeam($user, $unitTeam);
|
|
1961
|
+
|
|
1962
|
+
$payload = $this->getJson(route('utils.lazy-hierarchy.bootstrap', [
|
|
1963
|
+
'_lazyHierarchyId' => $this->registerTeamRoleSwitcherSource(),
|
|
1964
|
+
'mode' => 'teams',
|
|
1965
|
+
]))->json();
|
|
1966
|
+
|
|
1967
|
+
$node = $this->findNodeByTeamId($payload, $unitTeam->id);
|
|
1968
|
+
$this->assertTrue($node['isCurrent'], 'The current team must have isCurrent=true');
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// --- helpers: seed the minimal hierarchy + register a source -------
|
|
1972
|
+
|
|
1973
|
+
private function seedNationalAdmin(): array
|
|
1974
|
+
{
|
|
1975
|
+
// Produces a National → District → Group → Unit chain and a user with
|
|
1976
|
+
// the Super admin role on National. Return [$user, $national, $unit].
|
|
1977
|
+
// Implementation specific to the project's factory/fixture patterns.
|
|
1978
|
+
// TODO: adapt to existing test helpers in auth/tests/TestCase.php
|
|
1979
|
+
$this->markTestIncomplete('Implement seeder using existing auth/tests/ fixtures');
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
private function registerTeamRoleSwitcherSource(): string
|
|
1983
|
+
{
|
|
1984
|
+
// Mirror what TeamRoleSwitcherElement::initialize() does to stash the
|
|
1985
|
+
// source in LazyHierarchyRegistry and return the opaque id.
|
|
1986
|
+
// TODO: call app(LazyHierarchyRegistry::class)->storeSource(...)
|
|
1987
|
+
$this->markTestIncomplete('Wire registerTeamRoleSwitcherSource helper');
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
private function setCurrentTeam($user, $team): void
|
|
1991
|
+
{
|
|
1992
|
+
// Set the active team-role for the user so currentTeam()/currentTeamRole()
|
|
1993
|
+
// resolve to this team during the request.
|
|
1994
|
+
$this->markTestIncomplete('Wire setCurrentTeam helper');
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
private function findNodeByTeamId(array $payload, int $teamId): ?array
|
|
1998
|
+
{
|
|
1999
|
+
$nodeId = 'team-' . $teamId;
|
|
2000
|
+
return $payload['nodes'][$nodeId] ?? null;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
private function collectButtonLabels(array $element): array
|
|
2004
|
+
{
|
|
2005
|
+
$labels = [];
|
|
2006
|
+
$this->walkElements($element, function ($el) use (&$labels) {
|
|
2007
|
+
if (($el['vueComponent'] ?? null) === 'Button') {
|
|
2008
|
+
$labels[] = (string) ($el['label'] ?? '');
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
return $labels;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
private function walkElements(array $element, callable $visit): void
|
|
2015
|
+
{
|
|
2016
|
+
$visit($element);
|
|
2017
|
+
foreach ($element['elements'] ?? [] as $child) {
|
|
2018
|
+
if (is_array($child)) {
|
|
2019
|
+
$this->walkElements($child, $visit);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
```
|
|
2025
|
+
|
|
2026
|
+
**Step 2: Run the failing test**
|
|
2027
|
+
|
|
2028
|
+
Run: `cd "C:/Users/jkend/Documents/Projects/kompo/auth" && ./vendor/bin/phpunit --filter TeamRoleSwitcherHierarchyPayloadTest`
|
|
2029
|
+
Expected: the tests are marked incomplete until seeders are wired, which is acceptable — the structure is in place.
|
|
2030
|
+
|
|
2031
|
+
**Step 3: Fill in the incomplete seeders using existing fixtures**
|
|
2032
|
+
|
|
2033
|
+
Open `C:/Users/jkend/Documents/Projects/kompo/auth/tests/TestCase.php` and any factory files under `C:/Users/jkend/Documents/Projects/kompo/auth/tests/database/` / `C:/Users/jkend/Documents/Projects/kompo/auth/tests/Helpers/`. Locate the team/team-role factory helpers and wire `seedNationalAdmin`, `registerTeamRoleSwitcherSource`, and `setCurrentTeam`.
|
|
2034
|
+
|
|
2035
|
+
**Step 4: Run the tests to green**
|
|
2036
|
+
|
|
2037
|
+
Run: `cd "C:/Users/jkend/Documents/Projects/kompo/auth" && ./vendor/bin/phpunit --filter TeamRoleSwitcherHierarchyPayloadTest`
|
|
2038
|
+
Expected: all four tests pass.
|
|
2039
|
+
|
|
2040
|
+
**Step 5: Run the full auth feature suite to catch regressions**
|
|
2041
|
+
|
|
2042
|
+
Run: `cd "C:/Users/jkend/Documents/Projects/kompo/auth" && ./vendor/bin/phpunit --testsuite Feature`
|
|
2043
|
+
Expected: previously-passing tests still pass; no new failures.
|
|
2044
|
+
|
|
2045
|
+
---
|
|
2046
|
+
|
|
2047
|
+
## Task 8 — Build & manual browser verification
|
|
2048
|
+
|
|
2049
|
+
**Files:** none
|
|
2050
|
+
|
|
2051
|
+
**Step 1: Rebuild the front-end bundle**
|
|
2052
|
+
|
|
2053
|
+
Run: `cd "C:/Users/jkend/Documents/Projects/kompo/js-kompo-utils" && npm run build` (or the project's equivalent; check `package.json`).
|
|
2054
|
+
Expected: build succeeds with no errors.
|
|
2055
|
+
|
|
2056
|
+
**Step 2: Boot the consuming application**
|
|
2057
|
+
|
|
2058
|
+
Start whichever app embeds `MenuRolesSwitcherDropdown` (likely SISC or a Scouts Canada app). Follow its normal local dev instructions — `php artisan serve` / Herd / Sail, plus a Vite/webpack dev server if applicable.
|
|
2059
|
+
|
|
2060
|
+
**Step 3: Smoke-test the dropdown**
|
|
2061
|
+
|
|
2062
|
+
Walk through every row type in `docs/plans/2026-04-18-vl-lazy-hierarchy-generic-render.md` Design Summary and confirm:
|
|
2063
|
+
|
|
2064
|
+
- Dropdown trigger shows `currentTeamRole->getRoleName()` + chevron.
|
|
2065
|
+
- Opening the panel reveals search + "Teams" / "Committees" radios with Teams selected by default.
|
|
2066
|
+
- Root level renders at least "National" with:
|
|
2067
|
+
- Yellow `National` pill, `Super admin` role-name pill to its right, and a go (person-with-arrow) icon button.
|
|
2068
|
+
- Clicking the go icon POSTs to `route('team-role-switcher.switch')` with `{team_id, role_id}` and reloads the page. The trigger label updates to reflect the new role.
|
|
2069
|
+
- Expanding National → Atlantique (green `District` pill, go-only button — no role-name pill) → 001 - Scouts de Cap-Pelé (blue `Group` pill) → each Unit (purple `Unit` pill) matches the image.
|
|
2070
|
+
- The current team row has the highlighted (soft-green) background.
|
|
2071
|
+
- Switching to "Committees" reloads the tree with yellow `Committee` pills.
|
|
2072
|
+
- Typing in the search box debounces and filters with parent names shown under matches.
|
|
2073
|
+
- Loading states and skeleton rows appear briefly during fetches.
|
|
2074
|
+
|
|
2075
|
+
**Step 4: Compare side-by-side with the original**
|
|
2076
|
+
|
|
2077
|
+
If possible, pull the previous build artifact (before this branch) and open the two side by side at the same viewport. Pixel-level alignment is not required; visual equivalence and correct click behavior are.
|
|
2078
|
+
|
|
2079
|
+
**Step 5: Regression scan for other consumers**
|
|
2080
|
+
|
|
2081
|
+
Run: `grep -r "VlLazyHierarchy\|_LazyHierarchy(" "C:/Users/jkend/Documents/Projects/kompo/" --include="*.php" --include="*.vue" --include="*.js" --include="*.blade.php"`
|
|
2082
|
+
Confirm only `TeamRoleSwitcherElement`, `MenuRolesSwitcherDropdown`, and the helper in `utils/src/Helpers/elements/components.php` reference the element.
|
|
2083
|
+
|
|
2084
|
+
---
|
|
2085
|
+
|
|
2086
|
+
## Done Criteria
|
|
2087
|
+
|
|
2088
|
+
- `VlLazyHierarchy.vue` contains no references to `roles`, `committee`, `level`, `switchRole`, or any domain concept.
|
|
2089
|
+
- `LazyHierarchy.php` has no `hierarchyAction` method.
|
|
2090
|
+
- `TeamRoleSwitcherNodeFactory.php` produces `{id, parentId, hasChildren, isCurrent, render}` payload nodes with a composed `_Flex` render tree.
|
|
2091
|
+
- All feature tests in Task 7 pass.
|
|
2092
|
+
- Manual browser walk-through in Task 8 matches the provided reference image.
|
|
2093
|
+
- `grep -r "nodeBadges\|showCommitteeCount\|runRoleAction\|runNodeAction\|runConfiguredAction" "C:/Users/jkend/Documents/Projects/kompo/"` returns zero matches. (`switchOnlyRole` is legitimately retained as a method on `HierarchyNodeContext` — see Task 3.)
|