Skip to content

Commit c82f493

Browse files
authored
[71069] Use autocompleters in Admin/Backlogs page (#21841)
* Replace selectPanel with autocompleters * Fix specs for updated autocompleter * Attempt to fix test * Replace ng.getComponent by ViewChild API
1 parent 9c3d13b commit c82f493

File tree

14 files changed

+170
-189
lines changed

14 files changed

+170
-189
lines changed

frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@
206206
}
207207
@case (resource ==='subproject' || resource ==='version' || resource ==='status' || resource ==='default' || (!resource && !item.depth)) {
208208
<span
209-
[ngClass]=" additionalClassProperty ? item[additionalClassProperty] : ''"
209+
[ngClass]="'ng-option-label ' + (item?.[additionalClassProperty] || '')"
210210
[attr.data-hover-card-trigger-target]="getHoverCardTriggerTarget(item)"
211211
[attr.data-hover-card-url]="getHoverCardUrl(item)"
212212
[ngOptionHighlight]="search">{{ item.name }}

frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,10 @@ export class OpAutocompleterComponent<T extends IAutocompleteItem = IAutocomplet
334334
}
335335

336336
ngAfterViewInit():void {
337+
// Store ng-select instance on the host element for access from Stimulus controllers
338+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
339+
(this.elementRef.nativeElement as any).ngSelectComponentInstance = this.ngSelectInstance;
340+
337341
if (this.inputName && this.model) {
338342
this.syncHiddenField(this.mappedInputValue);
339343
}

frontend/src/stimulus/controllers/dynamic/admin/backlogs-settings.controller.ts

Lines changed: 76 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -29,103 +29,116 @@
2929
*/
3030

3131
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';
3436

3537
/**
3638
* Stimulus Controller adding behavior to Admin > Backlogs page.
39+
* Ensures that story types and task types are mutually exclusive.
3740
*/
3841
export default class BacklogsSettings extends Controller<HTMLElement> {
3942
static targets = ['storyTypes', 'taskType'];
4043

41-
declare readonly storyTypesTarget:SelectPanelElement;
42-
declare readonly taskTypeTarget:SelectPanelElement;
44+
declare readonly storyTypesTarget:HTMLElement;
45+
declare readonly taskTypeTarget:HTMLElement;
4346
declare readonly hasStoryTypesTarget:boolean;
4447
declare readonly hasTaskTypeTarget:boolean;
4548

46-
private originalLabel?:string;
49+
private isUpdating = false;
4750

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);
5353
}
5454

55-
storyTypesTargetDisconnected(target:SelectPanelElement) {
56-
target.removeEventListener('itemActivated', this.onStoryTypesActivated);
55+
storyTypesTargetDisconnected(target:HTMLElement) {
56+
target.removeEventListener('change', this.onStoryTypesChanged);
5757
}
5858

59-
taskTypeTargetConnected(target:SelectPanelElement) {
60-
target.addEventListener('itemActivated', this.onTaskTypeActivated);
59+
taskTypeTargetConnected(target:HTMLElement) {
60+
target.addEventListener('change', this.onTaskTypeChanged);
6161
}
6262

63-
taskTypeTargetDisconnected(target:SelectPanelElement) {
64-
target.removeEventListener('itemActivated', this.onTaskTypeActivated);
63+
taskTypeTargetDisconnected(target:HTMLElement) {
64+
target.removeEventListener('change', this.onTaskTypeChanged);
6565
}
6666

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;
7069

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);
7371
};
7472

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);
7877
};
7978

8079
/**
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.
82113
*
83-
* @param source source select panel
84-
* @param target target select panel
114+
* @param source source autocompleter
115+
* @param target target autocompleter
85116
*/
86-
private syncSelectPanels(source:SelectPanelElement, target:SelectPanelElement) {
87-
const sourceSelectedValues = new Set(
117+
private syncAutocompleters(source:NgSelectComponent, target:NgSelectComponent) {
118+
const sourceSelectedIds = new Set(
88119
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)
91122
);
92123

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;
97129

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;
103136
}
104137
});
105-
}
106138

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();
128142
}
129143
}
130144
}
131-

lib/primer/open_project/forms/dsl/autocompleter_input.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,19 @@ class AutocompleterInput < Primer::Forms::Dsl::Input
88
attr_reader :name, :label, :autocomplete_options, :select_options, :wrapper_data_attributes
99

1010
class Option
11-
attr_reader :label, :value, :selected, :classes, :group_by
11+
attr_reader :label, :value, :selected, :classes, :group_by, :disabled
1212

13-
def initialize(label:, value:, classes: nil, selected: false, group_by: nil)
13+
def initialize(label:, value:, classes: nil, selected: false, group_by: nil, disabled: false)
1414
@label = label
1515
@value = value
1616
@selected = selected
1717
@classes = classes
1818
@group_by = group_by
19+
@disabled = disabled
1920
end
2021

2122
def to_h
22-
{ id: value, name: label }.merge({ group_by:, classes: }.compact)
23+
{ id: value, name: label }.merge({ selected:, disabled:, group_by:, classes: }.compact)
2324
end
2425
end
2526

modules/backlogs/app/forms/admin/settings/backlogs_settings_form.rb

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -34,63 +34,59 @@ class BacklogsSettingsForm < ApplicationForm
3434
include ::Settings::FormHelper
3535

3636
form do |f|
37-
f.select_panel(
37+
f.autocompleter(
3838
name: :story_types,
3939
label: I18n.t(:backlogs_story_type),
40-
title: I18n.t(:label_select_types),
4140
caption: setting_caption(:plugin_openproject_backlogs, :story_types),
42-
select_variant: :multiple,
43-
fetch_strategy: :local,
44-
dynamic_label: true,
45-
dynamic_label_prefix: I18n.t(:label_selected_types),
46-
data: {
47-
admin__backlogs_settings_target: "storyTypes"
41+
autocomplete_options: {
42+
multiple: true,
43+
closeOnSelect: false,
44+
clearable: false,
45+
decorated: true,
46+
data: {
47+
admin__backlogs_settings_target: "storyTypes",
48+
test_selector: "story_type_autocomplete"
49+
}
4850
}
49-
) do |select_menu|
51+
) do |list|
5052
available_types.each do |label, value|
5153
active = value.in?(Story.types)
5254
in_use = Task.type == value
5355

54-
select_menu.with_item(
56+
list.option(
5557
label:,
56-
content_arguments: { data: { value: } },
57-
active:,
58-
disabled: in_use,
59-
item_id: "type-#{value}",
60-
label_arguments: { classes: "__hl_inline_type_#{value}" }
58+
value:,
59+
selected: active,
60+
disabled: in_use
6161
)
6262
end
63-
64-
select_menu.with_footer(show_divider: true) do
65-
render(Primer::Beta::Button.new(scheme: :primary, data: { action: "click:select-panel#hide" })) do
66-
I18n.t(:button_apply)
67-
end
68-
end
6963
end
7064

71-
f.select_panel(
65+
f.autocompleter(
7266
name: :task_type,
7367
label: I18n.t(:backlogs_task_type),
74-
title: I18n.t(:label_select_type),
7568
caption: setting_caption(:plugin_openproject_backlogs, :task_type),
76-
fetch_strategy: :local,
77-
dynamic_label: true,
78-
dynamic_label_prefix: I18n.t(:label_selected_type),
79-
data: {
80-
admin__backlogs_settings_target: "taskType"
69+
input_width: :small,
70+
autocomplete_options: {
71+
multiple: false,
72+
closeOnSelect: true,
73+
clearable: false,
74+
decorated: true,
75+
data: {
76+
admin__backlogs_settings_target: "taskType",
77+
test_selector: "task_type_autocomplete"
78+
}
8179
}
82-
) do |select_menu|
80+
) do |list|
8381
available_types.each do |label, value|
8482
active = Task.type == value
8583
in_use = value.in?(Story.types)
8684

87-
select_menu.with_item(
85+
list.option(
8886
label:,
89-
content_arguments: { data: { value: } },
90-
active:,
91-
disabled: in_use,
92-
item_id: "type-#{value}",
93-
label_arguments: { classes: "__hl_inline_type_#{value}" }
87+
value:,
88+
selected: active,
89+
disabled: in_use
9490
)
9591
end
9692
end

modules/backlogs/config/locales/en.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,6 @@ en:
142142
label_column_in_backlog: "Column in backlog"
143143
label_points_burn_down: "Down"
144144
label_points_burn_up: "Up"
145-
label_select_type: "Select a type"
146-
label_select_types: "Select types"
147-
label_selected_type: "Selected type"
148-
label_selected_types: "Selected types"
149145
label_sprint_impediments: "Sprint Impediments"
150146
label_task_board: "Task board"
151147

0 commit comments

Comments
 (0)