diff --git a/app/assets/javascripts/alchemy/admin.js b/app/assets/javascripts/alchemy/admin.js index 58c968966e..569b200b76 100644 --- a/app/assets/javascripts/alchemy/admin.js +++ b/app/assets/javascripts/alchemy/admin.js @@ -27,5 +27,4 @@ //= require alchemy/alchemy.list_filter //= require alchemy/alchemy.uploader //= require alchemy/alchemy.preview_window -//= require alchemy/page_select //= require alchemy/node_select diff --git a/app/assets/javascripts/alchemy/page_select.js b/app/assets/javascripts/alchemy/page_select.js deleted file mode 100644 index a3d470067c..0000000000 --- a/app/assets/javascripts/alchemy/page_select.js +++ /dev/null @@ -1,46 +0,0 @@ -$.fn.alchemyPageSelect = function (options) { - var pageTemplate = HandlebarsTemplates.page - - return this.select2({ - placeholder: options.placeholder, - allowClear: options.hasOwnProperty("allowClear") - ? options.allowClear - : true, - minimumInputLength: 3, - initSelection: function (_$el, callback) { - if (options.initialSelection) { - callback(options.initialSelection) - } - }, - ajax: { - url: options.url, - datatype: "json", - quietMillis: 300, - data: function (term, page) { - return { - q: $.extend( - { - name_cont: term - }, - options.query_params - ), - page: page - } - }, - results: function (data) { - var meta = data.meta - - return { - results: data.pages, - more: meta.page * meta.per_page < meta.total_count - } - } - }, - formatSelection: function (page) { - return page.text || page.name - }, - formatResult: function (page) { - return pageTemplate({ page: page }) - } - }) -} diff --git a/app/components/alchemy/admin/page_select.rb b/app/components/alchemy/admin/page_select.rb new file mode 100644 index 0000000000..f423d16c0f --- /dev/null +++ b/app/components/alchemy/admin/page_select.rb @@ -0,0 +1,42 @@ +module Alchemy + module Admin + class PageSelect < ViewComponent::Base + delegate :alchemy, to: :helpers + + def initialize(page = nil, url: nil, allow_clear: false, placeholder: Alchemy.t(:search_page), query_params: nil) + @page = page + @url = url + @allow_clear = allow_clear + @placeholder = placeholder + @query_params = query_params + end + + def call + content_tag("alchemy-page-select", content, attributes) + end + + private + + def attributes + options = { + placeholder: @placeholder, + url: @url || alchemy.api_pages_path + } + + options = options.merge({"allow-clear": @allow_clear}) if @allow_clear + options = options.merge({"query-params": @query_params.to_json}) if @query_params + + if @page + selection = { + id: @page.id, + name: @page.name, + url_path: @page.url_path + } + options = options.merge({selection: selection.to_json}) + end + + options + end + end + end +end diff --git a/app/javascript/alchemy_admin.js b/app/javascript/alchemy_admin.js index 786a407a8f..54a7df4915 100644 --- a/app/javascript/alchemy_admin.js +++ b/app/javascript/alchemy_admin.js @@ -25,6 +25,7 @@ $.fx.speeds._default = 400 import "alchemy_admin/components/char_counter" import "alchemy_admin/components/datepicker" import "alchemy_admin/components/overlay" +import "alchemy_admin/components/page_select" import "alchemy_admin/components/spinner" import "alchemy_admin/components/tinymce" import "alchemy_admin/components/tooltip" diff --git a/app/javascript/alchemy_admin/components/alchemy_html_element.js b/app/javascript/alchemy_admin/components/alchemy_html_element.js index 13d976cd2a..334642ffca 100644 --- a/app/javascript/alchemy_admin/components/alchemy_html_element.js +++ b/app/javascript/alchemy_admin/components/alchemy_html_element.js @@ -1,3 +1,5 @@ +import { toCamelCase } from "alchemy_admin/utils/string_conversions" + export class AlchemyHTMLElement extends HTMLElement { static properties = {} @@ -25,16 +27,16 @@ export class AlchemyHTMLElement extends HTMLElement { * @link https://developer.mozilla.org/en-US/docs/Web/API/Web_Components#reference */ connectedCallback() { - // parse the properties object and register property variables - Object.keys(this.constructor.properties).forEach((propertyName) => { + // parse the properties object and register property with the default values + Object.keys(this.constructor.properties).forEach((name) => { // if the options was given via the constructor, they should be prefer (e.g. new ({title: "Foo"})) - if (this.options[propertyName]) { - this[propertyName] = this.options[propertyName] - } else { - this._updateProperty(propertyName, this.getAttribute(propertyName)) - } + this[name] = + this.options[name] ?? this.constructor.properties[name].default }) + // then process the attributes + this.getAttributeNames().forEach((name) => this._updateFromAttribute(name)) + // render the component this._updateComponent() this.connected() @@ -54,8 +56,8 @@ export class AlchemyHTMLElement extends HTMLElement { * triggered by the browser, if one of the observed attributes is changing * @link https://developer.mozilla.org/en-US/docs/Web/API/Web_Components#reference */ - attributeChangedCallback(name, oldValue, newValue) { - this._updateProperty(name, newValue) + attributeChangedCallback(name) { + this._updateFromAttribute(name) this._updateComponent() } @@ -96,23 +98,21 @@ export class AlchemyHTMLElement extends HTMLElement { } /** - * update the property value - * if the value is undefined the default value is used + * update the value from the given attribute * - * @param {string} propertyName - * @param {string} value + * @param {string} name * @private */ - _updateProperty(propertyName, value) { - const property = this.constructor.properties[propertyName] + _updateFromAttribute(name) { + const attributeValue = this.getAttribute(name) + const propertyName = toCamelCase(name) + const isBooleanValue = + attributeValue.length === 0 || attributeValue === "true" + + const value = isBooleanValue ? true : attributeValue + if (this[propertyName] !== value) { this[propertyName] = value - if ( - typeof property.default !== "undefined" && - this[propertyName] === null - ) { - this[propertyName] = property.default - } this.changeComponent = true } } diff --git a/app/javascript/alchemy_admin/components/page_select.js b/app/javascript/alchemy_admin/components/page_select.js new file mode 100644 index 0000000000..1c4558a4a2 --- /dev/null +++ b/app/javascript/alchemy_admin/components/page_select.js @@ -0,0 +1,120 @@ +import { AlchemyHTMLElement } from "./alchemy_html_element" + +class PageSelect extends AlchemyHTMLElement { + static properties = { + allowClear: { default: false }, + selection: { default: undefined }, + placeholder: { default: "" }, + queryParams: { default: "{}" }, + url: { default: "" } + } + + connected() { + this.input.classList.add("alchemy_selectbox") + + const dispatchCustomEvent = (name, detail = {}) => { + this.dispatchEvent(new CustomEvent(name, { bubbles: true, detail })) + } + + $(this.input) + .select2(this.select2Config) + .on("select2-open", (event) => { + // add focus to the search input. Select2 is handling the focus on the first opening, + // but it does not work the second time. One process in select2 is "stealing" the focus + // if the command is not delayed. It is an intermediate solution until we are going to + // move away from Select2 + setTimeout(() => { + document.querySelector("#select2-drop .select2-input").focus() + }, 100) + }) + .on("change", (event) => { + if (event.added) { + dispatchCustomEvent("Alchemy.PageSelect.PageAdded", event.added) + } else { + dispatchCustomEvent("Alchemy.PageSelect.PageRemoved") + } + }) + } + + get input() { + return this.getElementsByTagName("input")[0] + } + + get select2Config() { + return { + placeholder: this.placeholder, + allowClear: this.allowClear, + initSelection: (_$el, callback) => { + if (this.selection) { + callback(JSON.parse(this.selection)) + } + }, + ajax: this.ajaxConfig, + formatSelection: this._renderResult, + formatResult: this._renderListEntry + } + } + + /** + * Ajax configuration for Select2 + * @returns {object} + */ + get ajaxConfig() { + const data = (term, page) => { + return { + q: { name_cont: term, ...JSON.parse(this.queryParams) }, + page: page + } + } + + const results = (data) => { + const meta = data.meta + return { + results: data.pages, + more: meta.page * meta.per_page < meta.total_count + } + } + + return { + url: this.url, + datatype: "json", + quietMillis: 300, + data, + results + } + } + + /** + * result which is visible if a page was selected + * @param {object} page + * @returns {string} + * @private + */ + _renderResult(page) { + return page.text || page.name + } + + /** + * html template for each list entry + * @param {object} page + * @returns {string} + * @private + */ + _renderListEntry(page) { + return ` +
+
+ + ${page.name} + ${page.url_path} +
+
+ ${page.site.name} + ${page.language.name} +
+
+ ` + } +} + +customElements.define("alchemy-page-select", PageSelect) diff --git a/app/javascript/alchemy_admin/utils/string_conversions.js b/app/javascript/alchemy_admin/utils/string_conversions.js new file mode 100644 index 0000000000..07f2255076 --- /dev/null +++ b/app/javascript/alchemy_admin/utils/string_conversions.js @@ -0,0 +1,10 @@ +/** + * convert dashes and underscore strings into camelCase strings + * @param {string} str + * @returns {string} + */ +export function toCamelCase(str) { + return str + .split(/-|_/) + .reduce((a, b) => a + b.charAt(0).toUpperCase() + b.slice(1)) +} diff --git a/app/views/alchemy/admin/nodes/_form.html.erb b/app/views/alchemy/admin/nodes/_form.html.erb index e8c1602b28..4175f37b2e 100644 --- a/app/views/alchemy/admin/nodes/_form.html.erb +++ b/app/views/alchemy/admin/nodes/_form.html.erb @@ -1,4 +1,4 @@ -<%= alchemy_form_for([:admin, node]) do |f| %> +<%= alchemy_form_for([:admin, node], id: "node_form") do |f| %> <% if node.new_record? && node.root? %> <%= f.input :menu_type, collection: Alchemy::Language.current.available_menu_names.map { |n| [I18n.t(n, scope: [:alchemy, :menu_names]), n] }, @@ -13,7 +13,9 @@ value: node.page && node.read_attribute(:name).blank? ? nil : node.name, placeholder: node.page ? node.page.name : nil } %> - <%= f.input :page_id, label: Alchemy::Page.model_name.human, input_html: { class: 'alchemy_selectbox' } %> + <%= render Alchemy::Admin::PageSelect.new(node.page, allow_clear: true) do %> + <%= f.input :page_id, label: Alchemy::Page.model_name.human %> + <% end %> <%= f.input :url, input_html: { disabled: node.page }, hint: Alchemy.t(:node_url_hint) %> <%= f.input :title %> <%= f.input :nofollow %> @@ -26,23 +28,20 @@ <% end %> diff --git a/app/views/alchemy/admin/pages/_form.html.erb b/app/views/alchemy/admin/pages/_form.html.erb index c5e12b0617..f619d07e82 100644 --- a/app/views/alchemy/admin/pages/_form.html.erb +++ b/app/views/alchemy/admin/pages/_form.html.erb @@ -1,6 +1,8 @@ <%= alchemy_form_for [:admin, @page], class: 'edit_page' do |f| %> <% unless @page.language_root? || @page.layoutpage %> - <%= f.input :parent_id, required: true, input_html: { class: 'alchemy_selectbox' } %> + <%= render Alchemy::Admin::PageSelect.new(@page.parent) do %> + <%= f.input :parent_id, required: true %> + <% end %> <% end %>
@@ -52,18 +54,3 @@ <%= f.submit Alchemy.t(:save) %> <% end %> - - diff --git a/app/views/alchemy/admin/pages/_new_page_form.html.erb b/app/views/alchemy/admin/pages/_new_page_form.html.erb index b2d070fd54..9785cc00d4 100644 --- a/app/views/alchemy/admin/pages/_new_page_form.html.erb +++ b/app/views/alchemy/admin/pages/_new_page_form.html.erb @@ -3,7 +3,9 @@ <%= f.hidden_field(:parent_id) %> <% else %> <% @page.parent = @current_language.root_page %> - <%= f.input :parent_id, as: :string, input_html: { class: 'alchemy_selectbox' } %> + <%= render Alchemy::Admin::PageSelect.new(@page.parent) do %> + <%= f.input :parent_id, as: :string %> + <% end %> <% end %> <%= f.hidden_field(:language_id) %> <%= f.hidden_field(:layoutpage) %> @@ -17,18 +19,3 @@ <%= f.input :name %> <%= f.submit Alchemy.t(:create) %> <% end %> - - diff --git a/app/views/alchemy/ingredients/_page_editor.html.erb b/app/views/alchemy/ingredients/_page_editor.html.erb index 99049ef698..1feaff3c68 100644 --- a/app/views/alchemy/ingredients/_page_editor.html.erb +++ b/app/views/alchemy/ingredients/_page_editor.html.erb @@ -3,23 +3,11 @@ data: page_editor.data_attributes do %> <%= element_form.fields_for(:ingredients, page_editor.ingredient) do |f| %> <%= ingredient_label(page_editor, :page_id) %> - <%= f.text_field :page_id, - value: page_editor.page&.id, - id: page_editor.form_field_id(:page_id), - class: 'alchemy_selectbox full_width' %> - <% end %> -<% end %> - - +<% end %> diff --git a/package.json b/package.json index 459a0db1f5..0ba5634c68 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "jest": "^29.6.4", "jest-environment-jsdom": "^29.6.4", "jsdom-testing-mocks": "^1.11.0", + "jquery": "^2.2.4", "lodash-es": "^4.17.21", "prettier": "^3.0.0", "sortablejs": "^1.10.2", @@ -25,7 +26,8 @@ "Alchemy": {} }, "moduleNameMapper": { - "alchemy_admin/(.*)": "/app/javascript/alchemy_admin/$1" + "alchemy_admin/(.*)": "/app/javascript/alchemy_admin/$1", + "vendor/(.*)": "/vendor/assets/javascripts/$1" }, "testEnvironment": "jsdom", "roots": [ diff --git a/spec/components/alchemy/admin/page_select_spec.rb b/spec/components/alchemy/admin/page_select_spec.rb new file mode 100644 index 0000000000..e92720fe2d --- /dev/null +++ b/spec/components/alchemy/admin/page_select_spec.rb @@ -0,0 +1,85 @@ +require "rails_helper" + +RSpec.describe Alchemy::Admin::PageSelect, type: :component do + before do + render + end + + context "without parameters" do + subject(:render) do + render_inline(described_class.new) { "Page Select Content" } + end + + it "should render the component and render given block content" do + expect(page).to have_selector("alchemy-page-select") + expect(page).to have_text("Page Select Content") + end + + it "should not allow clearing" do + expect(page).not_to have_selector("alchemy-page-select[allow-clear]") + end + + it "should have the default placeholder" do + expect(page).to have_selector("alchemy-page-select[placeholder='Search page']") + end + + it "should have the default page api - url" do + expect(page).to have_selector("alchemy-page-select[url='/api/pages']") + end + + it "should not have a selection" do + expect(page).to_not have_selector("alchemy-page-select[selection]") + end + end + + context "with page" do + let(:alchemy_page) { create(:alchemy_page, id: 123, name: "Test Page") } + subject(:render) do + render_inline(described_class.new(alchemy_page)) + end + + it "should have a serialized page information" do + expect(page).to have_selector('alchemy-page-select[selection="{\"id\":123,\"name\":\"Test Page\",\"url_path\":\"/test-page\"}"]') + end + end + + context "with url" do + subject(:render) do + render_inline(described_class.new(nil, url: "/foo-bar")) + end + + it "should have an url parameter" do + expect(page).to have_selector('alchemy-page-select[url="/foo-bar"]') + end + end + + context "with allow clear" do + subject(:render) do + render_inline(described_class.new(nil, allow_clear: true)) + end + + it "should not have a allow_clear attribute" do + expect(page).to have_selector("alchemy-page-select[allow-clear]") + end + end + + context "with custom placeholder" do + subject(:render) do + render_inline(described_class.new(nil, placeholder: "Custom Placeholder")) + end + + it "should have a custom placeholder" do + expect(page).to have_selector("alchemy-page-select[placeholder='Custom Placeholder']") + end + end + + context "with query parameter" do + subject(:render) do + render_inline(described_class.new(nil, query_params: {foo: :bar})) + end + + it "should have serialized custom parameter" do + expect(page).to have_selector('alchemy-page-select[query-params="{\"foo\":\"bar\"}"]') + end + end +end diff --git a/spec/javascript/alchemy_admin/components/alchemy_html_element.spec.js b/spec/javascript/alchemy_admin/components/alchemy_html_element.spec.js index 37d6c84b53..21c197c016 100644 --- a/spec/javascript/alchemy_admin/components/alchemy_html_element.spec.js +++ b/spec/javascript/alchemy_admin/components/alchemy_html_element.spec.js @@ -67,7 +67,9 @@ describe("AlchemyHTMLElement", () => { class Test extends AlchemyHTMLElement { static properties = { size: { default: "medium" }, - color: { default: "currentColor" } + color: { default: "currentColor" }, + longLongAttribute: { default: "foo" }, + booleanType: { default: false } } } ) @@ -89,6 +91,30 @@ describe("AlchemyHTMLElement", () => { expect(component.size).toEqual("large") }) + it("should cast dashes to camelcase", () => { + createComponent("test-camelcase") + component = renderComponent( + "test-camelcase", + `` + ) + expect(component.longLongAttribute).toEqual("bar") + }) + + it("should support boolean types", () => { + createComponent("test-boolean") + component = renderComponent( + "test-boolean", + `` + ) + expect(component.booleanType).toBeTruthy() + + const second_component = renderComponent( + "test-boolean", + `` + ) + expect(second_component.booleanType).toBeFalsy() + }) + it("should observe an attribute change", () => { createComponent("test-color") expect(component.color).toEqual("currentColor") diff --git a/spec/javascript/alchemy_admin/components/page_select.spec.js b/spec/javascript/alchemy_admin/components/page_select.spec.js new file mode 100644 index 0000000000..991953815a --- /dev/null +++ b/spec/javascript/alchemy_admin/components/page_select.spec.js @@ -0,0 +1,80 @@ +import { renderComponent } from "./component.helper" + +// import jquery and append it to the window object +import jQuery from "jquery" +globalThis.$ = jQuery +globalThis.jQuery = jQuery + +import "alchemy_admin/components/page_select" +import("vendor/jquery_plugins/select2") + +describe("alchemy-page-select", () => { + /** + * + * @type {HTMLElement | undefined} + */ + let component = undefined + + describe("without configuration", () => { + beforeEach(() => { + const html = ` + + + + ` + component = renderComponent("alchemy-page-select", html) + }) + + it("should render the input field", () => { + expect(component.getElementsByTagName("input")[0]).toBeInstanceOf( + HTMLElement + ) + }) + + it("should initialize Select2", () => { + expect( + component.getElementsByClassName("select2-container").length + ).toEqual(1) + }) + + it("should not show a remove 'button'", () => { + expect( + document.querySelector(".select2-container.select2-allowclear") + ).toBeNull() + }) + }) + + describe("allow clear", () => { + beforeEach(() => { + const html = ` + + + + ` + component = renderComponent("alchemy-page-select", html) + }) + + it("should show a remove 'button'", () => { + expect(component.allowClear).toBeTruthy() + }) + }) + + describe("query params", () => { + beforeEach(() => { + const html = ` + + + + ` + component = renderComponent("alchemy-page-select", html) + }) + + it("should receive query parameter", () => { + expect(JSON.parse(component.queryParams)).toEqual({ foo: "bar" }) + }) + + it("should add the query parameter to the API call", () => { + expect(component.ajaxConfig.data("test").q.foo).toEqual("bar") + }) + }) +}) diff --git a/spec/javascript/alchemy_admin/components/tinymce.spec.js b/spec/javascript/alchemy_admin/components/tinymce.spec.js index adbfe5859c..c2386276b3 100644 --- a/spec/javascript/alchemy_admin/components/tinymce.spec.js +++ b/spec/javascript/alchemy_admin/components/tinymce.spec.js @@ -1,6 +1,6 @@ import { renderComponent } from "./component.helper" import "alchemy_admin/components/tinymce" -import "../../../../vendor/assets/javascripts/tinymce/tinymce.min" +import "vendor/tinymce/tinymce.min" import { mockIntersectionObserver } from "jsdom-testing-mocks" describe("alchemy-tinymce", () => { diff --git a/spec/javascript/alchemy_admin/utils/string_conversions.spec.js b/spec/javascript/alchemy_admin/utils/string_conversions.spec.js new file mode 100644 index 0000000000..1fc21a34c1 --- /dev/null +++ b/spec/javascript/alchemy_admin/utils/string_conversions.spec.js @@ -0,0 +1,11 @@ +import { toCamelCase } from "alchemy_admin/utils/string_conversions" + +describe("toCamelCase", () => { + it("convert dashes into camelCase", () => { + expect(toCamelCase("foo-bar-bazzz")).toEqual("fooBarBazzz") + }) + + it("convert underscore into camelCase", () => { + expect(toCamelCase("foo_bar")).toEqual("fooBar") + }) +}) diff --git a/spec/views/alchemy/ingredients/page_editor_spec.rb b/spec/views/alchemy/ingredients/page_editor_spec.rb index ce7469a9fe..9ad911ac96 100644 --- a/spec/views/alchemy/ingredients/page_editor_spec.rb +++ b/spec/views/alchemy/ingredients/page_editor_spec.rb @@ -20,15 +20,15 @@ it_behaves_like "an alchemy ingredient editor" it "renders a page input" do - is_expected.to have_css("input.alchemy_selectbox.full_width") + is_expected.to have_css("alchemy-page-select input") end context "with a page related to ingredient" do - let(:page) { Alchemy::Page.new(id: 1) } + let(:page) { build(:alchemy_page) } let(:ingredient) { Alchemy::Ingredients::Page.new(page: page, element: element, role: "role") } it "sets page id as value" do - is_expected.to have_css('input.alchemy_selectbox[value="1"]') + is_expected.to have_css("input[value=\"#{page.id}\"]") end end end