From ec6cda6430ba067e2fa6a4f61b6ed8605cfcd06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Azimi?= Date: Thu, 11 Jan 2024 10:24:30 +0100 Subject: [PATCH] Change multi_list to allow preserving choice order --- CHANGELOG.md | 5 ++ README.md | 17 +++++ lib/tty/prompt/multi_list.rb | 20 ++++-- lib/tty/prompt/ordered_selected_choices.rb | 71 +++++++++++++++++++++ lib/tty/prompt/version.rb | 2 +- spec/unit/multi_select_spec.rb | 30 +++++++++ spec/unit/ordered_selected_choices_spec.rb | 73 ++++++++++++++++++++++ 7 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 lib/tty/prompt/ordered_selected_choices.rb create mode 100644 spec/unit/ordered_selected_choices_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b0e8e38..1a2a5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change log +## [v0.23.2] - 2023-01-11 + +### Changed +* Change multi_select to allow preserve user choice ordering + ## [v0.23.1] - 2021-04-17 ### Changed diff --git a/README.md b/README.md index 8313c85..b8368dd 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ Or install it yourself as: * [2.6.3.7 :filter](#2637-filter) * [2.6.3.8 :min](#2638-min) * [2.6.3.9 :max](#2639-max) + * [2.6.3.10 :preserve_order](#26310-preserve_order) * [2.6.4 enum_select](#264-enum_select) * [2.6.4.1 :per_page](#2641-per_page) * [2.6.4.1 :disabled](#2641-disabled) @@ -1153,6 +1154,22 @@ prompt.multi_select("Select drinks?", choices, max: 3) # ‣ ⬡ bourbon ``` +#### 2.6.3.10 `:preserve_order` + +To preserve the ordering of an user selections, use the `:preserve_order` option: + +```ruby +choices = %w(vodka beer wine whisky bourbon) +prompt.multi_select("Select drinks?", choices, preserve_order: true) +# => +# Select drinks? (max. 3) beer, vodka, whisky +# ⬢ vodka +# ⬢ beer +# ⬡ wine +# ⬢ whisky +# ‣ ⬡ bourbon +``` + ### 2.6.4 enum_select In order to ask for standard selection from indexed list you can use `enum_select` and pass question together with possible choices: diff --git a/lib/tty/prompt/multi_list.rb b/lib/tty/prompt/multi_list.rb index 0c50043..518e6b0 100644 --- a/lib/tty/prompt/multi_list.rb +++ b/lib/tty/prompt/multi_list.rb @@ -2,6 +2,7 @@ require_relative "list" require_relative "selected_choices" +require_relative "ordered_selected_choices" module TTY class Prompt @@ -18,7 +19,8 @@ class MultiList < List # @api public def initialize(prompt, **options) super - @selected = SelectedChoices.new + @preserve_order = options.fetch(:preserve_order, false) + @selected = selected_choices_klass.new @help = options[:help] @echo = options.fetch(:echo, true) @min = options[:min] @@ -71,7 +73,7 @@ def keyspace(*) def keyctrl_a(*) return if @max && @max < choices.size - @selected = SelectedChoices.new(choices.enabled, choices.enabled_indexes) + @selected = selected_choices_klass.new(choices.enabled, choices.enabled_indexes) end # Revert currently selected choices when Ctrl+I is pressed @@ -84,7 +86,7 @@ def keyctrl_r(*) acc << idx if !choice.disabled? && !@selected.include?(choice) acc end - @selected = SelectedChoices.new(choices.enabled - @selected.to_a, indexes) + @selected = selected_choices_klass.new(choices.enabled - @selected.to_a, indexes) end private @@ -102,7 +104,7 @@ def setup_defaults choices.index(choices.find_by(:name, d.to_s)) end end - @selected = SelectedChoices.new(@choices.values_at(*default_indexes), + @selected = selected_choices_klass.new(@choices.values_at(*default_indexes), default_indexes) if @default.empty? @@ -219,6 +221,16 @@ def render_menu output.join end + + # Render either SelectedChoices or OrderedSelectedChoices based on preserve_order option + # + # @return [Class] + # + # @api private + def selected_choices_klass + return OrderedSelectedChoices if @preserve_order + SelectedChoices + end end # MultiList end # Prompt end # TTY diff --git a/lib/tty/prompt/ordered_selected_choices.rb b/lib/tty/prompt/ordered_selected_choices.rb new file mode 100644 index 0000000..ed431e7 --- /dev/null +++ b/lib/tty/prompt/ordered_selected_choices.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module TTY + class Prompt + # @api private + class OrderedSelectedChoices + include Enumerable + + attr_reader :size + + # Create ordered selected choices + # + # @param [Array] selected + # @param [Array] indexes (ignored) + # + # @api public + def initialize(selected = [], _indexes = []) + @selected = selected + @size = @selected.size + end + + # Clear ordered selected choices + # + # @api public + def clear + @selected.clear + @size = 0 + end + + # Iterate over ordered selected choices + # + # @api public + def each(&block) + return to_enum unless block_given? + + @selected.each(&block) + end + + # Insert choice at the end + # + # @param [Integer] index (ignored) + # @param [Choice] choice + # + # @api public + def insert(_index, choice) + @selected << choice + @size += 1 + self + end + + # Delete choice at index + # + # @return [Choice] + # the deleted choice + # + # @api public + def delete_at(index) + return nil if index < 0 + return nil if index >= @size + + choice = @selected.delete_at(index) + @size -= 1 + choice + end + + def find_index_by(&search) + (0...@size).bsearch(&search) + end + end # OrderedSelectedChoices + end # Prompt +end # TTY diff --git a/lib/tty/prompt/version.rb b/lib/tty/prompt/version.rb index 54b02a4..63d9c45 100644 --- a/lib/tty/prompt/version.rb +++ b/lib/tty/prompt/version.rb @@ -2,6 +2,6 @@ module TTY class Prompt - VERSION = "0.23.1" + VERSION = "0.23.2" end # Prompt end # TTY diff --git a/spec/unit/multi_select_spec.rb b/spec/unit/multi_select_spec.rb index bf09b61..c243209 100644 --- a/spec/unit/multi_select_spec.rb +++ b/spec/unit/multi_select_spec.rb @@ -905,4 +905,34 @@ def exit_message(prompt, choices) expect(prompt.output.string).to eq(expected_output) end end + + context "with :preserve_order" do + it "preserves user choice ordering" do + choices = %w[A B C] + prompt.on(:keypress) { |e| + prompt.trigger(:keyup) if e.value == "k" + prompt.trigger(:keydown) if e.value == "j" + } + prompt.input << " " << "j" << " " << "j" << " " << "k" << " " << " " << "\r" + prompt.input.rewind + + value = prompt.multi_select("What letter?", choices, preserve_order: true, per_page: 100) + expect(value).to eq(%w[A C B]) + + expected_output = + output_helper("What letter?", choices, "A", [], init: true, preserve_order: true, + hint: "Press #{up_down} arrow to move, Space/Ctrl+A|R to select (all|rev) and Enter to finish") + + output_helper("What letter?", choices, "A", %w[A], preserve_order: true) + + output_helper("What letter?", choices, "B", %w[A], preserve_order: true) + + output_helper("What letter?", choices, "B", %w[A B], preserve_order: true) + + output_helper("What letter?", choices, "C", %w[A B], preserve_order: true) + + output_helper("What letter?", choices, "C", %w[A B C], preserve_order: true) + + output_helper("What letter?", choices, "B", %w[A B C], preserve_order: true) + + output_helper("What letter?", choices, "B", %w[A C], preserve_order: true) + + output_helper("What letter?", choices, "B", %w[A C B], preserve_order: true) + + exit_message("What letter?", %w[A C B]) + + expect(prompt.output.string).to eq(expected_output) + end + end end diff --git a/spec/unit/ordered_selected_choices_spec.rb b/spec/unit/ordered_selected_choices_spec.rb new file mode 100644 index 0000000..de19377 --- /dev/null +++ b/spec/unit/ordered_selected_choices_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +RSpec.describe TTY::Prompt::OrderedSelectedChoices do + it "inserts choices at the end" do + choices = %w[A B C D E F] + selected = described_class.new + + expect(selected.to_a).to eq([]) + expect(selected.size).to eq(0) + + selected.insert(5, "F") + selected.insert(1, "B") + selected.insert(3, "D") + selected.insert(0, "A") + selected.insert(4, "E") + selected.insert(2, "C") + + expect(selected.to_a).to eq(["F", "B", "D", "A", "E", "C"]) + expect(selected.size).to eq(6) + + expect(selected.delete_at(3)).to eq("A") + end + + it "initializes with selected choices" do + choices = %w[A B C D E F] + selected = described_class.new(choices, (0...choices.size).to_a) + + expect(selected.to_a).to eq(choices) + expect(selected.size).to eq(6) + + choice = selected.delete_at(3) + expect(choice).to eq("D") + + expect(selected.to_a).to eq(%w[A B C E F]) + expect(selected.size).to eq(5) + end + + it "inserts and deletes choices" do + selected = described_class.new + + selected.insert(5, "F") + selected.insert(1, "B") + selected.insert(3, "D") + selected.insert(0, "A") + + expect(selected.to_a).to eq(%w[F B D A]) + expect(selected.size).to eq(4) + + choice = selected.delete_at(2) + expect(choice).to eq("D") + expect(selected.to_a).to eq(%w[F B A]) + expect(selected.size).to eq(3) + + selected.insert(4, "E") + choice = selected.delete_at(-999) + + expect(choice).to eq(nil) + expect(selected.to_a).to eq(%w[F B A E]) + expect(selected.size).to eq(4) + end + + it "clears choices" do + selected = described_class.new(%w[B D F]) + + expect(selected.to_a).to eq(%w[B D F]) + expect(selected.size).to eq(3) + + selected.clear + + expect(selected.to_a).to eq([]) + expect(selected.size).to eq(0) + end +end