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.
<%= styled_label_tag :work_package_label_ids, WorkPackage.human_attribute_name(:labels) %>
- <%= 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" + } %>
<% 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