Skip to content

feat: Stimulus OrderedMultiselect — createable mode with inline item management#554

Open
vdedek wants to merge 5 commits intomasterfrom
vd/stimulus-ordered-multiselect
Open

feat: Stimulus OrderedMultiselect — createable mode with inline item management#554
vdedek wants to merge 5 commits intomasterfrom
vd/stimulus-ordered-multiselect

Conversation

@vdedek
Copy link

@vdedek vdedek commented Mar 13, 2026

Motivation

Admin interfaces often include simple taxonomies — records with just a title and position, like tags or feature labels assigned to a product. These are typically created on the fly while editing a parent record, often several in quick succession. Admins also need to rename or delete them on the spot, with changes propagating to all records that reference them.

This PR adds a Stimulus-based stimulus_ordered_multiselect helper that provides this functionality. It reuses the existing react_select autocomplete API endpoints (the react_select_* naming is a historical convention — these are plain JSON endpoints with no React dependency) and adds three new actions for create/update/destroy.

Summary

New Stimulus controller f-input-ordered-multiselect — a configurable ordered multiselect. Every feature is opt-in via flags, so the same component scales from a simple select+reorder widget to a full inline CRUD manager.

Configuration modes

Flags Result
createable: false, sortable: false, show_usage: false Plain select — pick items from dropdown, remove with X
sortable: true (default) Ordered select — drag-and-drop reordering via html5sortable
createable: true Full CRUD — create new items, inline rename, delete from DB
show_usage: true (default) Usage labels — "Použito v: Plan A, Plan B" hints below each item
show_usage: false No usage labels — cleaner look, items vertically centered

Helper signature

stimulus_ordered_multiselect(f, :relation_name,
  scope: nil,           # optional autocomplete scope
  order_scope: :ordered,
  sortable: true,       # drag-and-drop reordering
  createable: false,    # enables create/rename/delete
  show_usage: true,     # "Použito v:" labels on items
  required: nil,
  create_url: nil,      # custom API endpoints (auto-generated if nil)
  update_url: nil,
  delete_url: nil)

Features (when createable: true)

Feature Details
Select & order Tom Select (single mode) for async search + html5sortable for drag-and-drop reordering
Create Type a new label → "Create label…" option → POST to react_select_create → item added to list
Inline rename Edit icon in both the selected list and dropdown options → bare input → PATCH to react_select_update
Delete from DB Trash icon in dropdown → two-phase confirm (first click fetches usage count, second click with confirmed=true destroys) → animated removal
Remove from selection X button on selected items → marks for _destroy via hidden inputs (no DB delete)
Usage labels Muted hint below each item/option showing which parent records reference it (opt-in via usage_labels_for_warning on the model, toggleable via show_usage)
Real-time usage labels Client-side overlay of current record label based on form selection state — server data is adjusted to reflect unsaved changes
Duplicate detection Case-insensitive check on create and rename — swaps the usage hint text to an error message
No results __no_results__ dummy option workaround for Tom Select async load (Tom Select doesn't show no_results renderer for async)
Dropdown scrollbar .ts-dropdown-content capped at dynamic max-height (fits between dropdown top and form footer) with overflow-y: auto

Model requirements (for createable mode)

The through-target model (e.g. ComparisonFeature) should implement:

  • to_console_label — display label (inherited from Folio conventions)
  • usage_count_for_warning — integer, used in delete confirmation
  • usage_labels_for_warning — array of strings, shown as usage hints and in delete warning

Protective measures

  • CanCan authorization on all three new actions (authorize! :create/:update/:destroy)
  • Class validationsafe_constantize + < ActiveRecord::Base guard prevents arbitrary class instantiation
  • Two-phase delete — first request returns usage info, second with confirmed=true actually destroys
  • Duplicate detection — client-side case-insensitive check prevents unnecessary API calls
  • Soft removal — X button on selected items only marks _destroy in hidden inputs; actual deletion requires the trash icon + confirmation
  • _needsReload flag — marks Tom Select options as stale after mutations; dropdown refreshes on next open via clearOptions() + load('')
  • preventDropdownClose / restoreDropdownClose — monkey-patches tomSelect.close() during inline editing to prevent accidental dropdown dismissal, with document-level mousedown listener for outside-click detection and proper cleanup

No impact on existing components

  • The React OrderedMultiselectApp and react_ordered_multiselect helper are unchanged — both versions coexist
  • No existing Stimulus controllers modified
  • No shared styles modified (new SASS file with its own BEM namespace)
  • Routes are additive only (3 new collection routes on autocompletes)
  • The react_select action gains an optional usage_labels field in its response — backwards compatible, only present when the model responds to usage_labels_for_warning

Files

File Change
app/assets/javascripts/folio/input/ordered_multiselect.js NEW — Stimulus controller (~793 lines)
app/assets/stylesheets/folio/input/_ordered_multiselect.sass NEW — BEM styles (~158 lines)
app/assets/javascripts/folio/input.js Sprockets require
app/assets/stylesheets/folio/_input.sass SASS import
app/helpers/folio/console/react_helper.rb New stimulus_ordered_multiselect method
app/controllers/folio/console/api/autocompletes_controller.rb usage_labels in response + 3 new actions
config/routes.rb 3 new routes

Known limitations

  • Dropdown opens downward onlyadjustDropdownMaxHeight() dynamically caps the dropdown height to fit between its top edge and the form footer (or viewport bottom), with a minimum of 80 px. The dropdown does not flip upward when space below is limited.

  • Tom Select inline editing workarounds — inline rename/delete places interactive elements inside the Tom Select dropdown, which Tom Select was not designed for. This requires two coupled workarounds:

    1. preventDropdownClose monkey-patch — temporarily replaces tomSelect.close() with a no-op during editing, with a document-level mousedown listener for outside-click detection and cleanup.
    2. isFocused manual reset — because the rename input lives inside the dropdown, Tom Select never fires its onBlur handler. After editing ends, we manually set isFocused = true and call refreshState().

    Both workarounds are tested across Escape, Enter, click-outside, and confirm flows.

Test plan

  • Plain select modestimulus_ordered_multiselect(f, :tags, sortable: false, show_usage: false) renders minimal select + list, no drag handles, no usage labels, no CRUD buttons
  • Non-createable modestimulus_ordered_multiselect(f, :tags) renders select + reorder, no create/rename/delete buttons visible
  • show_usage: false — usage labels hidden, items vertically centered in both list and dropdown
  • Select existing item — appears in list, hidden inputs generated, position tracked
  • Reorder — drag items, positions update in hidden inputs
  • Remove from selection — X button removes from list, _destroy hidden input added, item reappears in dropdown
  • Create new — type label, select "Create label…", POST succeeds, item added to list
  • Inline rename (list) — edit icon → input appears → change label → Enter/checkmark → PATCH succeeds → label updated, flash animation
  • Inline rename (dropdown) — same flow inside open dropdown, dropdown stays open, option label updated
  • Double rename switch — clicking rename on item B while item A is being renamed cancels A and starts B
  • Cursor positioning in dropdown rename — clicking inside rename input text positions cursor, doesn't select the option
  • Duplicate detection — rename/create to existing label → usage hint swaps to error message, no API call
  • Delete (unused) — trash icon → confirm dialog → destroy, animated removal
  • Delete (used by N records) — first click shows usage warning with record names → confirm destroys
  • Empty dropdown — no available items → "Žádné výsledky" shown, dropdown stays open
  • Delete last item from dropdown — dropdown closes cleanly after animation
  • Usage labels update — removing item from selection updates usage labels in dropdown
  • Escape / click outside during rename — dropdown closes cleanly, can reopen
  • Enter in rename input — does NOT submit the parent <form>
  • React version unaffectedreact_ordered_multiselect still works identically

🤖 Generated with Claude Code

vdedek added 3 commits March 13, 2026 03:37
…management

Add new Stimulus controller f-input-ordered-multiselect with
stimulus_ordered_multiselect helper for managing has_many :through
relations with inline CRUD (create, rename, delete) directly in the
select dropdown.

Uses Tom Select (single mode) for async search + html5sortable for
drag-and-drop reordering. Reuses existing react_select autocomplete
endpoints and adds three new API actions (create/update/destroy) with
CanCan authorization and class validation.
…cal state

- __no_results__ dummy option keeps dropdown open when async load returns empty
- Usage labels overlay current record label based on form selection state
- Delete confirm dialog prefers local loadedOptions over server response
- Dropdown closes after deleting the last remaining option
- onItemRemoveClick syncs loadedOptions usage_labels
Mechanical refactoring — no behavior changes:
- Merge renderOptionWithActions + restoreOptionHtml into shared optionActionHtml
- Unify onOptionRenameInput + onItemRenameInputCheck into shared checkRenameDuplicate
- Extract fakeEvent helper in bindDropdownEvents
- Condense verbose HTML templates and remove redundant blank lines/comments
- Keep all section comments for readability
@vdedek
Copy link
Author

vdedek commented Mar 13, 2026

Demo: Možné vyzkoušet na boutique_private branch vd/bout-135-welcome-email (subscription plan (console/subscription_plans) edit form → "Vlastnosti do srovnávače nabídek")
image

@vdedek vdedek requested a review from mreq March 13, 2026 09:42
vdedek added 2 commits March 13, 2026 11:20
…condition

- Dropdown: cancel active rename before starting a new one (restoreOptionHtml)
- Dropdown: stopImmediatePropagation on edit input mousedown/click for cursor positioning
- Selected list: mousedown preventDefault on action buttons to prevent focus stealing
- Selected list: renderList() + double _blurCancelTimer clear to safely switch renames
- Cleanup: _blurCancelTimer in disconnect(), simplify onItemRenameCancel
Allows hiding "Použito v:" usage labels via show_usage: false.
When disabled, items are vertically centered (--no-usage CSS modifier).
Default: true (backwards compatible).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant