alchemy_cms 8.3.10 → 8.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +58 -10
- data/app/assets/builds/alchemy/admin/page-select.css +1 -1
- data/app/assets/builds/alchemy/admin/print.css +1 -1
- data/app/assets/builds/alchemy/admin/remote-select.css +1 -0
- data/app/assets/builds/alchemy/admin/tags-autocomplete.css +1 -0
- data/app/assets/builds/alchemy/admin.css +4 -4
- data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
- data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
- data/app/assets/builds/alchemy/dark-theme.css +1 -1
- data/app/assets/builds/alchemy/light-theme.css +1 -1
- data/app/assets/builds/alchemy/preview.min.js +1 -1
- data/app/assets/builds/alchemy/theme.css +1 -1
- data/app/assets/builds/alchemy/welcome.css +1 -1
- data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
- data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
- data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
- data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
- data/app/assets/images/alchemy/icons-sprite.svg +1 -1
- data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +1 -1
- data/app/components/alchemy/admin/dashboard/widgets/news_reader.html.erb +38 -0
- data/app/components/alchemy/admin/dashboard/widgets/news_reader.rb +54 -0
- data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +1 -1
- data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +1 -1
- data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +11 -3
- data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +6 -1
- data/app/components/alchemy/admin/element_editor.html.erb +1 -1
- data/app/components/alchemy/admin/element_select.rb +30 -20
- data/app/components/alchemy/admin/element_status_indicators.html.erb +16 -0
- data/app/components/alchemy/admin/element_status_indicators.rb +31 -0
- data/app/components/alchemy/admin/link_dialog/external_tab.rb +2 -1
- data/app/components/alchemy/admin/main_navigation/entry.rb +61 -0
- data/app/components/alchemy/admin/main_navigation/menu.rb +21 -0
- data/app/components/alchemy/admin/main_navigation/sub_navigation.rb +31 -0
- data/app/components/alchemy/admin/page_node.html.erb +1 -1
- data/app/components/alchemy/admin/page_permitted_roles_select.html.erb +11 -0
- data/app/components/alchemy/admin/page_permitted_roles_select.rb +35 -0
- data/app/components/alchemy/admin/page_select.rb +18 -8
- data/app/components/alchemy/admin/preview_time_select.rb +1 -1
- data/app/components/alchemy/admin/toolbar_button.rb +43 -20
- data/app/components/alchemy/admin/uploader_button.rb +66 -0
- data/app/components/alchemy/ingredients/base_editor.rb +10 -1
- data/app/components/alchemy/ingredients/boolean_editor.rb +1 -0
- data/app/components/alchemy/ingredients/color_editor.rb +6 -3
- data/app/components/alchemy/ingredients/datetime_view.rb +1 -1
- data/app/components/alchemy/ingredients/picture_view.rb +2 -4
- data/app/components/alchemy/menubar.html.erb +157 -0
- data/app/components/alchemy/menubar.rb +15 -0
- data/app/controllers/alchemy/admin/attachments_controller.rb +12 -9
- data/app/controllers/alchemy/admin/base_controller.rb +34 -21
- data/app/controllers/alchemy/admin/dashboard_controller.rb +4 -0
- data/app/controllers/alchemy/admin/elements_controller.rb +4 -23
- data/app/controllers/alchemy/admin/languages_controller.rb +1 -1
- data/app/controllers/alchemy/admin/nodes_controller.rb +1 -1
- data/app/controllers/alchemy/admin/pages_controller.rb +1 -1
- data/app/controllers/alchemy/admin/pictures_controller.rb +6 -3
- data/app/controllers/alchemy/admin/resources_controller.rb +5 -5
- data/app/controllers/alchemy/admin/sites_controller.rb +1 -1
- data/app/controllers/alchemy/base_controller.rb +24 -16
- data/app/controllers/alchemy/pages_controller.rb +29 -8
- data/app/controllers/alchemy/sitemap_controller.rb +58 -0
- data/app/controllers/concerns/alchemy/admin/csp_protection.rb +43 -0
- data/app/controllers/concerns/alchemy/admin/current_language.rb +1 -1
- data/app/controllers/concerns/alchemy/admin/resource_filter.rb +3 -1
- data/{lib → app/controllers/concerns}/alchemy/controller_actions.rb +13 -0
- data/app/controllers/concerns/alchemy/legacy_page_redirects.rb +4 -4
- data/app/helpers/alchemy/admin/base_helper.rb +4 -1
- data/app/helpers/alchemy/admin/form_helper.rb +0 -2
- data/app/helpers/alchemy/admin/navigation_helper.rb +10 -181
- data/app/helpers/alchemy/elements_block_helper.rb +14 -0
- data/app/helpers/alchemy/resources_helper.rb +15 -1
- data/app/javascript/alchemy_admin/components/attachment_select.js +11 -20
- data/app/javascript/alchemy_admin/components/auto_submit.js +2 -4
- data/app/javascript/alchemy_admin/components/button.js +0 -6
- data/app/javascript/alchemy_admin/components/clear_schedule_button.js +21 -0
- data/app/javascript/alchemy_admin/components/color_select.js +50 -45
- data/app/javascript/alchemy_admin/components/conditional_field.js +50 -0
- data/app/javascript/alchemy_admin/components/dirty_guard.js +77 -0
- data/app/javascript/alchemy_admin/components/dom_id_select.js +17 -10
- data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +7 -2
- data/app/javascript/alchemy_admin/components/element_editor.js +29 -56
- data/app/javascript/alchemy_admin/components/element_select.js +26 -66
- data/app/javascript/alchemy_admin/components/icon.js +9 -4
- data/app/javascript/alchemy_admin/{image_cropper.js → components/image_cropper.js} +39 -53
- data/app/javascript/alchemy_admin/components/image_overlay_link.js +14 -0
- data/app/javascript/alchemy_admin/components/index.js +12 -1
- data/app/javascript/alchemy_admin/components/main_navi_entry.js +75 -0
- data/app/javascript/alchemy_admin/components/menu_node.js +73 -0
- data/app/javascript/alchemy_admin/components/news_reader.js +109 -0
- data/app/javascript/alchemy_admin/components/node_folder.js +36 -0
- data/app/javascript/alchemy_admin/components/node_form.js +34 -0
- data/app/javascript/alchemy_admin/components/node_select.js +25 -30
- data/app/javascript/alchemy_admin/components/node_tree.js +56 -0
- data/app/javascript/alchemy_admin/components/page_node.js +2 -1
- data/app/javascript/alchemy_admin/components/page_select.js +16 -34
- data/app/javascript/alchemy_admin/components/picture_archive.js +92 -0
- data/app/javascript/alchemy_admin/components/picture_editor.js +2 -1
- data/app/javascript/alchemy_admin/components/preview_window.js +11 -3
- data/app/javascript/alchemy_admin/components/remote_select.js +461 -81
- data/app/javascript/alchemy_admin/components/select.js +26 -88
- data/app/javascript/alchemy_admin/components/sitemap.js +2 -1
- data/app/javascript/alchemy_admin/components/sortable_elements.js +2 -1
- data/app/javascript/alchemy_admin/components/tags_autocomplete.js +63 -48
- data/app/javascript/alchemy_admin/components/tinymce.js +2 -1
- data/app/javascript/alchemy_admin/components/uploader/file_upload.js +3 -2
- data/app/javascript/alchemy_admin/components/uploader.js +19 -3
- data/app/javascript/alchemy_admin/confirm_dialog.js +7 -1
- data/app/javascript/alchemy_admin/dialog.js +182 -110
- data/app/javascript/alchemy_admin/hotkeys.js +0 -5
- data/app/javascript/alchemy_admin/i18n.js +3 -9
- data/app/javascript/alchemy_admin/image_overlay.js +58 -42
- data/app/javascript/alchemy_admin/initializer.js +2 -2
- data/app/javascript/alchemy_admin/link_dialog.js +15 -2
- data/app/javascript/alchemy_admin/routes.js +18 -0
- data/app/javascript/alchemy_admin/turbo_stream_actions.js +88 -0
- data/app/javascript/alchemy_admin/utils/ajax.js +14 -3
- data/app/javascript/alchemy_admin/utils/events.js +5 -0
- data/app/javascript/alchemy_admin/utils/json_script.js +12 -0
- data/app/javascript/alchemy_admin/utils/tom_select.js +171 -0
- data/app/javascript/alchemy_admin.js +2 -19
- data/app/javascript/preview.js +131 -113
- data/app/jobs/alchemy/invalidate_elements_cache_job.rb +1 -1
- data/app/models/alchemy/admin/content_security_policy.rb +143 -0
- data/app/models/alchemy/admin/filters/base.rb +7 -0
- data/app/models/alchemy/admin/filters/datepicker.rb +1 -1
- data/app/models/alchemy/admin/filters/select.rb +20 -7
- data/app/models/alchemy/attachment.rb +14 -7
- data/app/models/alchemy/cache_control.rb +104 -0
- data/app/models/alchemy/current.rb +1 -1
- data/app/models/alchemy/eager_loading.rb +0 -2
- data/app/models/alchemy/element.rb +23 -8
- data/app/models/alchemy/element_definition.rb +1 -0
- data/app/models/alchemy/elements_repository.rb +11 -0
- data/app/models/alchemy/folded_page.rb +5 -1
- data/app/models/alchemy/ingredient.rb +26 -0
- data/app/models/alchemy/ingredients/datetime.rb +1 -1
- data/app/models/alchemy/ingredients/link.rb +2 -0
- data/app/models/alchemy/ingredients/picture.rb +3 -0
- data/app/models/alchemy/ingredients/text.rb +2 -0
- data/app/models/alchemy/language.rb +19 -7
- data/app/models/alchemy/legacy_page_url.rb +6 -1
- data/app/models/alchemy/node.rb +51 -0
- data/app/models/alchemy/page/page_natures.rb +41 -8
- data/app/models/alchemy/page/page_scopes.rb +23 -0
- data/app/models/alchemy/page/sitemap_cache_key.rb +56 -0
- data/app/models/alchemy/page.rb +88 -32
- data/app/models/alchemy/page_definition.rb +1 -1
- data/app/models/alchemy/page_mutex.rb +16 -0
- data/app/models/alchemy/page_version.rb +24 -1
- data/app/models/alchemy/permissions.rb +6 -5
- data/app/models/alchemy/picture.rb +15 -7
- data/app/models/alchemy/picture_description.rb +22 -0
- data/app/models/alchemy/picture_thumb.rb +18 -0
- data/app/models/alchemy/publishable/timestamp_resolver.rb +70 -0
- data/app/models/alchemy/resource.rb +1 -1
- data/app/models/alchemy/safe_url_validator.rb +34 -0
- data/app/models/alchemy/site.rb +8 -3
- data/app/models/alchemy/storage_adapter/active_storage/preprocessor.rb +7 -3
- data/app/models/alchemy/storage_adapter/active_storage.rb +4 -1
- data/app/models/alchemy/tag.rb +10 -4
- data/{lib → app/models/concerns}/alchemy/filetypes.rb +5 -0
- data/app/models/concerns/alchemy/picture_thumbnails.rb +61 -6
- data/app/models/concerns/alchemy/publishable.rb +14 -29
- data/app/models/concerns/alchemy/taggable.rb +92 -0
- data/app/models/concerns/alchemy/user_methods.rb +95 -0
- data/app/services/alchemy/duplicate_element.rb +5 -2
- data/app/services/alchemy/element_preloader.rb +1 -2
- data/app/stylesheets/alchemy/_custom-properties.scss +1 -0
- data/app/stylesheets/alchemy/_extends.scss +0 -8
- data/app/stylesheets/alchemy/_themes.scss +10 -9
- data/app/stylesheets/alchemy/admin/_empty-state.scss +29 -0
- data/app/stylesheets/alchemy/admin/_help.scss +10 -0
- data/app/stylesheets/alchemy/admin/_tom-select.scss +104 -15
- data/app/stylesheets/alchemy/admin/archive.scss +56 -11
- data/app/stylesheets/alchemy/admin/base.scss +4 -5
- data/app/stylesheets/alchemy/admin/buttons.scss +12 -1
- data/app/stylesheets/alchemy/admin/dashboard.scss +150 -20
- data/app/stylesheets/alchemy/admin/dialogs.scss +27 -36
- data/app/stylesheets/alchemy/admin/element-select.scss +1 -1
- data/app/stylesheets/alchemy/admin/elements.scss +8 -24
- data/app/stylesheets/alchemy/admin/flash.scss +2 -1
- data/app/stylesheets/alchemy/admin/forms.scss +60 -24
- data/app/stylesheets/alchemy/admin/frame.scss +40 -4
- data/app/stylesheets/alchemy/admin/image_library.scss +9 -12
- data/app/stylesheets/alchemy/admin/navigation.scss +17 -14
- data/app/stylesheets/alchemy/admin/node-select.scss +13 -37
- data/app/stylesheets/alchemy/admin/nodes.scss +1 -1
- data/app/stylesheets/alchemy/admin/page-select.scss +6 -97
- data/app/stylesheets/alchemy/admin/print.scss +0 -4
- data/app/stylesheets/alchemy/admin/remote-select.scss +170 -0
- data/app/stylesheets/alchemy/admin/search.scss +2 -3
- data/app/stylesheets/alchemy/admin/selects.scss +1 -294
- data/app/stylesheets/alchemy/admin/sitemap.scss +2 -1
- data/app/stylesheets/alchemy/admin/tables.scss +7 -1
- data/app/stylesheets/alchemy/admin/tags-autocomplete.scss +17 -0
- data/app/stylesheets/alchemy/admin/tags.scss +0 -13
- data/app/stylesheets/alchemy/admin/toolbar.scss +9 -6
- data/app/stylesheets/alchemy/admin/upload.scss +61 -1
- data/app/stylesheets/alchemy/admin.scss +4 -1
- data/app/stylesheets/tinymce/skins/ui/alchemy/skin.scss +19 -0
- data/app/stylesheets/tinymce/skins/ui/alchemy-dark/skin.scss +19 -0
- data/app/types/alchemy/cache_control_type.rb +84 -0
- data/app/views/alchemy/_edit_mode.html.erb +1 -1
- data/app/views/alchemy/_menubar.html.erb +2 -154
- data/app/views/alchemy/_preview_mode_code.html.erb +0 -3
- data/app/views/alchemy/admin/_left_menu.html.erb +18 -5
- data/app/views/alchemy/admin/_top_menu.html.erb +1 -1
- data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +5 -4
- data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
- data/app/views/alchemy/admin/attachments/_files_list.html.erb +71 -83
- data/app/views/alchemy/admin/attachments/assign.turbo_stream.erb +8 -0
- data/app/views/alchemy/admin/attachments/index.html.erb +32 -7
- data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
- data/app/views/alchemy/admin/crop.html.erb +20 -20
- data/app/views/alchemy/admin/dashboard/_footer.html.erb +0 -3
- data/app/views/alchemy/admin/dashboard/_top.html.erb +1 -0
- data/app/views/alchemy/admin/dashboard/help.html.erb +84 -57
- data/app/views/alchemy/admin/dashboard/license.html.erb +6 -0
- data/app/views/alchemy/admin/elements/_footer.html.erb +0 -1
- data/app/views/alchemy/admin/elements/_form.html.erb +1 -1
- data/app/views/alchemy/admin/elements/_header.html.erb +2 -19
- data/app/views/alchemy/admin/elements/_preview_text_quote.html.erb +3 -0
- data/app/views/alchemy/admin/elements/_schedule.html.erb +7 -7
- data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -1
- data/app/views/alchemy/admin/elements/create.turbo_stream.erb +1 -1
- data/app/views/alchemy/admin/elements/index.html.erb +1 -1
- data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +2 -2
- data/app/views/alchemy/admin/elements/update.turbo_stream.erb +31 -0
- data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +6 -3
- data/app/views/alchemy/admin/languages/_table.html.erb +0 -2
- data/app/views/alchemy/admin/languages/index.html.erb +25 -29
- data/app/views/alchemy/admin/layoutpages/_layoutpage.html.erb +1 -3
- data/app/views/alchemy/admin/layoutpages/edit.html.erb +1 -1
- data/app/views/alchemy/admin/layoutpages/index.html.erb +26 -7
- data/app/views/alchemy/admin/nodes/_form.html.erb +3 -21
- data/app/views/alchemy/admin/nodes/_node.html.erb +93 -88
- data/app/views/alchemy/admin/nodes/index.html.erb +15 -23
- data/app/views/alchemy/admin/pages/_create_language_form.html.erb +2 -6
- data/app/views/alchemy/admin/pages/_form.html.erb +2 -1
- data/app/views/alchemy/admin/pages/_publication_fields.html.erb +1 -1
- data/app/views/alchemy/admin/pages/_table.html.erb +2 -4
- data/app/views/alchemy/admin/pages/_toolbar.html.erb +2 -1
- data/app/views/alchemy/admin/pages/edit.html.erb +10 -21
- data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +1 -1
- data/app/views/alchemy/admin/pages/index.html.erb +26 -31
- data/app/views/alchemy/admin/pages/info.html.erb +2 -2
- data/app/views/alchemy/admin/pages/update.turbo_stream.erb +2 -2
- data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +1 -1
- data/app/views/alchemy/admin/partials/_routes.html.erb +24 -48
- data/app/views/alchemy/admin/pictures/_archive.html.erb +3 -17
- data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -1
- data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +5 -3
- data/app/views/alchemy/admin/pictures/_form.html.erb +0 -1
- data/app/views/alchemy/admin/pictures/_infos.html.erb +5 -1
- data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
- data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -4
- data/app/views/alchemy/admin/pictures/assign.turbo_stream.erb +7 -0
- data/app/views/alchemy/admin/pictures/index.html.erb +38 -30
- data/app/views/alchemy/admin/pictures/show.html.erb +41 -41
- data/app/views/alchemy/admin/resources/_empty_state.html.erb +27 -0
- data/app/views/alchemy/admin/resources/_pagination.html.erb +1 -1
- data/app/views/alchemy/admin/resources/_per_page_select.html.erb +1 -1
- data/app/views/alchemy/admin/resources/_resource_table.html.erb +0 -2
- data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +3 -2
- data/app/views/alchemy/admin/resources/index.html.erb +32 -13
- data/app/views/alchemy/admin/sites/index.html.erb +24 -22
- data/app/views/alchemy/admin/styleguide/index.html.erb +35 -12
- data/app/views/alchemy/admin/tags/index.html.erb +27 -25
- data/app/views/alchemy/admin/tinymce/_setup.html.erb +6 -8
- data/app/views/alchemy/admin/translations/_en.js +3 -0
- data/app/views/alchemy/admin/uploader/_button.html.erb +11 -17
- data/app/views/alchemy/admin/uploader/_setup.html.erb +3 -3
- data/app/views/alchemy/pages/sitemap.xml.erb +2 -0
- data/app/views/kaminari/alchemy/_first_page.html.erb +1 -2
- data/app/views/kaminari/alchemy/_gap.html.erb +0 -1
- data/app/views/kaminari/alchemy/_last_page.html.erb +1 -2
- data/app/views/kaminari/alchemy/_next_page.html.erb +1 -2
- data/app/views/kaminari/alchemy/_page.html.erb +1 -2
- data/app/views/kaminari/alchemy/_paginator.html.erb +0 -1
- data/app/views/kaminari/alchemy/_prev_page.html.erb +1 -2
- data/app/views/layouts/alchemy/admin/dialog.html.erb +3 -0
- data/app/views/layouts/alchemy/admin.html.erb +5 -6
- data/config/brakeman.ignore +23 -1
- data/config/importmap.rb +10 -8
- data/config/initializers/dragonfly.rb +21 -17
- data/config/locales/alchemy.en.yml +53 -12
- data/config/routes.rb +3 -3
- data/db/migrate/20260727120000_add_permitted_roles_to_alchemy_pages.rb +23 -0
- data/db/migrate/20260820120000_add_cached_tag_list_to_taggables.rb +57 -0
- data/lib/alchemy/configuration/number_option.rb +13 -0
- data/lib/alchemy/configuration/proc_option.rb +13 -0
- data/lib/alchemy/configuration.rb +2 -0
- data/lib/alchemy/configurations/admin_components.rb +30 -0
- data/lib/alchemy/configurations/format_matchers.rb +7 -1
- data/lib/alchemy/configurations/main.rb +103 -4
- data/lib/alchemy/configurations/menu.rb +91 -0
- data/lib/alchemy/configurations/menu_item.rb +123 -0
- data/lib/alchemy/configurations/sitemap.rb +1 -0
- data/lib/alchemy/configurations/uploader.rb +15 -1
- data/lib/alchemy/engine.rb +0 -6
- data/lib/alchemy/i18n.rb +26 -0
- data/lib/alchemy/modules.rb +52 -50
- data/lib/alchemy/tasks/generate_thumbnails.rb +99 -0
- data/lib/alchemy/tasks/tidy.rb +1 -1
- data/lib/alchemy/test_support/capybara_helpers.rb +48 -37
- data/lib/alchemy/test_support/current_language_shared_examples.rb +1 -1
- data/lib/alchemy/test_support/factories/dummy_user_factory.rb +4 -1
- data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +132 -0
- data/lib/alchemy/test_support/shared_ingredient_examples.rb +52 -0
- data/lib/alchemy/test_support/shared_publishable_examples.rb +39 -4
- data/lib/alchemy/upgrader/eight_four.rb +237 -0
- data/lib/alchemy/upgrader/register_module_rewriter.rb +177 -0
- data/lib/alchemy/upgrader.rb +2 -1
- data/lib/alchemy/version.rb +1 -1
- data/lib/alchemy.rb +0 -1
- data/lib/alchemy_cms.rb +0 -13
- data/lib/generators/alchemy/elements/templates/view.html.erb +1 -1
- data/lib/generators/alchemy/elements/templates/view.html.haml +1 -1
- data/lib/generators/alchemy/elements/templates/view.html.slim +1 -1
- data/lib/generators/alchemy/install/install_generator.rb +2 -13
- data/lib/generators/alchemy/install/templates/alchemy.rb.tt +24 -9
- data/lib/generators/alchemy/module/module_generator.rb +1 -0
- data/lib/generators/alchemy/module/templates/ability.rb.tt +1 -1
- data/lib/generators/alchemy/module/templates/module_config.rb.tt +16 -18
- data/lib/generators/alchemy/user_columns_migration/templates/migration.rb.tt +19 -0
- data/lib/generators/alchemy/user_columns_migration/user_columns_migration_generator.rb +30 -0
- data/lib/tasks/alchemy/thumbnails.rake +16 -29
- data/lib/tasks/alchemy/upgrade.rake +45 -3
- data/vendor/javascript/cropperjs.min.js +3 -3
- data/vendor/javascript/floating-ui.min.js +1 -1
- data/vendor/javascript/tinymce.min.js +5 -5
- data/vendor/javascript/tom-select.min.js +1 -1
- metadata +85 -65
- data/app/javascript/alchemy_admin/components/action.js +0 -50
- data/app/javascript/alchemy_admin/dirty.js +0 -55
- data/app/javascript/alchemy_admin/node_tree.js +0 -82
- data/app/javascript/alchemy_admin/picture_selector.js +0 -67
- data/app/javascript/alchemy_admin/templates/compiled.js +0 -1
- data/app/javascript/alchemy_admin/templates/node_folder.hbs +0 -3
- data/app/stylesheets/alchemy/admin/attachment-select.scss +0 -19
- data/app/views/alchemy/admin/_main_navi.html.erb +0 -6
- data/app/views/alchemy/admin/attachments/assign.js.erb +0 -15
- data/app/views/alchemy/admin/pages/_page_layout_filter.html.erb +0 -30
- data/app/views/alchemy/admin/partials/_main_navigation_entry.html.erb +0 -20
- data/app/views/alchemy/admin/partials/_sub_navigation.html.erb +0 -11
- data/app/views/alchemy/admin/pictures/assign.js.erb +0 -13
- data/app/views/alchemy/base/error_notice.js.erb +0 -1
- data/app/views/alchemy/base/permission_denied.js.erb +0 -3
- data/app/views/alchemy/base/redirect.js.erb +0 -11
- data/config/alchemy/config.yml +0 -217
- data/config/alchemy/modules.yml +0 -82
- data/config/initializers/rails_live_reload.rb +0 -13
- data/lib/alchemy/dev_support/live_reload_watcher.rb +0 -5
- data/lib/alchemy/taggable.rb +0 -49
- data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +0 -34
- data/vendor/assets/stylesheets/alchemy_admin/select2.css +0 -1
- data/vendor/javascript/handlebars.min.js +0 -29
- data/vendor/javascript/jquery.min.js +0 -2
- data/vendor/javascript/rails-ujs.min.js +0 -1
- data/vendor/javascript/select2.min.js +0 -23
- /data/{lib → app/controllers/concerns}/alchemy/ability_helper.rb +0 -0
- /data/{lib → app/controllers/concerns}/alchemy/admin/locale.rb +0 -0
- /data/{lib → app/controllers/concerns}/alchemy/admin/preview_time.rb +0 -0
- /data/{lib → app/controllers/concerns}/alchemy/admin/timezone.rb +0 -0
- /data/{lib → app/controllers/concerns}/alchemy/configuration_methods.rb +0 -0
- /data/{lib → app/models}/alchemy/admin/preview_url.rb +0 -0
- /data/{lib → app/models/concerns}/alchemy/hints.rb +0 -0
- /data/{lib → app/models/concerns}/alchemy/name_conversions.rb +0 -0
- /data/{lib → app/services}/alchemy/elements_finder.rb +0 -0
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
.replace(/&/g, "&")
|
|
13
|
-
.replace(/</g, "<")
|
|
14
|
-
.replace(/>/g, ">")
|
|
15
|
-
.replace(/"/g, """)
|
|
16
|
-
}
|
|
1
|
+
import TomSelect from "tom-select"
|
|
2
|
+
import "alchemy_admin/components/icon"
|
|
3
|
+
import { escape_html } from "tom-select/utils"
|
|
4
|
+
import { growl } from "alchemy_admin/growler"
|
|
5
|
+
import { translate } from "alchemy_admin/i18n"
|
|
6
|
+
import {
|
|
7
|
+
createDropdownPositioning,
|
|
8
|
+
dropdownMessages,
|
|
9
|
+
focusTomSelect,
|
|
10
|
+
teardownTomSelect
|
|
11
|
+
} from "alchemy_admin/utils/tom_select"
|
|
17
12
|
|
|
18
13
|
/**
|
|
19
14
|
* Escapes a string for literal use inside a `RegExp`, so a term with
|
|
@@ -27,10 +22,10 @@ export function escapeRegExp(value) {
|
|
|
27
22
|
}
|
|
28
23
|
|
|
29
24
|
export function hightlightTerm(name, term) {
|
|
30
|
-
// The result is rendered raw as the
|
|
31
|
-
// before the term markup is woven in — otherwise a crafted name
|
|
32
|
-
// node title) is a stored XSS sink.
|
|
33
|
-
const escapedName =
|
|
25
|
+
// The result is rendered raw as the option's primary text, so the name must
|
|
26
|
+
// be escaped before the term markup is woven in — otherwise a crafted name
|
|
27
|
+
// (e.g. a menu node title) is a stored XSS sink.
|
|
28
|
+
const escapedName = escape_html(name)
|
|
34
29
|
if (!term) return escapedName
|
|
35
30
|
return escapedName.replace(
|
|
36
31
|
new RegExp(escapeRegExp(term), "gi"),
|
|
@@ -38,43 +33,101 @@ export function hightlightTerm(name, term) {
|
|
|
38
33
|
)
|
|
39
34
|
}
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Serializes a (possibly nested) params object into a Rails/Ransack style query
|
|
38
|
+
* string, e.g. `{ q: { name_cont: "x" }, page: 1 }` => `q%5Bname_cont%5D=x&page=1`.
|
|
39
|
+
* @param {object} params
|
|
40
|
+
* @param {string} [prefix]
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function serializeParams(params, prefix) {
|
|
44
|
+
return Object.keys(params)
|
|
45
|
+
.map((key) => {
|
|
46
|
+
const value = params[key]
|
|
47
|
+
const scopedKey = prefix ? `${prefix}[${key}]` : key
|
|
48
|
+
if (value === null || value === undefined) return ""
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
return value
|
|
51
|
+
.map(
|
|
52
|
+
(item) =>
|
|
53
|
+
`${encodeURIComponent(`${scopedKey}[]`)}=${encodeURIComponent(item)}`
|
|
54
|
+
)
|
|
55
|
+
.join("&")
|
|
56
|
+
}
|
|
57
|
+
if (typeof value === "object") {
|
|
58
|
+
return serializeParams(value, scopedKey)
|
|
59
|
+
}
|
|
60
|
+
return `${encodeURIComponent(scopedKey)}=${encodeURIComponent(value)}`
|
|
61
|
+
})
|
|
62
|
+
.filter((part) => part !== "")
|
|
63
|
+
.join("&")
|
|
64
|
+
}
|
|
49
65
|
|
|
50
|
-
|
|
66
|
+
export class RemoteSelect extends HTMLElement {
|
|
67
|
+
#tomSelect = null
|
|
68
|
+
// The currently selected item, used to compute the added/removed delta that
|
|
69
|
+
// consumers of the RemoteSelect.Change event rely on.
|
|
70
|
+
#selectedItem = null
|
|
71
|
+
// In multiple mode the same delta is computed against this map of the
|
|
72
|
+
// currently selected records, keyed by their id.
|
|
73
|
+
#selectedItems = new Map()
|
|
51
74
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
connectedCallback() {
|
|
76
|
+
const element = this.input
|
|
77
|
+
element.classList.add("alchemy_selectbox")
|
|
78
|
+
if (element instanceof HTMLInputElement) {
|
|
79
|
+
// Preselection is driven by the `selection` attribute, not by the input's
|
|
80
|
+
// value. Clear the value during setup so Tom Select does not turn it into a
|
|
81
|
+
// stray option and does not overwrite it with the selected id — the link
|
|
82
|
+
// dialog renders a URL in this input and reads it back. Restore it afterwards.
|
|
83
|
+
const inputValue = element.value
|
|
84
|
+
element.value = ""
|
|
85
|
+
this.#tomSelect = new TomSelect(element, this.tomSelectConfig)
|
|
86
|
+
element.value = inputValue
|
|
87
|
+
} else {
|
|
88
|
+
// A multiple select wraps a <select>, which has no such value to protect.
|
|
89
|
+
this.#tomSelect = new TomSelect(element, this.tomSelectConfig)
|
|
90
|
+
}
|
|
91
|
+
// A spinner shown inside the control while results load. Tom Select toggles
|
|
92
|
+
// the wrapper's `loading` class, which reveals it (see the stylesheet).
|
|
93
|
+
this.#tomSelect.control.append(document.createElement("sl-spinner"))
|
|
94
|
+
// Open the dropdown on click, mirroring the native <alchemy-select> (Tom
|
|
95
|
+
// Select does not open on click on its own once openOnFocus is off).
|
|
96
|
+
this.#tomSelect.control.addEventListener(
|
|
97
|
+
"click",
|
|
98
|
+
this.#tomSelect.open.bind(this.#tomSelect)
|
|
99
|
+
)
|
|
56
100
|
}
|
|
57
101
|
|
|
58
102
|
disconnectedCallback() {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
this.#select2.off("change", this.#onChange)
|
|
62
|
-
this.#select2.select2("destroy")
|
|
63
|
-
this.#select2 = null
|
|
64
|
-
}
|
|
103
|
+
teardownTomSelect(this.#tomSelect)
|
|
104
|
+
this.#tomSelect = null
|
|
65
105
|
}
|
|
66
106
|
|
|
67
|
-
|
|
68
|
-
|
|
107
|
+
focus() {
|
|
108
|
+
focusTomSelect(this.#tomSelect, () => super.focus())
|
|
109
|
+
}
|
|
69
110
|
|
|
70
111
|
/**
|
|
71
|
-
* Optional on change handler
|
|
72
|
-
* @param {
|
|
112
|
+
* Optional on change handler.
|
|
113
|
+
* @param {{added: ?object, removed: ?object}} event
|
|
73
114
|
*/
|
|
74
115
|
onChange(event) {
|
|
75
|
-
// Update selection attribute so re-attaching the
|
|
76
|
-
//
|
|
77
|
-
|
|
116
|
+
// Update selection attribute so re-attaching the component to the same
|
|
117
|
+
// input (e.g. after dragndrop) does not reset the selection. In multiple
|
|
118
|
+
// mode it holds the array of the currently selected records.
|
|
119
|
+
if (this.multiple) {
|
|
120
|
+
if (event.added) {
|
|
121
|
+
this.#selectedItems.set(String(event.added.id), event.added)
|
|
122
|
+
}
|
|
123
|
+
if (event.removed) {
|
|
124
|
+
this.#selectedItems.delete(String(event.removed.id))
|
|
125
|
+
}
|
|
126
|
+
this.setAttribute(
|
|
127
|
+
"selection",
|
|
128
|
+
JSON.stringify([...this.#selectedItems.values()])
|
|
129
|
+
)
|
|
130
|
+
} else if (event.added) {
|
|
78
131
|
this.setAttribute("selection", JSON.stringify(event.added))
|
|
79
132
|
}
|
|
80
133
|
this.dispatchCustomEvent("RemoteSelect.Change", {
|
|
@@ -84,18 +137,10 @@ export class RemoteSelect extends HTMLElement {
|
|
|
84
137
|
}
|
|
85
138
|
|
|
86
139
|
/**
|
|
87
|
-
* Optional on open handler called
|
|
88
|
-
* @param {Event} event
|
|
140
|
+
* Optional on open handler, called when the dropdown opens.
|
|
141
|
+
* @param {Event} [event]
|
|
89
142
|
*/
|
|
90
|
-
onOpen(
|
|
91
|
-
// add focus to the search input. Select2 is handling the focus on the first opening,
|
|
92
|
-
// but it does not work the second time. One process in select2 is "stealing" the focus
|
|
93
|
-
// if the command is not delayed. It is an intermediate solution until we are going to
|
|
94
|
-
// move away from Select2
|
|
95
|
-
setTimeout(() => {
|
|
96
|
-
document.querySelector("#select2-drop .select2-input").focus()
|
|
97
|
-
}, 100)
|
|
98
|
-
}
|
|
143
|
+
onOpen() {}
|
|
99
144
|
|
|
100
145
|
/**
|
|
101
146
|
* Dispatches a custom event with given name, namespaced under `Alchemy.`.
|
|
@@ -113,6 +158,10 @@ export class RemoteSelect extends HTMLElement {
|
|
|
113
158
|
return this.hasAttribute("allow-clear")
|
|
114
159
|
}
|
|
115
160
|
|
|
161
|
+
get multiple() {
|
|
162
|
+
return this.hasAttribute("multiple")
|
|
163
|
+
}
|
|
164
|
+
|
|
116
165
|
get selection() {
|
|
117
166
|
return this.getAttribute("selection")
|
|
118
167
|
}
|
|
@@ -130,27 +179,132 @@ export class RemoteSelect extends HTMLElement {
|
|
|
130
179
|
}
|
|
131
180
|
|
|
132
181
|
get input() {
|
|
133
|
-
|
|
182
|
+
// The wrapped control is an <input> in single mode and a <select> in
|
|
183
|
+
// multiple mode. Tom Select keeps the original element in place and inserts
|
|
184
|
+
// its own control after it, so the original always comes first.
|
|
185
|
+
return this.querySelector("input, select")
|
|
134
186
|
}
|
|
135
187
|
|
|
136
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Tom Select configuration.
|
|
190
|
+
* @returns {object}
|
|
191
|
+
*/
|
|
192
|
+
get tomSelectConfig() {
|
|
193
|
+
const self = this
|
|
194
|
+
const ajax = this.ajaxConfig
|
|
195
|
+
const plugins = { virtual_scroll: {} }
|
|
196
|
+
|
|
197
|
+
if (this.multiple) {
|
|
198
|
+
plugins.remove_button = {}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (this.allowClear) {
|
|
202
|
+
plugins.clear_button = {
|
|
203
|
+
html() {
|
|
204
|
+
return `<button type="button" class="clear-button" aria-label="${translate(
|
|
205
|
+
"Clear selection"
|
|
206
|
+
)}">
|
|
207
|
+
<alchemy-icon name="close" size="1x"></alchemy-icon>
|
|
208
|
+
</button>`
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const { onDropdownOpen, onDropdownClose } = createDropdownPositioning()
|
|
214
|
+
|
|
137
215
|
return {
|
|
216
|
+
plugins,
|
|
217
|
+
// The server returns each item with an `id` used as the option value.
|
|
218
|
+
valueField: "id",
|
|
219
|
+
// A single item unless the multiple attribute lifts the limit.
|
|
220
|
+
maxItems: this.multiple ? null : 1,
|
|
221
|
+
// Close the dropdown after selecting a single item; in multiple mode keep
|
|
222
|
+
// it open so several items can be picked in a row.
|
|
223
|
+
closeAfterSelect: !this.multiple,
|
|
224
|
+
// Show every item returned by the server, not just the first 50.
|
|
225
|
+
maxOptions: null,
|
|
226
|
+
// Searching is done on the server, so keep every returned option.
|
|
227
|
+
searchField: [],
|
|
228
|
+
// Debounce the server requests while typing.
|
|
229
|
+
loadThrottle: ajax.quietMillis,
|
|
230
|
+
// Refresh the input state (hide the selection, show the loading dropdown)
|
|
231
|
+
// on every keystroke without delay. The server request stays debounced by
|
|
232
|
+
// loadThrottle, so this only affects the immediate UI feedback.
|
|
233
|
+
refreshThrottle: 0,
|
|
138
234
|
placeholder: this.placeholder,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
235
|
+
// Behave like the local <alchemy-select>: focusing neither opens the
|
|
236
|
+
// dropdown nor fires a search. The first page of results is loaded when
|
|
237
|
+
// the dropdown opens (see onDropdownOpen), i.e. on click or typing.
|
|
238
|
+
openOnFocus: false,
|
|
239
|
+
// Load even for an empty search term to show the initial result list.
|
|
240
|
+
shouldLoad: () => true,
|
|
241
|
+
firstUrl: (query) => this.#requestUrl(query, 1),
|
|
242
|
+
load: (query, callback) => this.#load(query, callback),
|
|
243
|
+
onInitialize() {
|
|
244
|
+
if (!self.selection) return
|
|
245
|
+
// The preselection only carries enough data to label an item. Mark each
|
|
246
|
+
// one, so the option template can tell it apart from the complete records
|
|
247
|
+
// the server returns. Loading the record drops the mark again.
|
|
248
|
+
if (self.multiple) {
|
|
249
|
+
const items = JSON.parse(self.selection)
|
|
250
|
+
// Track the preselected items first, so the change handler recognizes
|
|
251
|
+
// them as unchanged. Adding them silently avoids a spurious change
|
|
252
|
+
// event while still rendering the items and marking the options.
|
|
253
|
+
self.#selectedItems = new Map(
|
|
254
|
+
items.map((item) => [String(item.id), item])
|
|
255
|
+
)
|
|
256
|
+
items.forEach((item) => {
|
|
257
|
+
this.addOption({ ...item, $preselection: true })
|
|
258
|
+
this.addItem(item.id, true)
|
|
259
|
+
})
|
|
260
|
+
} else {
|
|
261
|
+
const item = JSON.parse(self.selection)
|
|
262
|
+
// Track the preselected item first, so the change handler recognizes
|
|
263
|
+
// it as unchanged. Adding it silently avoids a spurious change event
|
|
264
|
+
// while still rendering the item and marking the option.
|
|
265
|
+
self.#selectedItem = item
|
|
266
|
+
this.addOption({ ...item, $preselection: true })
|
|
267
|
+
this.addItem(item.id, true)
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
onChange(value) {
|
|
271
|
+
self.#onChange(value, this)
|
|
272
|
+
},
|
|
273
|
+
onType(term) {
|
|
274
|
+
// While typing, give the search input an opaque background so it covers
|
|
275
|
+
// the selected item behind it. Without this the previous selection shows
|
|
276
|
+
// through the search box (see the `input-active` styles).
|
|
277
|
+
this.control_input.classList.toggle("has-value", term.length > 0)
|
|
278
|
+
},
|
|
279
|
+
onDropdownOpen() {
|
|
280
|
+
onDropdownOpen.call(this)
|
|
281
|
+
if (!this.loading) {
|
|
282
|
+
this.load(this.lastValue ?? "")
|
|
143
283
|
}
|
|
284
|
+
self.onOpen()
|
|
144
285
|
},
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
286
|
+
onDropdownClose,
|
|
287
|
+
render: {
|
|
288
|
+
option(item, _escape) {
|
|
289
|
+
// A preselection that the server has not returned yet only carries
|
|
290
|
+
// enough data to label the item, so render it like the control does.
|
|
291
|
+
// Everything else is a complete record and gets the list entry.
|
|
292
|
+
if (item.$preselection) {
|
|
293
|
+
return `<div>${self._renderResult(item)}</div>`
|
|
294
|
+
}
|
|
295
|
+
return self._renderListEntry(item, this.lastValue)
|
|
296
|
+
},
|
|
297
|
+
item(item, _escape) {
|
|
298
|
+
return `<div>${self._renderResult(item)}</div>`
|
|
299
|
+
},
|
|
300
|
+
...dropdownMessages
|
|
301
|
+
}
|
|
149
302
|
}
|
|
150
303
|
}
|
|
151
304
|
|
|
152
305
|
/**
|
|
153
|
-
* Ajax configuration for
|
|
306
|
+
* Ajax configuration. Kept for API compatibility with subclasses that spread
|
|
307
|
+
* `super.ajaxConfig` and augment it (e.g. adding request `params`).
|
|
154
308
|
* @returns {object}
|
|
155
309
|
*/
|
|
156
310
|
get ajaxConfig() {
|
|
@@ -164,7 +318,123 @@ export class RemoteSelect extends HTMLElement {
|
|
|
164
318
|
}
|
|
165
319
|
|
|
166
320
|
/**
|
|
167
|
-
*
|
|
321
|
+
* Builds the request url for a given search term and page.
|
|
322
|
+
* @param {string} term
|
|
323
|
+
* @param {number} page
|
|
324
|
+
* @returns {string}
|
|
325
|
+
* @private
|
|
326
|
+
*/
|
|
327
|
+
#requestUrl(term, page) {
|
|
328
|
+
const query = serializeParams(this.ajaxConfig.data(term, page))
|
|
329
|
+
const separator = this.url.includes("?") ? "&" : "?"
|
|
330
|
+
return query ? `${this.url}${separator}${query}` : this.url
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Loads results from the server and registers the next page url for the
|
|
335
|
+
* virtual scroll plugin.
|
|
336
|
+
* @param {string} query
|
|
337
|
+
* @param {function} callback
|
|
338
|
+
* @private
|
|
339
|
+
*/
|
|
340
|
+
async #load(query, callback) {
|
|
341
|
+
const ajax = this.ajaxConfig
|
|
342
|
+
const url = this.#tomSelect.getUrl(query)
|
|
343
|
+
const page = Number(
|
|
344
|
+
new URL(url, window.location.origin).searchParams.get("page")
|
|
345
|
+
)
|
|
346
|
+
try {
|
|
347
|
+
const response = await fetch(url, {
|
|
348
|
+
headers: { Accept: "application/json", ...(ajax.params?.headers ?? {}) }
|
|
349
|
+
})
|
|
350
|
+
// fetch only rejects on a network error, so an error response has to be
|
|
351
|
+
// turned into one. Its body often parses as JSON just fine and would
|
|
352
|
+
// otherwise reach the callback as an empty result set.
|
|
353
|
+
if (!response.ok) {
|
|
354
|
+
throw new Error(`${response.status} ${response.statusText}`)
|
|
355
|
+
}
|
|
356
|
+
const { results, more } = ajax.results(await response.json())
|
|
357
|
+
if (more) {
|
|
358
|
+
this.#tomSelect.setNextUrl(query, this.#requestUrl(query, page + 1))
|
|
359
|
+
}
|
|
360
|
+
callback(results)
|
|
361
|
+
} catch (error) {
|
|
362
|
+
growl(error.message || error, "error")
|
|
363
|
+
console.error(`[alchemy] Loading ${url} failed`, error)
|
|
364
|
+
// Tom Select only clears its loading state once the callback ran.
|
|
365
|
+
callback()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Computes the added/removed delta from a Tom Select change and forwards it to
|
|
371
|
+
* the `onChange` handler.
|
|
372
|
+
* @param {string} value
|
|
373
|
+
* @param {TomSelect} tomSelect
|
|
374
|
+
* @private
|
|
375
|
+
*/
|
|
376
|
+
#onChange(value, tomSelect) {
|
|
377
|
+
if (this.multiple) {
|
|
378
|
+
this.#onChangeMultiple(value, tomSelect)
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
const previous = this.#selectedItem
|
|
382
|
+
if (value === "" || value == null) {
|
|
383
|
+
if (!previous) return
|
|
384
|
+
this.#selectedItem = null
|
|
385
|
+
this.onChange({ added: null, removed: previous })
|
|
386
|
+
} else if (!previous || String(previous.id) !== String(value)) {
|
|
387
|
+
const added = this.#itemData(tomSelect.options[value])
|
|
388
|
+
this.#selectedItem = added
|
|
389
|
+
this.onChange({ added, removed: previous })
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Computes the per item added/removed deltas from a multiple Tom Select change
|
|
395
|
+
* (whose value is the array of selected ids) by diffing it against the tracked
|
|
396
|
+
* selection, and forwards each delta to the `onChange` handler.
|
|
397
|
+
* @param {string[]} values
|
|
398
|
+
* @param {TomSelect} tomSelect
|
|
399
|
+
* @private
|
|
400
|
+
*/
|
|
401
|
+
#onChangeMultiple(values, tomSelect) {
|
|
402
|
+
const ids = (values ?? []).map(String)
|
|
403
|
+
const previous = new Set(this.#selectedItems.keys())
|
|
404
|
+
const current = new Set(ids)
|
|
405
|
+
previous.forEach((id) => {
|
|
406
|
+
if (!current.has(id)) {
|
|
407
|
+
this.onChange({ added: null, removed: this.#selectedItems.get(id) })
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
ids.forEach((id) => {
|
|
411
|
+
if (!previous.has(id)) {
|
|
412
|
+
this.onChange({
|
|
413
|
+
added: this.#itemData(tomSelect.options[id]),
|
|
414
|
+
removed: null
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* An option also carries the bookkeeping Tom Select keeps on it, among it the
|
|
422
|
+
* node it was rendered into. Serializing that into the selection attribute
|
|
423
|
+
* breaks the render cache once a component is attached to it again, so keep
|
|
424
|
+
* the record itself only.
|
|
425
|
+
* @param {?object} option
|
|
426
|
+
* @returns {?object}
|
|
427
|
+
* @private
|
|
428
|
+
*/
|
|
429
|
+
#itemData(option) {
|
|
430
|
+
if (!option) return null
|
|
431
|
+
return Object.fromEntries(
|
|
432
|
+
Object.entries(option).filter(([key]) => !key.startsWith("$"))
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Search query send to server.
|
|
168
438
|
* @param {string} term
|
|
169
439
|
* @param {number} page
|
|
170
440
|
* @returns {object}
|
|
@@ -173,7 +443,7 @@ export class RemoteSelect extends HTMLElement {
|
|
|
173
443
|
_searchQuery(term, page) {
|
|
174
444
|
return {
|
|
175
445
|
q: {
|
|
176
|
-
|
|
446
|
+
name_i_cont: term,
|
|
177
447
|
...JSON.parse(this.queryParams)
|
|
178
448
|
},
|
|
179
449
|
page: page
|
|
@@ -181,9 +451,9 @@ export class RemoteSelect extends HTMLElement {
|
|
|
181
451
|
}
|
|
182
452
|
|
|
183
453
|
/**
|
|
184
|
-
* Parses server response into
|
|
454
|
+
* Parses server response into a results object.
|
|
185
455
|
* @param {object} response
|
|
186
|
-
* @returns {
|
|
456
|
+
* @returns {{results: Array, more: boolean}}
|
|
187
457
|
* @private
|
|
188
458
|
*/
|
|
189
459
|
_parseResponse(response) {
|
|
@@ -195,30 +465,140 @@ export class RemoteSelect extends HTMLElement {
|
|
|
195
465
|
}
|
|
196
466
|
|
|
197
467
|
/**
|
|
198
|
-
*
|
|
468
|
+
* Renders a dropdown option from the slots a subclass describes in `_entry`.
|
|
469
|
+
* External subclasses that still override `_renderListEntry` directly keep
|
|
470
|
+
* their own markup and never reach this.
|
|
199
471
|
* @param {object} item
|
|
472
|
+
* @param {string} term
|
|
200
473
|
* @returns {string}
|
|
201
474
|
* @private
|
|
202
475
|
*/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
"You need to define a _renderResult function on your sub class!"
|
|
206
|
-
)
|
|
476
|
+
_renderListEntry(item, term) {
|
|
477
|
+
return this.#renderEntry(this._entry(item, term))
|
|
207
478
|
}
|
|
208
479
|
|
|
209
480
|
/**
|
|
210
|
-
*
|
|
481
|
+
* Renders the selected item (shown in the control) from the slots a subclass
|
|
482
|
+
* describes in `_selectedEntry`. External subclasses that still override
|
|
483
|
+
* `_renderResult` directly keep their own markup and never reach this.
|
|
211
484
|
* @param {object} item
|
|
212
|
-
* @param {string} term
|
|
213
485
|
* @returns {string}
|
|
214
486
|
* @private
|
|
215
487
|
*/
|
|
216
|
-
|
|
488
|
+
_renderResult(item) {
|
|
489
|
+
return this.#renderSelection(this._selectedEntry(item))
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Describes the slots of a dropdown option. Subclasses override this and
|
|
494
|
+
* return an object with any of `icon`/`media`, `primary`, `aside`,
|
|
495
|
+
* `secondary`, and `secondaryAside`. See `#renderEntry` for the accepted
|
|
496
|
+
* value shapes.
|
|
497
|
+
* @param {object} item
|
|
498
|
+
* @param {string} term
|
|
499
|
+
* @returns {object}
|
|
500
|
+
* @protected
|
|
501
|
+
*/
|
|
502
|
+
_entry() {
|
|
503
|
+
throw new Error("You need to define an _entry function on your sub class!")
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Describes the slots of the selected item shown in the control. Subclasses
|
|
508
|
+
* override this and return an object with `icon`/`media`, `primary`, and
|
|
509
|
+
* `secondary`.
|
|
510
|
+
* @param {object} item
|
|
511
|
+
* @returns {object}
|
|
512
|
+
* @protected
|
|
513
|
+
*/
|
|
514
|
+
_selectedEntry() {
|
|
217
515
|
throw new Error(
|
|
218
|
-
"You need to define a
|
|
516
|
+
"You need to define a _selectedEntry function on your sub class!"
|
|
219
517
|
)
|
|
220
518
|
}
|
|
221
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Builds the two row grid markup of a dropdown option from its slots.
|
|
522
|
+
* @param {object} slots
|
|
523
|
+
* @returns {string}
|
|
524
|
+
* @private
|
|
525
|
+
*/
|
|
526
|
+
#renderEntry(slots) {
|
|
527
|
+
return `
|
|
528
|
+
<div class="remote-select--entry">
|
|
529
|
+
${this.#leadColumn(slots)}
|
|
530
|
+
${this.#cell("remote-select--primary", slots.primary, { raw: true })}
|
|
531
|
+
${this.#cell("remote-select--aside", slots.aside)}
|
|
532
|
+
${this.#cell("remote-select--secondary", slots.secondary)}
|
|
533
|
+
${this.#cell("remote-select--secondary-aside", slots.secondaryAside)}
|
|
534
|
+
</div>
|
|
535
|
+
`
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Builds the single row markup of the selected item from its slots. Only the
|
|
540
|
+
* leading column, the primary text, and the (fast shrinking) secondary text
|
|
541
|
+
* are shown; the compact control has no room for more.
|
|
542
|
+
* @param {object} slots
|
|
543
|
+
* @returns {string}
|
|
544
|
+
* @private
|
|
545
|
+
*/
|
|
546
|
+
#renderSelection(slots) {
|
|
547
|
+
return `
|
|
548
|
+
<div class="remote-select--selection">
|
|
549
|
+
${this.#leadColumn(slots)}
|
|
550
|
+
${this.#cell("remote-select--selection-name", slots.primary, { raw: true })}
|
|
551
|
+
${this.#cell("remote-select--selection-aside", slots.secondary)}
|
|
552
|
+
</div>
|
|
553
|
+
`
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Builds the leading column, either an icon or a media thumbnail. The two are
|
|
558
|
+
* mutually exclusive; the icon wins if a subclass passes both.
|
|
559
|
+
* @param {object} slots
|
|
560
|
+
* @returns {string}
|
|
561
|
+
* @private
|
|
562
|
+
*/
|
|
563
|
+
#leadColumn(slots) {
|
|
564
|
+
if (slots.icon) {
|
|
565
|
+
return `<alchemy-icon class="remote-select--icon" name="${escape_html(slots.icon)}"></alchemy-icon>`
|
|
566
|
+
}
|
|
567
|
+
if (slots.media) {
|
|
568
|
+
return `<img class="remote-select--media" src="${escape_html(slots.media)}" alt="">`
|
|
569
|
+
}
|
|
570
|
+
return ""
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Renders a single cell from a slot value. Omitted or empty values render
|
|
575
|
+
* nothing. A string is escaped, unless `raw` is set (the primary text is
|
|
576
|
+
* pre-highlighted HTML). `{ text, truncate: "head" }` wraps the text in
|
|
577
|
+
* `<bdi>` and truncates it from the head. `{ badge }` renders the pill style.
|
|
578
|
+
* @param {string} className
|
|
579
|
+
* @param {(string|object)} value
|
|
580
|
+
* @param {{raw?: boolean}} [options]
|
|
581
|
+
* @returns {string}
|
|
582
|
+
* @private
|
|
583
|
+
*/
|
|
584
|
+
#cell(className, value, { raw = false } = {}) {
|
|
585
|
+
if (value == null || value === "") return ""
|
|
586
|
+
|
|
587
|
+
if (typeof value === "object") {
|
|
588
|
+
if (value.badge != null && value.badge !== "") {
|
|
589
|
+
return `<span class="${className} remote-select--badge">${escape_html(value.badge)}</span>`
|
|
590
|
+
}
|
|
591
|
+
if (value.text == null || value.text === "") return ""
|
|
592
|
+
const text = raw ? value.text : escape_html(value.text)
|
|
593
|
+
if (value.truncate === "head") {
|
|
594
|
+
return `<span class="${className} remote-select--truncate-head"><bdi>${text}</bdi></span>`
|
|
595
|
+
}
|
|
596
|
+
return `<span class="${className}">${text}</span>`
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return `<span class="${className}">${raw ? value : escape_html(value)}</span>`
|
|
600
|
+
}
|
|
601
|
+
|
|
222
602
|
/**
|
|
223
603
|
* hightlighted search term
|
|
224
604
|
* @param {string} name
|