Skip to content

Commit

Permalink
Merge pull request #2582 from sascha-karnatz/update_javascript/page_s…
Browse files Browse the repository at this point in the history
…elect

Page Select Component
tvdeyen authored Sep 27, 2023
2 parents b432945 + 2435410 commit b85bd1b
Showing 18 changed files with 436 additions and 145 deletions.
1 change: 0 additions & 1 deletion app/assets/javascripts/alchemy/admin.js
Original file line number Diff line number Diff line change
@@ -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
46 changes: 0 additions & 46 deletions app/assets/javascripts/alchemy/page_select.js

This file was deleted.

42 changes: 42 additions & 0 deletions app/components/alchemy/admin/page_select.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/javascript/alchemy_admin.js
Original file line number Diff line number Diff line change
@@ -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"
42 changes: 21 additions & 21 deletions app/javascript/alchemy_admin/components/alchemy_html_element.js
Original file line number Diff line number Diff line change
@@ -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 <WebComponentName>({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
}
}
120 changes: 120 additions & 0 deletions app/javascript/alchemy_admin/components/page_select.js
Original file line number Diff line number Diff line change
@@ -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 `
<div class="page-select--page">
<div class="page-select--top">
<i class="icon far fa-file fa-lg"></i>
<span class="page-select--page-name">${page.name}</span>
<span class="page-select--page-urlname">${page.url_path}</span>
</div>
<div class="page-select--bottom">
<span class="page-select--site-name">${page.site.name}</span>
<span class="page-select--language-code">${page.language.name}</span>
</div>
</div>
`
}
}

customElements.define("alchemy-page-select", PageSelect)
10 changes: 10 additions & 0 deletions app/javascript/alchemy_admin/utils/string_conversions.js
Original file line number Diff line number Diff line change
@@ -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))
}
39 changes: 19 additions & 20 deletions app/views/alchemy/admin/nodes/_form.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>

<script>
$('#node_page_id').alchemyPageSelect({
placeholder: "<%= Alchemy.t(:search_page) %>",
url: "<%= alchemy.api_pages_path %>",
<% if node.page %>
initialSelection: {
id: <%= node.page_id %>,
text: "<%= node.page.name %>",
url_path: "<%= node.page.url_path %>"
}
<% end %>
}).on('change', function(e) {
if (e.val === '') {
$('#node_name').removeAttr('placeholder')
$('#node_url').val('').prop('disabled', false)
} else {
$('#node_name').attr('placeholder', e.added.name)
$('#node_url').val(e.added.url_path).prop('disabled', true)
}
const nodeName = document.getElementById("node_name")
const nodeUrl = document.getElementById("node_url")
const form = document.getElementById("node_form")

form.addEventListener("Alchemy.PageSelect.PageAdded", (event) => {
const page = event.detail
nodeName.setAttribute("placeholder", page.name)
nodeUrl.value = page.url_path
nodeUrl.setAttribute("disabled", "disabled")
})

form.addEventListener("Alchemy.PageSelect.PageRemoved", (event) => {
nodeName.removeAttribute("placeholder")
nodeUrl.value = ""
nodeUrl.removeAttribute("disabled")
})
</script>
19 changes: 3 additions & 16 deletions app/views/alchemy/admin/pages/_form.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>

<div class="input check_boxes">
@@ -52,18 +54,3 @@

<%= f.submit Alchemy.t(:save) %>
<% end %>

<script>
$('#page_parent_id').alchemyPageSelect({
placeholder: "<%= Alchemy.t(:search_page) %>",
url: "<%= alchemy.api_pages_path %>",
allowClear: false,
<% if @page.parent %>
initialSelection: {
id: <%= @page.parent.id %>,
text: "<%= @page.parent.name %>",
url_path: "<%= @page.parent.url_path %>"
}
<% end %>
})
</script>
19 changes: 3 additions & 16 deletions app/views/alchemy/admin/pages/_new_page_form.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>

<script>
$('input[type="text"]#page_parent_id').alchemyPageSelect({
placeholder: "<%= Alchemy.t(:search_page) %>",
url: "<%= alchemy.api_pages_path %>",
allowClear: false,
<% if @page.parent %>
initialSelection: {
id: <%= @page.parent.id %>,
text: "<%= @page.parent.name %>",
url_path: "<%= @page.parent.url_path %>"
}
<% end %>
})
</script>
26 changes: 7 additions & 19 deletions app/views/alchemy/ingredients/_page_editor.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>

<script>
$('#<%= page_editor.form_field_id(:page_id) %>').alchemyPageSelect({
placeholder: "<%= Alchemy.t(:search_page) %>",
url: "<%= alchemy.api_pages_path %>",
query_params: <%== page_editor.settings[:query_params].to_json %>,
<% if page_editor.page %>
initialSelection: {
id: <%= page_editor.page.id %>,
text: "<%= page_editor.page.name %>"
}
<%= render Alchemy::Admin::PageSelect.new(page_editor.page, allow_clear: true, query_params: page_editor.settings[:query_params]) do %>
<%= f.text_field :page_id,
value: page_editor.page&.id,
id: page_editor.form_field_id(:page_id),
class: 'full_width' %>
<% end %>
<% end %>
})
</script>
<% end %>
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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/(.*)": "<rootDir>/app/javascript/alchemy_admin/$1"
"alchemy_admin/(.*)": "<rootDir>/app/javascript/alchemy_admin/$1",
"vendor/(.*)": "<rootDir>/vendor/assets/javascripts/$1"
},
"testEnvironment": "jsdom",
"roots": [
85 changes: 85 additions & 0 deletions spec/components/alchemy/admin/page_select_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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",
`<test-camelcase long-long-attribute="bar"></test-camelcase>`
)
expect(component.longLongAttribute).toEqual("bar")
})

it("should support boolean types", () => {
createComponent("test-boolean")
component = renderComponent(
"test-boolean",
`<test-boolean boolean-type></test-boolean>`
)
expect(component.booleanType).toBeTruthy()

const second_component = renderComponent(
"test-boolean",
`<test-boolean></test-boolean>`
)
expect(second_component.booleanType).toBeFalsy()
})

it("should observe an attribute change", () => {
createComponent("test-color")
expect(component.color).toEqual("currentColor")
80 changes: 80 additions & 0 deletions spec/javascript/alchemy_admin/components/page_select.spec.js
Original file line number Diff line number Diff line change
@@ -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 = `
<alchemy-page-select>
<input type="text">
</alchemy-page-select>
`
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 = `
<alchemy-page-select allow-clear>
<input type="text">
</alchemy-page-select>
`
component = renderComponent("alchemy-page-select", html)
})

it("should show a remove 'button'", () => {
expect(component.allowClear).toBeTruthy()
})
})

describe("query params", () => {
beforeEach(() => {
const html = `
<alchemy-page-select query-params="{&quot;foo&quot;:&quot;bar&quot;}">
<input type="text">
</alchemy-page-select>
`
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")
})
})
})
2 changes: 1 addition & 1 deletion spec/javascript/alchemy_admin/components/tinymce.spec.js
Original file line number Diff line number Diff line change
@@ -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", () => {
11 changes: 11 additions & 0 deletions spec/javascript/alchemy_admin/utils/string_conversions.spec.js
Original file line number Diff line number Diff line change
@@ -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")
})
})
6 changes: 3 additions & 3 deletions spec/views/alchemy/ingredients/page_editor_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit b85bd1b

Please sign in to comment.