Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@
}
@case (resource ==='subproject' || resource ==='version' || resource ==='status' || resource ==='default' || (!resource && !item.depth)) {
<span
[ngClass]=" additionalClassProperty ? item[additionalClassProperty] : ''"
[ngClass]="'ng-option-label ' + (item?.[additionalClassProperty] || '')"
[attr.data-hover-card-trigger-target]="getHoverCardTriggerTarget(item)"
[attr.data-hover-card-url]="getHoverCardUrl(item)"
[ngOptionHighlight]="search">{{ item.name }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@
}

ngAfterViewInit():void {
// Store ng-select instance on the host element for access from Stimulus controllers
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
(this.elementRef.nativeElement as any).ngSelectComponentInstance = this.ngSelectInstance;

Check failure on line 339 in frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts#L339 <@typescript-eslint/no-unnecessary-type-assertion>(https://typescript-eslint.io/rules/no-unnecessary-type-assertion)

This assertion is unnecessary since it does not change the type of the expression.
Raw output
{"ruleId":"@typescript-eslint/no-unnecessary-type-assertion","severity":2,"message":"This assertion is unnecessary since it does not change the type of the expression.","line":339,"column":6,"nodeType":"TSAsExpression","messageId":"unnecessaryAssertion","endLine":339,"endColumn":42,"fix":{"range":[10728,10735],"text":""}}

if (this.inputName && this.model) {
this.syncHiddenField(this.mappedInputValue);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,103 +29,116 @@
*/

import { Controller } from '@hotwired/stimulus';
import { type SelectPanelElement, type SelectPanelItem } from '@openproject/primer-view-components/app/components/primer/alpha/select_panel_element';
import { type ItemActivatedEvent } from '@openproject/primer-view-components/app/components/primer/shared_events';
import {
NgOption,
NgSelectComponent,
} from '@ng-select/ng-select';

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

declare readonly storyTypesTarget:SelectPanelElement;
declare readonly taskTypeTarget:SelectPanelElement;
declare readonly storyTypesTarget:HTMLElement;
declare readonly taskTypeTarget:HTMLElement;
declare readonly hasStoryTypesTarget:boolean;
declare readonly hasTaskTypeTarget:boolean;

private originalLabel?:string;
private isUpdating = false;

storyTypesTargetConnected(target:SelectPanelElement) {
target.addEventListener('itemActivated', this.onStoryTypesActivated);

// this can be removed once implemented upstream: https://github.com/primer/view_components/pull/3825
this.setDynamicLabel(this.storyTypesTarget);
storyTypesTargetConnected(target:HTMLElement) {
target.addEventListener('change', this.onStoryTypesChanged);
}

storyTypesTargetDisconnected(target:SelectPanelElement) {
target.removeEventListener('itemActivated', this.onStoryTypesActivated);
storyTypesTargetDisconnected(target:HTMLElement) {
target.removeEventListener('change', this.onStoryTypesChanged);
}

taskTypeTargetConnected(target:SelectPanelElement) {
target.addEventListener('itemActivated', this.onTaskTypeActivated);
taskTypeTargetConnected(target:HTMLElement) {
target.addEventListener('change', this.onTaskTypeChanged);
}

taskTypeTargetDisconnected(target:SelectPanelElement) {
target.removeEventListener('itemActivated', this.onTaskTypeActivated);
taskTypeTargetDisconnected(target:HTMLElement) {
target.removeEventListener('change', this.onTaskTypeChanged);
}

private onStoryTypesActivated = (_event:CustomEvent<ItemActivatedEvent>) => {
if (!this.hasTaskTypeTarget) return;
this.syncSelectPanels(this.storyTypesTarget, this.taskTypeTarget);
private onStoryTypesChanged = () => {
if (this.isUpdating || !this.hasTaskTypeTarget) return;

// this can be removed once implemented upstream: https://github.com/primer/view_components/pull/3825
this.setDynamicLabel(this.storyTypesTarget);
this.syncDisabledOptions(this.storyTypesTarget, this.taskTypeTarget);
};

private onTaskTypeActivated = (_event:CustomEvent<ItemActivatedEvent>) => {
if (!this.hasStoryTypesTarget) return;
this.syncSelectPanels(this.taskTypeTarget, this.storyTypesTarget);
private onTaskTypeChanged = () => {
if (this.isUpdating || !this.hasStoryTypesTarget) return;

this.syncDisabledOptions(this.taskTypeTarget, this.storyTypesTarget);
};

/**
* Syncs two select panels - ensuring selections are mutually exclusive.
* Syncs disabled options between two autocompleters.
* Selected values in the source autocompleter will be disabled in the target.
*
* @param sourceTarget The autocompleter whose selections should disable options in the target
* @param targetTarget The autocompleter whose options should be disabled
*/
private syncDisabledOptions(sourceTarget:HTMLElement, targetTarget:HTMLElement) {
this.isUpdating = true;
try {
const sourceNgSelect = this.getNgSelectComponent(sourceTarget);
const targetNgSelect = this.getNgSelectComponent(targetTarget);

if (!sourceNgSelect || !targetNgSelect) {
return;
}

this.syncAutocompleters(sourceNgSelect, targetNgSelect);
} finally {
this.isUpdating = false;
}
}

/**
* Gets the NgSelectComponent instance from an op-autocompleter element.
*/
private getNgSelectComponent(target:HTMLElement):NgSelectComponent|null {
// Access the ng-select instance stored by op-autocompleter component
// eslint-disable-next-line @typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-member-access
return (target as any).ngSelectComponentInstance ?? null;

Check failure on line 108 in frontend/src/stimulus/controllers/dynamic/admin/backlogs-settings.controller.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] frontend/src/stimulus/controllers/dynamic/admin/backlogs-settings.controller.ts#L108 <@typescript-eslint/no-explicit-any>(https://typescript-eslint.io/rules/no-explicit-any)

Unexpected any. Specify a different type.
Raw output
{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":108,"column":23,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":108,"endColumn":26,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3846,3849],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3846,3849],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}
}

/**
* Syncs two ng-select autocompleters - ensuring selections are mutually exclusive.
*
* @param source source select panel
* @param target target select panel
* @param source source autocompleter
* @param target target autocompleter
*/
private syncSelectPanels(source:SelectPanelElement, target:SelectPanelElement) {
const sourceSelectedValues = new Set(
private syncAutocompleters(source:NgSelectComponent, target:NgSelectComponent) {
const sourceSelectedIds = new Set(
source.selectedItems
.map((item) => item.value)
.filter((value):value is string => value != null && value !== '')
.map((item) => item.value.id)

Check failure on line 120 in frontend/src/stimulus/controllers/dynamic/admin/backlogs-settings.controller.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] frontend/src/stimulus/controllers/dynamic/admin/backlogs-settings.controller.ts#L120 <@typescript-eslint/no-unsafe-return>(https://typescript-eslint.io/rules/no-unsafe-return)

Unsafe return of a value of type `any`.
Raw output
{"ruleId":"@typescript-eslint/no-unsafe-return","severity":2,"message":"Unsafe return of a value of type `any`.","line":120,"column":24,"nodeType":"MemberExpression","messageId":"unsafeReturn","endLine":120,"endColumn":37}

Check failure on line 120 in frontend/src/stimulus/controllers/dynamic/admin/backlogs-settings.controller.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] frontend/src/stimulus/controllers/dynamic/admin/backlogs-settings.controller.ts#L120 <@typescript-eslint/no-unsafe-member-access>(https://typescript-eslint.io/rules/no-unsafe-member-access)

Unsafe member access .id on an `any` value.
Raw output
{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .id on an `any` value.","line":120,"column":35,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":120,"endColumn":37}
.filter((id) => id != null)
);

target.items.forEach((targetItem:SelectPanelItem) => {
const itemContent = targetItem.querySelector<HTMLElement>('.ActionListContent');
const itemValue = itemContent?.dataset.value;
if (!itemValue) return;
// Directly mutate the items array to ensure ng-select updates properly
let hasChanges = false;
target.itemsList.items.forEach((targetItem:NgOption) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const itemId = targetItem.value?.id;

Check failure on line 128 in frontend/src/stimulus/controllers/dynamic/admin/backlogs-settings.controller.ts

View workflow job for this annotation

GitHub Actions / eslint

[eslint] frontend/src/stimulus/controllers/dynamic/admin/backlogs-settings.controller.ts#L128 <@typescript-eslint/no-unsafe-member-access>(https://typescript-eslint.io/rules/no-unsafe-member-access)

Unsafe member access .id on an `any` value.
Raw output
{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .id on an `any` value.","line":128,"column":40,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":128,"endColumn":42}

if (sourceSelectedValues.has(itemValue)) {
target.disableItem(targetItem);
target.uncheckItem(targetItem);
} else {
target.enableItem(targetItem);
if (!itemId) return;

const shouldBeDisabled = sourceSelectedIds.has(itemId);
if (targetItem.disabled !== shouldBeDisabled) {
targetItem.disabled = shouldBeDisabled;
hasChanges = true;
}
});
}

// this can be removed once implemented upstream: https://github.com/primer/view_components/pull/3825
private setDynamicLabel(panel:SelectPanelElement) {
const invokerLabel = panel.invokerLabel!;
this.originalLabel ??= invokerLabel.textContent ?? '';
const selectedLabels = Array.from(panel.querySelectorAll(`[${panel.ariaSelectionType}=true] .ActionListItem-label`))
.map((label) => label.textContent?.trim() ?? '')
.join(', ');

if (selectedLabels) {
const prefixSpan = document.createElement('span');
prefixSpan.classList.add('color-fg-muted');
const contentSpan = document.createElement('span');
prefixSpan.textContent = `${panel.dynamicLabelPrefix} `;
contentSpan.textContent = selectedLabels;
invokerLabel.replaceChildren(prefixSpan, contentSpan);

if (panel.dynamicAriaLabelPrefix) {
panel.invokerElement?.setAttribute('aria-label', `${panel.dynamicAriaLabelPrefix} ${selectedLabels}`);
}
} else {
invokerLabel.textContent = this.originalLabel;
// Force ng-select to re-render if we made changes
if (hasChanges) {
target.detectChanges();
}
}
}

7 changes: 4 additions & 3 deletions lib/primer/open_project/forms/dsl/autocompleter_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,19 @@ class AutocompleterInput < Primer::Forms::Dsl::Input
attr_reader :name, :label, :autocomplete_options, :select_options, :wrapper_data_attributes

class Option
attr_reader :label, :value, :selected, :classes, :group_by
attr_reader :label, :value, :selected, :classes, :group_by, :disabled

def initialize(label:, value:, classes: nil, selected: false, group_by: nil)
def initialize(label:, value:, classes: nil, selected: false, group_by: nil, disabled: false)
@label = label
@value = value
@selected = selected
@classes = classes
@group_by = group_by
@disabled = disabled
end

def to_h
{ id: value, name: label }.merge({ group_by:, classes: }.compact)
{ id: value, name: label }.merge({ selected:, disabled:, group_by:, classes: }.compact)
end
end

Expand Down
66 changes: 31 additions & 35 deletions modules/backlogs/app/forms/admin/settings/backlogs_settings_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,63 +34,59 @@ class BacklogsSettingsForm < ApplicationForm
include ::Settings::FormHelper

form do |f|
f.select_panel(
f.autocompleter(
name: :story_types,
label: I18n.t(:backlogs_story_type),
title: I18n.t(:label_select_types),
caption: setting_caption(:plugin_openproject_backlogs, :story_types),
select_variant: :multiple,
fetch_strategy: :local,
dynamic_label: true,
dynamic_label_prefix: I18n.t(:label_selected_types),
data: {
admin__backlogs_settings_target: "storyTypes"
autocomplete_options: {
multiple: true,
closeOnSelect: false,
clearable: false,
decorated: true,
data: {
admin__backlogs_settings_target: "storyTypes",
test_selector: "story_type_autocomplete"
}
}
) do |select_menu|
) do |list|
available_types.each do |label, value|
active = value.in?(Story.types)
in_use = Task.type == value

select_menu.with_item(
list.option(
label:,
content_arguments: { data: { value: } },
active:,
disabled: in_use,
item_id: "type-#{value}",
label_arguments: { classes: "__hl_inline_type_#{value}" }
value:,
selected: active,
disabled: in_use
)
end

select_menu.with_footer(show_divider: true) do
render(Primer::Beta::Button.new(scheme: :primary, data: { action: "click:select-panel#hide" })) do
I18n.t(:button_apply)
end
end
end

f.select_panel(
f.autocompleter(
name: :task_type,
label: I18n.t(:backlogs_task_type),
title: I18n.t(:label_select_type),
caption: setting_caption(:plugin_openproject_backlogs, :task_type),
fetch_strategy: :local,
dynamic_label: true,
dynamic_label_prefix: I18n.t(:label_selected_type),
data: {
admin__backlogs_settings_target: "taskType"
input_width: :small,
autocomplete_options: {
multiple: false,
closeOnSelect: true,
clearable: false,
decorated: true,
data: {
admin__backlogs_settings_target: "taskType",
test_selector: "task_type_autocomplete"
}
}
) do |select_menu|
) do |list|
available_types.each do |label, value|
active = Task.type == value
in_use = value.in?(Story.types)

select_menu.with_item(
list.option(
label:,
content_arguments: { data: { value: } },
active:,
disabled: in_use,
item_id: "type-#{value}",
label_arguments: { classes: "__hl_inline_type_#{value}" }
value:,
selected: active,
disabled: in_use
)
end
end
Expand Down
4 changes: 0 additions & 4 deletions modules/backlogs/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,6 @@ en:
label_column_in_backlog: "Column in backlog"
label_points_burn_down: "Down"
label_points_burn_up: "Up"
label_select_type: "Select a type"
label_select_types: "Select types"
label_selected_type: "Selected type"
label_selected_types: "Selected types"
label_sprint_impediments: "Sprint Impediments"
label_task_board: "Task board"

Expand Down
Loading
Loading