diff --git a/app/components/work_packages/moves/form_component.html.erb b/app/components/work_packages/moves/form_component.html.erb
index 6e88f819e17b..1dc7e95136c3 100644
--- a/app/components/work_packages/moves/form_component.html.erb
+++ b/app/components/work_packages/moves/form_component.html.erb
@@ -139,14 +139,20 @@ See COPYRIGHT and LICENSE files for more details.
<%= WorkPackage.human_attribute_name(:labels) %>
- <%= 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"
+ } %>
<% end %>
diff --git a/app/components/work_packages/moves/form_component.rb b/app/components/work_packages/moves/form_component.rb
index 39db6b4e8b99..a39a7629b5b0 100644
--- a/app/components/work_packages/moves/form_component.rb
+++ b/app/components/work_packages/moves/form_component.rb
@@ -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")
diff --git a/app/contracts/labels/create_contract.rb b/app/contracts/labels/create_contract.rb
index aa865cd7f669..ab1b26c70d3c 100644
--- a/app/contracts/labels/create_contract.rb
+++ b/app/contracts/labels/create_contract.rb
@@ -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
diff --git a/app/controllers/work_packages/id_list_normalization.rb b/app/controllers/work_packages/id_list_normalization.rb
index e5737c92ae2a..e8ddcc94904e 100644
--- a/app/controllers/work_packages/id_list_normalization.rb
+++ b/app/controllers/work_packages/id_list_normalization.rb
@@ -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)
diff --git a/app/models/label.rb b/app/models/label.rb
index b241ba3e8df1..10e9c2baf256 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -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
diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb
index 631816a2f9ec..bb8ce4325db4 100644
--- a/app/models/permitted_params.rb
+++ b/app/models/permitted_params.rb
@@ -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,
@@ -593,6 +594,7 @@ def self.permitted_attributes
{ target_version_ids: [] },
{ observed_in_version_ids: [] },
{ label_ids: [] },
+ { label_names: [] },
:priority_id
],
oauth_application: [
diff --git a/app/views/work_packages/bulk/edit.html.erb b/app/views/work_packages/bulk/edit.html.erb
index d4e1a335fa12..97401e94547a 100644
--- a/app/views/work_packages/bulk/edit.html.erb
+++ b/app/views/work_packages/bulk/edit.html.erb
@@ -178,13 +178,21 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html
index b28f1044c8ba..2263d897a130 100644
--- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html
+++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.html
@@ -15,6 +15,15 @@
[attr.data-action]="hiddenFieldAction"
[value]="value">
}
+ @if (pendingInputName) {
+ @for (value of pendingInputValues; track value) {
+
+ }
+ }
}
((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);
}
diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.spec.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.spec.ts
index b7c73c4875e4..90ce8e52b022 100644
--- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.spec.ts
+++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.spec.ts
@@ -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
diff --git a/spec/components/work_packages/moves/form_component_spec.rb b/spec/components/work_packages/moves/form_component_spec.rb
index 2d518caa89ab..8917cac8852e 100644
--- a/spec/components/work_packages/moves/form_component_spec.rb
+++ b/spec/components/work_packages/moves/form_component_spec.rb
@@ -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
diff --git a/spec/controllers/work_packages/bulk_controller_spec.rb b/spec/controllers/work_packages/bulk_controller_spec.rb
index e24d5b74ba9d..47039fa8a3be 100644
--- a/spec/controllers/work_packages/bulk_controller_spec.rb
+++ b/spec/controllers/work_packages/bulk_controller_spec.rb
@@ -651,6 +651,7 @@
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) }
@@ -658,7 +659,7 @@
put :update,
params: {
ids: work_package_ids,
- work_package: { label_ids: }
+ work_package: { label_ids:, label_names: }.compact
}
end
@@ -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
diff --git a/spec/controllers/work_packages/moves_controller_spec.rb b/spec/controllers/work_packages/moves_controller_spec.rb
index 72f3aa4e2472..ba5c8fd358ae 100644
--- a/spec/controllers/work_packages/moves_controller_spec.rb
+++ b/spec/controllers/work_packages/moves_controller_spec.rb
@@ -374,6 +374,7 @@
context "with labels" do
let(:existing_label) { create(:label) }
let(:new_label) { create(:label) }
+ let(:label_names) { nil }
before do
create(:labeling, label: existing_label, labelable: work_package)
@@ -381,8 +382,9 @@
post :create,
params: {
ids: [work_package.id],
- label_ids:
- }
+ label_ids:,
+ label_names:
+ }.compact
end
subject { work_package.reload.labels }
@@ -418,6 +420,16 @@
expect(subject).to contain_exactly(existing_label)
end
end
+
+ context "when a new label name is given" do
+ let(:label_ids) { [""] }
+ let(:label_names) { ["Brand new"] }
+
+ it "creates the label and replaces the work package's labels" do
+ expect(subject.map(&:name)).to contain_exactly("Brand new")
+ expect(Label.where(name: "Brand new").count).to eq(1)
+ end
+ end
end
context "with another budget" do
@@ -525,6 +537,15 @@
expect(subject).to contain_exactly(new_label)
end
end
+
+ context "when a new label name is given" do
+ let(:label_params) { { label_names: ["Brand new"] } }
+
+ it "gives the copy the new label and leaves the source unchanged" do
+ expect(subject.map(&:name)).to contain_exactly("Brand new")
+ expect(work_package.reload.labels).to contain_exactly(existing_label)
+ end
+ end
end
context "without changing the work package's attribute" do