diff --git a/README.md b/README.md index 3a75f3bb..9d767c78 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ bin/rails g * [GoodJob](./lib/generators/rolemodel/good_job) * [Editors](./lib/generators/rolemodel/editors) * [Tailored Select](./lib/generators/rolemodel/tailored_select) +* [Tom Select](./lib/generators/rolemodel/tom_select) * [Lograge](./lib/generators/rolemodel/lograge) ## Utilities diff --git a/lib/generators/rolemodel/README.md b/lib/generators/rolemodel/README.md index a2651040..68b371be 100644 --- a/lib/generators/rolemodel/README.md +++ b/lib/generators/rolemodel/README.md @@ -4,20 +4,31 @@ ## What you get +* [Editors](./editors) * [Github](./github) * [GoodJob](./good_job) * [Heroku](./heroku) +* [Kaminari](./kaminari) * [Linters](./linters) +* [Lograge](./lograge) * [Mailers](./mailers) -* [Modals](./modals) * [Optics](./optics) +* [React](./react) * [README](./readme) * [SaaS](./saas) +* [Sentry](./sentry) * [SimpleForm](./simple_form) * [Slim](./slim) * [SoftDestroyable](./soft_destroyable) * [Source Map](./source_map) * [Testing](./testing) +* [Tom Select](./tom_select) +* [Turbo](./turbo/all) + * [Ready](./turbo/ready) + * [Confirm](./turbo/confirm) + * [Modals](./turbo/modals) + * [Form](./turbo/form) +* [UI Components](./ui_components) * [Webpack](./webpack) ## Helpful documentation diff --git a/lib/generators/rolemodel/all_generator.rb b/lib/generators/rolemodel/all_generator.rb index 4b8ae9c0..d7eb6e36 100644 --- a/lib/generators/rolemodel/all_generator.rb +++ b/lib/generators/rolemodel/all_generator.rb @@ -12,7 +12,9 @@ def run_all_the_generators generate 'rolemodel:slim' generate 'rolemodel:optics:all' generate 'rolemodel:testing:all' + generate 'rolemodel:turbo:all' generate 'rolemodel:simple_form' + generate 'rolemodel:tom_select' generate 'rolemodel:soft_destroyable' generate 'rolemodel:saas:all' generate 'rolemodel:mailers' diff --git a/lib/generators/rolemodel/core_setup/README.md b/lib/generators/rolemodel/core_setup/README.md index 6b7c3116..35910902 100644 --- a/lib/generators/rolemodel/core_setup/README.md +++ b/lib/generators/rolemodel/core_setup/README.md @@ -21,6 +21,7 @@ app you can push straight to Heroku right after generation. - [Testing](../testing) — RSpec, FactoryBot, parallel_tests, TestProf (pass `--js-runner` to include jasmine-playwright-runner) - [SimpleForm](../simple_form) — SimpleForm with our configuration + - [Tom Select](../tom_select) — Tom Select with a Stimulus controller and SimpleForm inputs - [Linters](../linters) — Rubocop and ESLint - [UI Components](../ui_components) — flash, the modal pattern, & Turbo 8 support - [Editors](../editors) — EditorConfig and recommended VSCode extensions diff --git a/lib/generators/rolemodel/core_setup/core_setup_generator.rb b/lib/generators/rolemodel/core_setup/core_setup_generator.rb index 2efa2a6f..166ebbba 100644 --- a/lib/generators/rolemodel/core_setup/core_setup_generator.rb +++ b/lib/generators/rolemodel/core_setup/core_setup_generator.rb @@ -7,6 +7,7 @@ def run_the_core_generators generate 'rolemodel:webpack' generate 'rolemodel:optics:all' generate 'rolemodel:simple_form' + generate 'rolemodel:tom_select' generate 'rolemodel:testing:all' generate 'rolemodel:turbo:all' generate 'rolemodel:ui_components:flash' diff --git a/lib/generators/rolemodel/simple_form/simple_form_generator.rb b/lib/generators/rolemodel/simple_form/simple_form_generator.rb index a27b637b..82e09ff2 100644 --- a/lib/generators/rolemodel/simple_form/simple_form_generator.rb +++ b/lib/generators/rolemodel/simple_form/simple_form_generator.rb @@ -8,9 +8,7 @@ class SimpleFormGenerator < GeneratorBase desc: 'Install the tailored_select experimental component input' def add_gem - Bundler.with_unbundled_env do - bundle_command 'add simple_form' - end + bundle_command 'add simple_form' end def add_files diff --git a/lib/generators/rolemodel/tom_select/README.md b/lib/generators/rolemodel/tom_select/README.md new file mode 100644 index 00000000..74698fb8 --- /dev/null +++ b/lib/generators/rolemodel/tom_select/README.md @@ -0,0 +1,15 @@ +# Tom Select Generator + +`rails g rolemodel:tom_select` + +## What you get + +* The [Tom Select](https://tom-select.js.org/) package and its stylesheet +* A `tom-select` Stimulus controller that survives Turbo morphs +* `tom_select` and `grouped_tom_select` SimpleForm inputs, if the app uses SimpleForm + +```slim += f.input :project_manager, as: :tom_select, collection: User.all += f.input :substance, as: :tom_select, collection: Substance.all, allow_create: true += f.input :material, as: :grouped_tom_select, collection: Category.all, group_method: :materials +``` diff --git a/lib/generators/rolemodel/tom_select/USAGE b/lib/generators/rolemodel/tom_select/USAGE new file mode 100644 index 00000000..4a31c6c1 --- /dev/null +++ b/lib/generators/rolemodel/tom_select/USAGE @@ -0,0 +1,7 @@ +Description: + Installs the tom-select package, its stylesheet, and a tom-select Stimulus + controller. If the app is using SimpleForm, it also installs the matching + tom_select and grouped_tom_select SimpleForm inputs. + +Example: + rails generate rolemodel:tom_select diff --git a/lib/generators/rolemodel/tom_select/templates/app/inputs/grouped_tom_select_input.rb b/lib/generators/rolemodel/tom_select/templates/app/inputs/grouped_tom_select_input.rb new file mode 100644 index 00000000..7bc5de71 --- /dev/null +++ b/lib/generators/rolemodel/tom_select/templates/app/inputs/grouped_tom_select_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class GroupedTomSelectInput < SimpleForm::Inputs::GroupedCollectionSelectInput + def input(wrapper_options = nil) + super(merge_wrapper_options(tom_select_options, wrapper_options)) + end + + def input_html_classes = [] + + private + + def tom_select_options + { data: { controller: 'tom-select', action: TomSelectInput::MORPH_ACTIONS } } + end +end diff --git a/lib/generators/rolemodel/tom_select/templates/app/inputs/tom_select_input.rb b/lib/generators/rolemodel/tom_select/templates/app/inputs/tom_select_input.rb new file mode 100644 index 00000000..88bcc106 --- /dev/null +++ b/lib/generators/rolemodel/tom_select/templates/app/inputs/tom_select_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class TomSelectInput < SimpleForm::Inputs::CollectionSelectInput + MORPH_ACTIONS = 'turbo:before-morph-element->tom-select#disconnect:self turbo:morph-element->tom-select#afterMorph:self' + + def input(wrapper_options = nil) + super(merge_wrapper_options(tom_select_options, wrapper_options)) + end + + def input_html_classes = [] + + private + + def tom_select_options + { data: { controller: 'tom-select', tom_select_create_value: options.delete(:allow_create) || false, action: MORPH_ACTIONS } } + end +end diff --git a/lib/generators/rolemodel/tom_select/templates/app/javascript/controllers/tom_select_controller.js b/lib/generators/rolemodel/tom_select/templates/app/javascript/controllers/tom_select_controller.js new file mode 100644 index 00000000..e4fb1090 --- /dev/null +++ b/lib/generators/rolemodel/tom_select/templates/app/javascript/controllers/tom_select_controller.js @@ -0,0 +1,36 @@ +import { Controller } from '@hotwired/stimulus' +import TomSelect from 'tom-select' + +export default class extends Controller { + static values = { create: Boolean } + + connect() { + this.tomSelect = new TomSelect(this.element, this.settings) + this.element.tomSelect = this.tomSelect + } + + disconnect() { + // In case a Turbo morph is happening, we need to update TomSelect's revertSettings, + // in order to preserve the current value(s) when reconnecting. + this.tomSelect.revertSettings.innerHTML = this.element.innerHTML + + this.tomSelect.destroy() + delete this.element.tomSelect + } + + afterMorph() { + queueMicrotask(() => this.connect()) + } + + get settings() { + return { + create: this.createValue, + persist: false, + maxOptions: null, + delimiter: null, + maxItems: this.element.multiple ? null : 1, + plugins: this.element.multiple ? ['remove_button'] : [], + onItemAdd: () => this.tomSelect.setTextboxValue(''), // clear the input text box when a selection is made + } + } +} diff --git a/lib/generators/rolemodel/tom_select/tom_select_generator.rb b/lib/generators/rolemodel/tom_select/tom_select_generator.rb new file mode 100644 index 00000000..d39ef607 --- /dev/null +++ b/lib/generators/rolemodel/tom_select/tom_select_generator.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Rolemodel + class TomSelectGenerator < GeneratorBase + source_root File.expand_path('templates', __dir__) + + def add_tom_select_package + say 'Installing Tom Select package', :green + + yarn_command 'add tom-select' + end + + def import_stylesheet + say 'Importing Tom Select stylesheet', :green + + prepend_to_file application_stylesheet_path, <<~CSS + @import 'tom-select/dist/css/tom-select.css'; + CSS + end + + def add_stimulus_controller + say 'Adding Tom Select Stimulus controller', :green + + directory 'app/javascript/controllers' + + rails_command 'stimulus:manifest:update' + end + + def add_simple_form_inputs + return unless simple_form? + + say 'Installing the Tom Select SimpleForm inputs', :green + + directory 'app/inputs' + end + + private + + def simple_form? + File.exist?(File.expand_path('config/initializers/simple_form.rb', destination_root)) + end + end +end diff --git a/spec/generators/rolemodel/all_generator_spec.rb b/spec/generators/rolemodel/all_generator_spec.rb index e4cbf4d2..e58c4e22 100644 --- a/spec/generators/rolemodel/all_generator_spec.rb +++ b/spec/generators/rolemodel/all_generator_spec.rb @@ -1,7 +1,22 @@ RSpec.describe Rolemodel::AllGenerator, type: :generator do # Each sub-generator is exercised by its own spec, so here we only verify - # which generators get delegated to (and in what order). + # which generators get delegated to. let(:invoked_generators) { [] } + let(:generators_root) { File.expand_path('../../../lib/generators/rolemodel', __dir__) } + let(:excluded_generators) do + { + 'rolemodel:core_setup' => 'a subset of rolemodel:all', + 'rolemodel:tailored_select' => 'not production ready' + } + end + + # A directory with its own all generator is delegated to as a group. + def generators_on_disk + Dir.children(generators_root).select { File.directory?(File.join(generators_root, it)) }.map do |dir| + grouped = Dir.exist?(File.join(generators_root, dir, 'all')) || File.exist?(File.join(generators_root, dir, 'all_generator.rb')) + grouped ? "rolemodel:#{dir}:all" : "rolemodel:#{dir}" + end + end before do # Stub on the Actions module rather than the generator class: stubbing the @@ -14,42 +29,7 @@ run_generators end - it 'delegates to every rolemodel generator in order' do - expect(invoked_generators).to eq([ - 'rolemodel:github', - 'rolemodel:heroku', - 'rolemodel:readme', - 'rolemodel:webpack', - 'rolemodel:sentry', - 'rolemodel:react', - 'rolemodel:slim', - 'rolemodel:optics:all', - 'rolemodel:testing:all', - 'rolemodel:simple_form', - 'rolemodel:soft_destroyable', - 'rolemodel:saas:all', - 'rolemodel:mailers', - 'rolemodel:linters:all', - 'rolemodel:ui_components:all', - 'rolemodel:source_map', - 'rolemodel:good_job', - 'rolemodel:kaminari', - 'rolemodel:editors', - 'rolemodel:lograge' - ]) - end - - it 'only delegates to generators that exist' do - unknown = invoked_generators.reject { |name| Rails::Generators.find_by_namespace(name) } - - expect(unknown).to eq([]) - end - - it 'does not delegate to tailored_select, which is not production ready' do - expect(invoked_generators).not_to include('rolemodel:tailored_select') - end - - it 'does not delegate to a semaphore generator' do - expect(invoked_generators).not_to include(a_string_matching(/semaphore/)) + it 'delegates to every generator on disk that is not excluded' do + expect(invoked_generators).to match_array(generators_on_disk - excluded_generators.keys) end end diff --git a/spec/generators/rolemodel/tom_select_generator_spec.rb b/spec/generators/rolemodel/tom_select_generator_spec.rb new file mode 100644 index 00000000..d05495fc --- /dev/null +++ b/spec/generators/rolemodel/tom_select_generator_spec.rb @@ -0,0 +1,39 @@ +RSpec.describe Rolemodel::TomSelectGenerator, type: :generator do + it 'adds tom-select to package.json' do + run_generators + + assert_file 'package.json' do |content| + expect(content).to include('"tom-select":') + end + end + + it 'imports the tom-select stylesheet' do + run_generators + + assert_file 'app/assets/stylesheets/application.css' do |content| + expect(content).to include("@import 'tom-select/dist/css/tom-select.css';") + end + end + + it 'registers the stimulus controller' do + run_generators + + assert_file 'app/javascript/controllers/tom_select_controller.js' + assert_file 'app/javascript/controllers/index.js' do |content| + expect(content).to include('application.register("tom-select", TomSelectController)') + end + end + + it 'does not install the SimpleForm inputs when SimpleForm is absent' do + run_generators + + assert_no_file 'app/inputs/tom_select_input.rb' + end + + it 'installs the SimpleForm inputs when SimpleForm is present' do + run_generators generators: [::Rolemodel::SimpleFormGenerator, described_class] + + assert_file 'app/inputs/tom_select_input.rb' + assert_file 'app/inputs/grouped_tom_select_input.rb' + end +end