Skip to content

feat: OrderedMultiselect — createable mode with inline item management#553

Closed
vdedek wants to merge 24 commits intomasterfrom
vd/ordered-multiselect-createable
Closed

feat: OrderedMultiselect — createable mode with inline item management#553
vdedek wants to merge 24 commits intomasterfrom
vd/ordered-multiselect-createable

Conversation

@vdedek
Copy link

@vdedek vdedek commented Mar 12, 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.

Without createable mode, the component only supports selecting existing records. Any creation, renaming, or deletion requires navigating to a separate CRUD page and coming back. For such simple entities, that's unnecessary friction. Inline management directly in the select field keeps the admin in context and reduces the workflow to a minimum.

Summary

Extends the existing react_ordered_multiselect helper with an opt-in
createable: true mode. The original (non-createable) behaviour is
unchanged.

New in createable mode:

  • Users can create new options directly in the select dropdown (AsyncCreatableSelect)
  • Inline rename and delete of existing options in the open dropdown, without page navigation
  • Duplicate detection on create and rename (case-insensitive) — shows an error instead of sending a request
  • Delete warning dialog with a bullet list of affected records when deleting an option linked to multiple items
  • Usage count displayed per option
  • Action buttons styled as btn btn-none

Also fixes:

  • YAML single quotes for deleteWarningWithLabels translation (\n in double-quoted YAML caused JS SyntaxError in _translations.slim)

Test plan

  • Non-createable mode — no visible change in behaviour
  • Create new option via createable select — appears in list and persists
  • Rename existing option inline in dropdown — updated everywhere
  • Attempt rename/create with duplicate name — error shown, no request sent
  • Delete option assigned to 1+ records — warning dialog shows bullet list
  • Delete option assigned to 0 records — deletes without warning
  • Enter key in rename input does not submit the parent form

vdedek added 21 commits March 11, 2026 15:47
Add opt-in `createable: true` option to `react_ordered_multiselect`
helper for inline creation of new records directly from the multiselect.

- New `react_select_create` API endpoint in autocompletes controller
- OrderedMultiselectApp passes createable + onCreateOption to Select
- Select component forwards onCreateOption to AsyncCreatableSelect
- Rails helper generates data-createable and data-create-url attributes
…tableSelect

The createable loadOptions path was using formatOptions() which wraps
each API response object as {value: obj, label: obj}, causing react-select
to crash when trying to render objects as strings. Now properly extracts
value and label fields from the API response, matching the AsyncPaginate path.
…te) for createable mode

When createable: true, dropdown options now show a three-dot menu (⋮)
with Rename and Delete actions. Rename opens inline input directly in
the dropdown. Delete checks usage count via API and shows a warning
if the record is used by other records.

New API endpoints: PATCH react_select_update, DELETE react_select_destroy.
Custom OptionWithActions component for react-select with green + icon
for create option and sub-menu for existing options.
… menu

Override innerProps on Option component to check for data-owa-action
attribute before propagating mouse events to react-select. This prevents
the dropdown from closing when clicking the three-dot menu or sub-menu items.
Rename state was lost when dropdown closed (Option components unmount).
Now OptionWithActions only signals onStartRename to parent, and
OrderedMultiselectApp renders the rename input in place of the Select.
Enter submits, Escape cancels, blur submits.
Replace three-dot sub-menu with inline edit/delete icons visible on
hover. Rename now happens inline within the open dropdown — clicking
the edit icon transforms the label into an input field, Enter confirms,
Escape cancels. Removed rename-outside-select approach.
- Fix rename: add e.preventDefault() on mouseDown to keep dropdown open
- Fix rename input: add onMouseDown stopPropagation to prevent blur
- Delete dialog: use deleteFromDbConfirm for simple case, deleteWarning
  with count for shared records
- Icons: match Folio patterns (height=16, opacity 0.35, transition-base)
- Add deleteFromDbConfirm translations (cs/en)
- Keep menu open during rename by passing menuIsOpen=true when editing
- Rename input now appears inside the focused dropdown option
- Add rename icon to list items (already-added entries)
- Fix icon opacity/hover styles to match Folio conventions
… dirty state

- Menu stays open after rename (closes only on explicit close)
- Add checkmark confirm button next to rename inputs
- Debounce createable loadOptions to prevent flicker on each keystroke
- Don't fire change event on rename (saved via API immediately)
Track inlineEditing flag separately so onMenuClose ignores react-select's
close request while rename input holds focus.
- Use option.value (not option.id) for rename recordId — fixes order
  change when renaming server-loaded items (id = join table, value = record)
- Remove forceSelectRefresh() from onRenameSubmit — no more flash;
  dropdown shows new label immediately via local renamedLabel state
- isValidNewOption now checks by label (not value) to prevent duplicate
  create options appearing
- Add existingLabels prop so isValidNewOption can check already-added items
- Add duplicate check in onCreateOption with alreadyExists translation
- Add onBlur to dropdown rename input (submit when clicking outside)
- Pass onMenuClose only when createable
- is-invalid red border + invalid-feedback error text on rename inputs
  (both in dropdown options and list items) when duplicate detected
- Rename stays in edit mode on Enter if duplicate (doesn't close)
- Create input: noOptionsMessage shows alreadyExists when duplicate typed
- Fix Enter key causing form submission in createable select
… detection

Track API response in Select state and pass loadedOptions to SelectComponent
so OptionWithActions can check rename/create against unselected dropdown options.
…ons guard

- Use stopPropagation instead of preventDefault for creatable Enter key:
  react-select v5 skips its own handler when defaultPrevented is true,
  so Enter was not triggering onCreateOption
- isValidNewOption also checks this.state.loadedOptions (last API response)
  to catch duplicates during debounce timing gap
- noOptionsMessage also checks loadedOptions for 'already exists' case
- onCreateOption also checks loadedOptions before calling API
- Pass loadedOptions state up to App via onLoadedOptionsChange callback
… reducer

- duplicateDetection.test.js: tests for checkDuplicate function and
  isValidNewOption logic including the timing-gap scenario
- orderedMultiselect.test.js: tests for addItem, removeItem, renameItem,
  removeDeletedItem, updateItems reducers
- Export checkDuplicate from OptionWithActions to make it testable
…count

- _translations.slim: add missing keys (alreadyExists, rename, deleteFromDb,
  deleteFromDbConfirm, deleteWarning, addPlaceholder) — they were in YAML
  but never rendered to window.FolioConsole.translations
- Select/index.js: fix Enter page reload for createable — stopPropagation
  was preventing bubbling but not browser default action (form submit).
  react-select v3 also skips selectOption when defaultPrevented is true.
  Solution: do nothing in onKeyDown for createable, let parent wrapper
  call preventDefault after react-select has already processed the event
- OrderedMultiselectApp: add onKeyDown on wrapper div to preventDefault
  on Enter — fires after react-select's handler (event bubbles up),
  so onCreateOption is called first, then form submission is blocked
…e detection

Pass loadedOptions from App state down to Item component so renaming a
selected item is also checked against unselected DB items in the dropdown,
not only against other already-selected items.
…ing with labels list

- Refactor action buttons in Item and OptionWithActions to use btn btn-none p-0
- Return usage_labels from autocompletes API alongside usage_count
- Format delete warning as multi-line with bullet list and count
- Add deleteWarningWithLabels locale key (CS + EN)
@vdedek vdedek requested a review from mreq March 12, 2026 12:51
vdedek added 3 commits March 12, 2026 14:09
…ve duplication

- Extract buildAtomSettingsParams() to eliminate duplicated URL building in Select
- Extract isDuplicateLabel() to shared module, used by both Item and OptionWithActions
- Deduplicate async loadOptions error/empty-result handling into selectedAsFallback()
- Simplify onChange, remove verbose comments
- Fix document.querySelector → wrapRef.current for change event dispatch
- Remove useDebounceTimeout indirection
…ve isDuplicateLabel to utils

- Extract InlineRenameInput shared component (used by Item and OptionWithActions)
- Move isDuplicateLabel to utils/, update all imports
- Replace window.alert/confirm with FolioConsole.Ui.Flash where applicable
- Use extractIdFromValue in onSelect, remove inline duplication
- Convert CRUD handlers to async/await
- Remove fragile renamedLabel local state from OptionWithActions
- Add RENAME_ITEM + REMOVE_DELETED_ITEM to saga trigger list
- Remove dead yield $wrap line in saga
- Fix data-createable to emit "0" instead of nil for consistency with data-sortable
- Fix test import after isDuplicateLabel rename
@jirkamotejl
Copy link
Contributor

@vdedek nevím jestli máme v plánu dál rozvíjet react funkcionalitu. Nešlo by to udělat přes stimulus? 📞 kouzelné sluchátko...

@vdedek
Copy link
Author

vdedek commented Mar 12, 2026

@jirkamotejl Jde to udělat ve Stimulusu — pro async search je Tom Select, pro drag & drop SortableJS, inline editace je čistý DOM. Ale znamenalo by to buď:

  • Napsat novou Stimulus komponentu vedle té stávající React
  • Přepsat celý ordered multiselect do Stimulusu

Tady rozšiřuju existující React komponentu o opt-in režim. Pokud je ale dlouhodobý plán migrovat do stimulusu, tak to asi dává smysl napsat celé v něm. Je to, myslíš, žádoucí?

@vdedek
Copy link
Author

vdedek commented Mar 13, 2026

Udělám to ve stimulusu. Je to i příjemnější s tím pracovat a zároveň výrazně méně kódu.
#554

@vdedek vdedek closed this Mar 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants