|
29 | 29 | */ |
30 | 30 |
|
31 | 31 | import { Controller } from '@hotwired/stimulus'; |
32 | | -import { type SelectPanelElement, type SelectPanelItem } from '@openproject/primer-view-components/app/components/primer/alpha/select_panel_element'; |
33 | | -import { type ItemActivatedEvent } from '@openproject/primer-view-components/app/components/primer/shared_events'; |
| 32 | +import { |
| 33 | + NgOption, |
| 34 | + NgSelectComponent, |
| 35 | +} from '@ng-select/ng-select'; |
34 | 36 |
|
35 | 37 | /** |
36 | 38 | * Stimulus Controller adding behavior to Admin > Backlogs page. |
| 39 | + * Ensures that story types and task types are mutually exclusive. |
37 | 40 | */ |
38 | 41 | export default class BacklogsSettings extends Controller<HTMLElement> { |
39 | 42 | static targets = ['storyTypes', 'taskType']; |
40 | 43 |
|
41 | | - declare readonly storyTypesTarget:SelectPanelElement; |
42 | | - declare readonly taskTypeTarget:SelectPanelElement; |
| 44 | + declare readonly storyTypesTarget:HTMLElement; |
| 45 | + declare readonly taskTypeTarget:HTMLElement; |
43 | 46 | declare readonly hasStoryTypesTarget:boolean; |
44 | 47 | declare readonly hasTaskTypeTarget:boolean; |
45 | 48 |
|
46 | | - private originalLabel?:string; |
| 49 | + private isUpdating = false; |
47 | 50 |
|
48 | | - storyTypesTargetConnected(target:SelectPanelElement) { |
49 | | - target.addEventListener('itemActivated', this.onStoryTypesActivated); |
50 | | - |
51 | | - // this can be removed once implemented upstream: https://github.com/primer/view_components/pull/3825 |
52 | | - this.setDynamicLabel(this.storyTypesTarget); |
| 51 | + storyTypesTargetConnected(target:HTMLElement) { |
| 52 | + target.addEventListener('change', this.onStoryTypesChanged); |
53 | 53 | } |
54 | 54 |
|
55 | | - storyTypesTargetDisconnected(target:SelectPanelElement) { |
56 | | - target.removeEventListener('itemActivated', this.onStoryTypesActivated); |
| 55 | + storyTypesTargetDisconnected(target:HTMLElement) { |
| 56 | + target.removeEventListener('change', this.onStoryTypesChanged); |
57 | 57 | } |
58 | 58 |
|
59 | | - taskTypeTargetConnected(target:SelectPanelElement) { |
60 | | - target.addEventListener('itemActivated', this.onTaskTypeActivated); |
| 59 | + taskTypeTargetConnected(target:HTMLElement) { |
| 60 | + target.addEventListener('change', this.onTaskTypeChanged); |
61 | 61 | } |
62 | 62 |
|
63 | | - taskTypeTargetDisconnected(target:SelectPanelElement) { |
64 | | - target.removeEventListener('itemActivated', this.onTaskTypeActivated); |
| 63 | + taskTypeTargetDisconnected(target:HTMLElement) { |
| 64 | + target.removeEventListener('change', this.onTaskTypeChanged); |
65 | 65 | } |
66 | 66 |
|
67 | | - private onStoryTypesActivated = (_event:CustomEvent<ItemActivatedEvent>) => { |
68 | | - if (!this.hasTaskTypeTarget) return; |
69 | | - this.syncSelectPanels(this.storyTypesTarget, this.taskTypeTarget); |
| 67 | + private onStoryTypesChanged = () => { |
| 68 | + if (this.isUpdating || !this.hasTaskTypeTarget) return; |
70 | 69 |
|
71 | | - // this can be removed once implemented upstream: https://github.com/primer/view_components/pull/3825 |
72 | | - this.setDynamicLabel(this.storyTypesTarget); |
| 70 | + this.syncDisabledOptions(this.storyTypesTarget, this.taskTypeTarget); |
73 | 71 | }; |
74 | 72 |
|
75 | | - private onTaskTypeActivated = (_event:CustomEvent<ItemActivatedEvent>) => { |
76 | | - if (!this.hasStoryTypesTarget) return; |
77 | | - this.syncSelectPanels(this.taskTypeTarget, this.storyTypesTarget); |
| 73 | + private onTaskTypeChanged = () => { |
| 74 | + if (this.isUpdating || !this.hasStoryTypesTarget) return; |
| 75 | + |
| 76 | + this.syncDisabledOptions(this.taskTypeTarget, this.storyTypesTarget); |
78 | 77 | }; |
79 | 78 |
|
80 | 79 | /** |
81 | | - * Syncs two select panels - ensuring selections are mutually exclusive. |
| 80 | + * Syncs disabled options between two autocompleters. |
| 81 | + * Selected values in the source autocompleter will be disabled in the target. |
| 82 | + * |
| 83 | + * @param sourceTarget The autocompleter whose selections should disable options in the target |
| 84 | + * @param targetTarget The autocompleter whose options should be disabled |
| 85 | + */ |
| 86 | + private syncDisabledOptions(sourceTarget:HTMLElement, targetTarget:HTMLElement) { |
| 87 | + this.isUpdating = true; |
| 88 | + try { |
| 89 | + const sourceNgSelect = this.getNgSelectComponent(sourceTarget); |
| 90 | + const targetNgSelect = this.getNgSelectComponent(targetTarget); |
| 91 | + |
| 92 | + if (!sourceNgSelect || !targetNgSelect) { |
| 93 | + return; |
| 94 | + } |
| 95 | + |
| 96 | + this.syncAutocompleters(sourceNgSelect, targetNgSelect); |
| 97 | + } finally { |
| 98 | + this.isUpdating = false; |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + /** |
| 103 | + * Gets the NgSelectComponent instance from an op-autocompleter element. |
| 104 | + */ |
| 105 | + private getNgSelectComponent(target:HTMLElement):NgSelectComponent|null { |
| 106 | + // Access the ng-select instance stored by op-autocompleter component |
| 107 | + // eslint-disable-next-line @typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-member-access |
| 108 | + return (target as any).ngSelectComponentInstance ?? null; |
| 109 | + } |
| 110 | + |
| 111 | + /** |
| 112 | + * Syncs two ng-select autocompleters - ensuring selections are mutually exclusive. |
82 | 113 | * |
83 | | - * @param source source select panel |
84 | | - * @param target target select panel |
| 114 | + * @param source source autocompleter |
| 115 | + * @param target target autocompleter |
85 | 116 | */ |
86 | | - private syncSelectPanels(source:SelectPanelElement, target:SelectPanelElement) { |
87 | | - const sourceSelectedValues = new Set( |
| 117 | + private syncAutocompleters(source:NgSelectComponent, target:NgSelectComponent) { |
| 118 | + const sourceSelectedIds = new Set( |
88 | 119 | source.selectedItems |
89 | | - .map((item) => item.value) |
90 | | - .filter((value):value is string => value != null && value !== '') |
| 120 | + .map((item) => item.value.id) |
| 121 | + .filter((id) => id != null) |
91 | 122 | ); |
92 | 123 |
|
93 | | - target.items.forEach((targetItem:SelectPanelItem) => { |
94 | | - const itemContent = targetItem.querySelector<HTMLElement>('.ActionListContent'); |
95 | | - const itemValue = itemContent?.dataset.value; |
96 | | - if (!itemValue) return; |
| 124 | + // Directly mutate the items array to ensure ng-select updates properly |
| 125 | + let hasChanges = false; |
| 126 | + target.itemsList.items.forEach((targetItem:NgOption) => { |
| 127 | + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment |
| 128 | + const itemId = targetItem.value?.id; |
97 | 129 |
|
98 | | - if (sourceSelectedValues.has(itemValue)) { |
99 | | - target.disableItem(targetItem); |
100 | | - target.uncheckItem(targetItem); |
101 | | - } else { |
102 | | - target.enableItem(targetItem); |
| 130 | + if (!itemId) return; |
| 131 | + |
| 132 | + const shouldBeDisabled = sourceSelectedIds.has(itemId); |
| 133 | + if (targetItem.disabled !== shouldBeDisabled) { |
| 134 | + targetItem.disabled = shouldBeDisabled; |
| 135 | + hasChanges = true; |
103 | 136 | } |
104 | 137 | }); |
105 | | - } |
106 | 138 |
|
107 | | - // this can be removed once implemented upstream: https://github.com/primer/view_components/pull/3825 |
108 | | - private setDynamicLabel(panel:SelectPanelElement) { |
109 | | - const invokerLabel = panel.invokerLabel!; |
110 | | - this.originalLabel ??= invokerLabel.textContent ?? ''; |
111 | | - const selectedLabels = Array.from(panel.querySelectorAll(`[${panel.ariaSelectionType}=true] .ActionListItem-label`)) |
112 | | - .map((label) => label.textContent?.trim() ?? '') |
113 | | - .join(', '); |
114 | | - |
115 | | - if (selectedLabels) { |
116 | | - const prefixSpan = document.createElement('span'); |
117 | | - prefixSpan.classList.add('color-fg-muted'); |
118 | | - const contentSpan = document.createElement('span'); |
119 | | - prefixSpan.textContent = `${panel.dynamicLabelPrefix} `; |
120 | | - contentSpan.textContent = selectedLabels; |
121 | | - invokerLabel.replaceChildren(prefixSpan, contentSpan); |
122 | | - |
123 | | - if (panel.dynamicAriaLabelPrefix) { |
124 | | - panel.invokerElement?.setAttribute('aria-label', `${panel.dynamicAriaLabelPrefix} ${selectedLabels}`); |
125 | | - } |
126 | | - } else { |
127 | | - invokerLabel.textContent = this.originalLabel; |
| 139 | + // Force ng-select to re-render if we made changes |
| 140 | + if (hasChanges) { |
| 141 | + target.detectChanges(); |
128 | 142 | } |
129 | 143 | } |
130 | 144 | } |
131 | | - |
|
0 commit comments