Skip to content
Closed
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
22 changes: 14 additions & 8 deletions app/components/work_packages/moves/form_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,20 @@ See COPYRIGHT and LICENSE files for more details.
<%= WorkPackage.human_attribute_name(:labels) %>
</label>
<div class="form--field-container">
<%= styled_select_tag(
"label_ids[]",
content_tag("option", t(:label_no_change_option), value: "", selected: no_label_change_selected?) +
content_tag("option", t(:label_none), value: "none", selected: label_removal_selected?) +
options_from_collection_for_select(available_labels, :id, :name, selected_label_ids),
id: "label_ids",
multiple: true
) %>
<%= angular_component_tag "opce-autocompleter",
inputs: {
multiple: true,
defaultData: false,
items: label_items,
model: selected_label_model,
inputName: "label_ids[]",
pendingInputName: "label_names[]",
addTag: Label.creatable_by?(current_user),
addTagText: I18n.t("js.autocompleter.create_label"),
placeholder: t(:label_no_change_option),
bindLabel: "name",
labelForId: "label_ids"
} %>
</div>
</div>
<% end %>
Expand Down
14 changes: 11 additions & 3 deletions app/components/work_packages/moves/form_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,21 @@ def selected_versions_for(key, available)
available.select { |version| selected_ids.include?(version.id.to_s) }
end

def available_labels
@available_labels ||= Label.order(Arel.sql("LOWER(name)"))
def label_items
[none_label_item] + Label.order(Arel.sql("LOWER(name)")).map { { id: it.id, name: it.name } }
end

def selected_label_model
removal = label_removal_selected? ? [none_label_item] : []
existing = Label.where(id: selected_label_ids).order(Arel.sql("LOWER(name)")).map { { id: it.id, name: it.name } }
removal + existing + selected_label_names.map { { name: it } }
end

def none_label_item = { id: "none", name: I18n.t(:label_none) }

def selected_label_ids = Array(selected_values[:label_ids]).compact_blank.map(&:to_s)

def no_label_change_selected? = selected_label_ids.empty?
def selected_label_names = Array(selected_values[:label_names]).compact_blank

def label_removal_selected? = selected_label_ids.include?("none")

Expand Down
2 changes: 1 addition & 1 deletion app/contracts/labels/create_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class CreateContract < BaseContract
private

def user_allowed_to_create
unless user.active? && (user.admin? || user.allowed_in_any_project?(:edit_work_packages))
unless user.active? && Label.creatable_by?(user)
errors.add :base, :error_unauthorized
end
end
Expand Down
11 changes: 10 additions & 1 deletion app/controllers/work_packages/id_list_normalization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,18 @@ module WorkPackages::IdListNormalization
private

def extract_normalized_id_lists(attributes)
ID_LIST_ATTRIBUTES
id_lists = ID_LIST_ATTRIBUTES
.index_with { |key| normalized_id_list(attributes.delete(key)) }
.compact

id_lists.merge(normalized_label_names(attributes.delete(:label_names), id_lists[:label_ids]))
end

def normalized_label_names(raw, label_ids)
names = Array(raw).compact_blank
return {} if names.empty? || label_ids == []

{ label_names: names, label_ids: label_ids || [] }
end

def normalized_id_list(raw)
Expand Down
4 changes: 4 additions & 0 deletions app/models/label.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ class Label < ApplicationRecord
uniqueness: { case_sensitive: false },
length: { maximum: 255 }

def self.creatable_by?(user)
user.admin? || user.allowed_in_any_project?(:edit_work_packages)
end

def self.page_of(label, per_page:)
(where("LOWER(labels.name) < LOWER(?)", label.name).count / per_page) + 1
end
Expand Down
2 changes: 2 additions & 0 deletions app/models/permitted_params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ def self.permitted_attributes
{ target_version_ids: [] },
{ observed_in_version_ids: [] },
{ label_ids: [] },
{ label_names: [] },
:budget_id,
:parent_id,
:priority_id,
Expand Down Expand Up @@ -593,6 +594,7 @@ def self.permitted_attributes
{ target_version_ids: [] },
{ observed_in_version_ids: [] },
{ label_ids: [] },
{ label_names: [] },
:priority_id
],
oauth_application: [
Expand Down
22 changes: 15 additions & 7 deletions app/views/work_packages/bulk/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,21 @@ See COPYRIGHT and LICENSE files for more details.
<div class="form--field">
<%= styled_label_tag :work_package_label_ids, WorkPackage.human_attribute_name(:labels) %>
<div class="form--field-container">
<%= styled_select_tag(
"work_package[label_ids][]",
content_tag("option", t(:label_none), value: "none") +
options_from_collection_for_select(Label.order(Arel.sql("LOWER(name)")), :id, :name),
include_blank: t(:label_no_change_option),
multiple: true
) %>
<%= angular_component_tag "opce-autocompleter",
inputs: {
multiple: true,
defaultData: false,
items: [{ id: "none", name: t(:label_none) }] +
Label.order(Arel.sql("LOWER(name)")).map { { id: it.id, name: it.name } },
model: [],
inputName: "work_package[label_ids][]",
pendingInputName: "work_package[label_names][]",
addTag: Label.creatable_by?(current_user),
addTagText: I18n.t("js.autocompleter.create_label"),
placeholder: t(:label_no_change_option),
bindLabel: "name",
labelForId: "work_package_label_ids"
} %>
</div>
</div>
<% end %>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@
[attr.data-action]="hiddenFieldAction"
[value]="value">
}
@if (pendingInputName) {
@for (value of pendingInputValues; track value) {
<input
type="hidden"
[attr.name]="pendingInputName + '[]'"
[attr.data-action]="hiddenFieldAction"
[value]="value">
}
}
}
<ng-select
#ngSelectInstance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ export class OpAutocompleterComponent<T extends IAutocompleteItem = IAutocomplet

@Input() public inputName?:string;

@Input() public pendingInputName?:string;

@Input() public inputValue?:string|string[];

@Input() public multipleAsSeparateInputs = true;
Expand Down Expand Up @@ -395,13 +397,29 @@ export class OpAutocompleterComponent<T extends IAutocompleteItem = IAutocomplet
if (!this.model) {
return '';
} else if (Array.isArray(this.model)) {
const mappedValues = this.model.map((el) => ((typeof el === 'object' && el !== null) ? el[this.inputBindValue as 'id'] : el) as string);
const mappedValues = this.model
.map((el) => ((typeof el === 'object' && el !== null) ? el[this.inputBindValue as 'id'] : el) as string|null|undefined)
.filter((value):value is string => value !== null && value !== undefined);
return mappedValues.length > 0 ? mappedValues : [''];
} else {
return this.model[this.inputBindValue as 'id'] as string || '';
}
}

public get pendingInputValues():string[] {
if (!this.pendingInputName || !Array.isArray(this.model) || !this.bindLabel) {
return [];
}

const bindLabel = this.bindLabel;
const names = this.model
.filter((el) => typeof el === 'object' && el !== null && (el[this.inputBindValue as 'id'] ?? null) === null)
.map((el) => String(el[bindLabel as 'id']))
.filter((name) => name !== '');

return [...new Set(names)];
}

public repositionDropdown() {
repositionDropdownBugfix(this.ngSelectInstance);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,56 @@ describe('autocompleter', () => {
expect(getOptionsFnSpy).toHaveBeenCalledWith('Wor');
});
});

describe('hidden inputs for pending items', () => {
const model = [{ id: 7, name: 'Existing' }, { name: 'Needs triage' }];

beforeEach(() => {
const component = fixture.componentInstance;
component.multiple = true;
component.inputName = 'work_package[label_ids]';
component.bindLabel = 'name';
component.model = model as unknown as typeof component.model;
});

function hiddenInputs() {
return fixture.debugElement
.queryAll(By.css('input[type=hidden]'))
.map((el) => el.nativeElement as HTMLInputElement);
}

it('renders one id input and no empty input for a pending item', () => {
fixture.detectChanges();

const inputs = hiddenInputs();

expect(inputs.map((i) => [i.name, i.value])).toEqual([['work_package[label_ids][]', '7']]);
});

it('renders pending items under pendingInputName', () => {
fixture.componentInstance.pendingInputName = 'work_package[new_label_names]';
fixture.detectChanges();

const inputs = hiddenInputs();

expect(inputs.map((i) => [i.name, i.value])).toEqual([
['work_package[label_ids][]', '7'],
['work_package[new_label_names][]', 'Needs triage'],
]);
});

it('renders the clear fallback id input next to a lone pending item', () => {
const component = fixture.componentInstance;
component.pendingInputName = 'work_package[new_label_names]';
component.model = [{ name: 'Needs triage' }] as unknown as typeof component.model;
fixture.detectChanges();

expect(hiddenInputs().map((i) => [i.name, i.value])).toEqual([
['work_package[label_ids][]', ''],
['work_package[new_label_names][]', 'Needs triage'],
]);
});
});
});

// NG0953 ("Unexpected emit for destroyed OutputRef") comes from ng-select's
Expand Down
35 changes: 25 additions & 10 deletions spec/components/work_packages/moves/form_component_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,26 +137,41 @@ def build_component(**params)
context "with labels enabled", with_flag: { work_package_labels: true } do
let!(:label) { create(:label, name: "Frontend") }

it "defaults the labels select to '(no change)' and offers the labels" do
expect(rendered_component).to have_select("label_ids[]",
selected: I18n.t(:label_no_change_option),
with_options: [I18n.t(:label_none), "Frontend"])
def autocompleter_input(name)
rendered_component
JSON.parse(page.find("opce-autocompleter", visible: :all)["data-#{name}"])
end

context "with a selected label" do
it "offers the labels and the none item, with nothing preselected" do
expect(autocompleter_input("items"))
.to eq([{ "id" => "none", "name" => I18n.t(:label_none) },
{ "id" => label.id, "name" => "Frontend" }])
expect(autocompleter_input("model")).to eq([])
end

context "with a selected label and a typed name" do
let(:component) do
build_component(selected_values: { label_ids: [label.id.to_s] })
build_component(selected_values: { label_ids: [label.id.to_s], label_names: ["Brand new"] })
end

it "preserves both so a refresh does not drop them" do
expect(autocompleter_input("model"))
.to eq([{ "id" => label.id, "name" => "Frontend" }, { "name" => "Brand new" }])
end
end

context "with label removal selected" do
let(:component) { build_component(selected_values: { label_ids: ["none"] }) }

it "preserves the selected value" do
expect(rendered_component).to have_select("label_ids[]", selected: "Frontend")
it "preselects the none item" do
expect(autocompleter_input("model")).to eq([{ "id" => "none", "name" => I18n.t(:label_none) }])
end
end
end

context "with labels disabled", with_flag: { work_package_labels: false } do
it "does not render the labels select" do
expect(rendered_component).to have_no_select("label_ids[]")
it "does not render the labels autocompleter" do
expect(rendered_component).to have_no_css("opce-autocompleter", visible: :all)
end
end

Expand Down
34 changes: 33 additions & 1 deletion spec/controllers/work_packages/bulk_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -651,14 +651,15 @@
describe "#labels" do
shared_let(:existing_label) { create(:label) }
shared_let(:new_label) { create(:label) }
let(:label_names) { nil }

before do
work_packages.each { |wp| create(:labeling, label: existing_label, labelable: wp) }

put :update,
params: {
ids: work_package_ids,
work_package: { label_ids: }
work_package: { label_ids:, label_names: }.compact
}
end

Expand Down Expand Up @@ -697,6 +698,37 @@
expect(subject).to contain_exactly([existing_label.id])
end
end

context "with a new label name" do
let(:label_ids) { [""] }
let(:label_names) { ["Brand new"] }

it "creates one label and attaches it to every selected work package" do
created = Label.find_by!(name: "Brand new")
expect(Label.where(name: "Brand new").count).to eq(1)
expect(subject).to contain_exactly([created.id])
end
end

context "with a new label name and an existing label selected" do
let(:label_ids) { [new_label.id.to_s] }
let(:label_names) { ["Brand new"] }

it "attaches both to every selected work package" do
created = Label.find_by!(name: "Brand new")
expect(subject.map(&:sort)).to contain_exactly([new_label.id, created.id].sort)
end
end

context "with none selected together with a new name" do
let(:label_ids) { ["none"] }
let(:label_names) { ["Brand new"] }

it "clears the labels and creates no label" do
expect(subject).to contain_exactly([])
expect(Label.where(name: "Brand new")).not_to exist
end
end
end

describe "#done_ratio" do
Expand Down
Loading
Loading