shoelace-rails 0.1.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 +7 -0
- data/.github/workflows/main.yml +85 -0
- data/.gitignore +20 -0
- data/Appraisals +25 -0
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +178 -0
- data/Rakefile +27 -0
- data/app/helpers/shoelace/form_helper.rb +451 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/dist/.keep +0 -0
- data/dist/types/.keep +0 -0
- data/gemfiles/rails_50.gemfile +12 -0
- data/gemfiles/rails_51.gemfile +11 -0
- data/gemfiles/rails_52.gemfile +11 -0
- data/gemfiles/rails_60.gemfile +11 -0
- data/gemfiles/rails_61.gemfile +11 -0
- data/gemfiles/rails_70.gemfile +11 -0
- data/gemfiles/rails_edge.gemfile +14 -0
- data/lib/shoelace/engine.rb +8 -0
- data/lib/shoelace/rails/version.rb +7 -0
- data/lib/shoelace/rails.rb +10 -0
- data/lib/shoelace/testing.rb +40 -0
- data/package.json +50 -0
- data/rollup.config.js +49 -0
- data/shoelace-rails.gemspec +35 -0
- data/src/index.ts +2 -0
- data/src/turbo/index.ts +6 -0
- data/src/turbo/polyfills/formdata-event.js +27 -0
- data/src/turbo/sl-turbo-form.ts +110 -0
- data/src/turbolinks/features/confirm.ts +42 -0
- data/src/turbolinks/features/disable.ts +94 -0
- data/src/turbolinks/features/remote.ts +107 -0
- data/src/turbolinks/index.ts +6 -0
- data/src/turbolinks/selectors.ts +38 -0
- data/src/turbolinks/start.ts +38 -0
- data/src/turbolinks/turbolinks.ts +78 -0
- data/src/turbolinks/utils/ajax.ts +146 -0
- data/src/turbolinks/utils/csp.ts +20 -0
- data/src/turbolinks/utils/csrf.ts +33 -0
- data/src/turbolinks/utils/dom.ts +40 -0
- data/src/turbolinks/utils/event.ts +57 -0
- data/src/turbolinks/utils/form.ts +58 -0
- data/test/dummy_app/Gemfile +19 -0
- data/test/dummy_app/Rakefile +6 -0
- data/test/dummy_app/app/controllers/hotwire_forms_controller.rb +46 -0
- data/test/dummy_app/app/controllers/turbolinks_forms_controller.rb +37 -0
- data/test/dummy_app/app/models/user.rb +16 -0
- data/test/dummy_app/app/packs/entrypoints/hotwire.js +1 -0
- data/test/dummy_app/app/packs/entrypoints/turbolinks.js +5 -0
- data/test/dummy_app/app/views/hotwire_forms/form.html.erb +45 -0
- data/test/dummy_app/app/views/hotwire_forms/show.html.erb +5 -0
- data/test/dummy_app/app/views/layouts/application.html.erb +39 -0
- data/test/dummy_app/app/views/turbolinks_forms/form.html.erb +44 -0
- data/test/dummy_app/app/views/turbolinks_forms/show.html.erb +5 -0
- data/test/dummy_app/bin/rails +5 -0
- data/test/dummy_app/bin/webpack +18 -0
- data/test/dummy_app/bin/yarn +18 -0
- data/test/dummy_app/config/application.rb +16 -0
- data/test/dummy_app/config/boot.rb +4 -0
- data/test/dummy_app/config/environment.rb +2 -0
- data/test/dummy_app/config/environments/development.rb +10 -0
- data/test/dummy_app/config/environments/test.rb +18 -0
- data/test/dummy_app/config/routes.rb +4 -0
- data/test/dummy_app/config/webpack/development.js +5 -0
- data/test/dummy_app/config/webpack/production.js +1 -0
- data/test/dummy_app/config/webpack/test.js +5 -0
- data/test/dummy_app/config/webpacker.yml +33 -0
- data/test/dummy_app/config.ru +6 -0
- data/test/dummy_app/package.json +24 -0
- data/test/dummy_app/test/system/hotwire_form_test.rb +65 -0
- data/test/dummy_app/test/system/turbolinks_form_test.rb +39 -0
- data/test/dummy_app/test/test_helper.rb +68 -0
- data/test/helpers/form_helper_test.rb +397 -0
- data/test/test_helper.rb +18 -0
- data/tsconfig.json +19 -0
- data/yarn.lock +249 -0
- metadata +196 -0
@@ -0,0 +1,78 @@
|
|
1
|
+
import { Snapshot, SnapshotRenderer, ErrorRenderer, uuid } from "turbolinks"
|
2
|
+
import { formSubmitSelector } from "./selectors"
|
3
|
+
import { matches } from "./utils/dom"
|
4
|
+
import { delegate } from "./utils/event"
|
5
|
+
|
6
|
+
const nullCallback = function () {}
|
7
|
+
const nullDelegate = {
|
8
|
+
viewInvalidated: nullCallback,
|
9
|
+
viewWillRender: nullCallback,
|
10
|
+
viewRendered: nullCallback,
|
11
|
+
}
|
12
|
+
|
13
|
+
const renderWithTurbolinks = (htmlContent) => {
|
14
|
+
const currentSnapshot = Snapshot.fromHTMLElement(document.documentElement)
|
15
|
+
const newSnapshot = Snapshot.fromHTMLString(htmlContent)
|
16
|
+
let renderer = new SnapshotRenderer(currentSnapshot, newSnapshot, false)
|
17
|
+
|
18
|
+
if (!renderer.shouldRender()) {
|
19
|
+
renderer = new ErrorRenderer(htmlContent)
|
20
|
+
}
|
21
|
+
|
22
|
+
renderer.delegate = nullDelegate
|
23
|
+
renderer.render(nullCallback)
|
24
|
+
}
|
25
|
+
|
26
|
+
const findActiveElement = (shadowRoot: ShadowRoot) => {
|
27
|
+
let el = shadowRoot.activeElement
|
28
|
+
|
29
|
+
while (el && el.shadowRoot && el.shadowRoot.activeElement) {
|
30
|
+
el = el.shadowRoot.activeElement
|
31
|
+
}
|
32
|
+
|
33
|
+
return el
|
34
|
+
}
|
35
|
+
|
36
|
+
export const addShadowDomSupportToTurbolinks = (turbolinksController) => {
|
37
|
+
// From https://github.com/turbolinks/turbolinks/blob/71b7a7d0546a573735af99113b622180e8a813c2/src/util.ts#L9
|
38
|
+
// © 2019 Basecamp, LLC.
|
39
|
+
const originalClosest: typeof turbolinksController.closest = turbolinksController.closest
|
40
|
+
|
41
|
+
turbolinksController.closest = (node, selector) => {
|
42
|
+
if (!!node.shadowRoot) {
|
43
|
+
const rootActiveElement = findActiveElement(node.shadowRoot) || node
|
44
|
+
|
45
|
+
if (matches(rootActiveElement, selector)) {
|
46
|
+
return rootActiveElement
|
47
|
+
}
|
48
|
+
} else {
|
49
|
+
return originalClosest(node, selector)
|
50
|
+
}
|
51
|
+
}
|
52
|
+
}
|
53
|
+
|
54
|
+
// Turbolinks does not automatically handle form responses. This creates a handler that does it.
|
55
|
+
// From https://github.com/turbolinks/turbolinks/issues/85#issuecomment-299617076
|
56
|
+
export const handleResponse = (turbolinksInstance) => {
|
57
|
+
return (event: CustomEvent<[XMLHttpRequest, string]>) => {
|
58
|
+
const [xhr, _status] = event.detail
|
59
|
+
|
60
|
+
if (xhr.getResponseHeader("Content-Type").startsWith("text/html")) {
|
61
|
+
turbolinksInstance.restorationIdentifier = uuid()
|
62
|
+
turbolinksInstance.clearCache()
|
63
|
+
turbolinksInstance.dispatch("turbolinks:before-cache")
|
64
|
+
turbolinksInstance.controller.pushHistoryWithLocationAndRestorationIdentifier(
|
65
|
+
xhr.responseURL,
|
66
|
+
turbolinksInstance.restorationIdentifier
|
67
|
+
)
|
68
|
+
renderWithTurbolinks(xhr.responseText)
|
69
|
+
window.scroll(0, 0)
|
70
|
+
turbolinksInstance.dispatch("turbolinks:load")
|
71
|
+
}
|
72
|
+
}
|
73
|
+
}
|
74
|
+
|
75
|
+
export const startTurbolinks = (turbolinksInstance) => {
|
76
|
+
delegate(document, formSubmitSelector, "ajax:complete", handleResponse(turbolinksInstance))
|
77
|
+
addShadowDomSupportToTurbolinks(turbolinksInstance)
|
78
|
+
}
|
@@ -0,0 +1,146 @@
|
|
1
|
+
// This code was heavily inspired by the rails-ujs project.
|
2
|
+
// Copyright (c) 2007-2021 Rails Core team.
|
3
|
+
import { cspNonce } from "./csp"
|
4
|
+
import { CSRFProtection } from "./csrf"
|
5
|
+
|
6
|
+
const AcceptHeaders = {
|
7
|
+
"*": "*/*",
|
8
|
+
text: "text/plain",
|
9
|
+
html: "text/html",
|
10
|
+
xml: "application/xml, text/xml",
|
11
|
+
json: "application/json, text/javascript",
|
12
|
+
script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript",
|
13
|
+
}
|
14
|
+
|
15
|
+
export const ajax = (options) => {
|
16
|
+
options = prepareOptions(options)
|
17
|
+
var xhr = createXHR(options, function () {
|
18
|
+
const response = processResponse(
|
19
|
+
xhr.response != null ? xhr.response : xhr.responseText,
|
20
|
+
xhr.getResponseHeader("Content-Type")
|
21
|
+
)
|
22
|
+
|
23
|
+
if (Math.floor(xhr.status / 100) === 2) {
|
24
|
+
if (typeof options.success === "function") {
|
25
|
+
options.success(response, xhr.statusText, xhr)
|
26
|
+
}
|
27
|
+
} else {
|
28
|
+
if (typeof options.error === "function") {
|
29
|
+
options.error(response, xhr.statusText, xhr)
|
30
|
+
}
|
31
|
+
}
|
32
|
+
return typeof options.complete === "function" ? options.complete(xhr, xhr.statusText) : undefined
|
33
|
+
})
|
34
|
+
|
35
|
+
if (options.beforeSend != null && !options.beforeSend(xhr, options)) {
|
36
|
+
return false
|
37
|
+
}
|
38
|
+
|
39
|
+
if (xhr.readyState === XMLHttpRequest.OPENED) {
|
40
|
+
return xhr.send(options.data)
|
41
|
+
}
|
42
|
+
}
|
43
|
+
|
44
|
+
const prepareOptions = (options) => {
|
45
|
+
options.url = options.url || location.href
|
46
|
+
options.type = options.type.toUpperCase()
|
47
|
+
// append data to url if it's a GET request
|
48
|
+
if (options.type === "GET" && options.data) {
|
49
|
+
if (options.url.indexOf("?") < 0) {
|
50
|
+
options.url += "?" + options.data
|
51
|
+
} else {
|
52
|
+
options.url += "&" + options.data
|
53
|
+
}
|
54
|
+
}
|
55
|
+
|
56
|
+
// Use "*" as default dataType
|
57
|
+
if (AcceptHeaders[options.dataType] == null) {
|
58
|
+
options.dataType = "*"
|
59
|
+
}
|
60
|
+
|
61
|
+
options.accept = AcceptHeaders[options.dataType]
|
62
|
+
if (options.dataType !== "*") {
|
63
|
+
options.accept += ", */*; q=0.01"
|
64
|
+
}
|
65
|
+
|
66
|
+
return options
|
67
|
+
}
|
68
|
+
|
69
|
+
const createXHR = (options, done) => {
|
70
|
+
const xhr = new XMLHttpRequest()
|
71
|
+
|
72
|
+
// Open and set up xhr
|
73
|
+
xhr.open(options.type, options.url, true)
|
74
|
+
xhr.setRequestHeader("Accept", options.accept)
|
75
|
+
|
76
|
+
// Set Content-Type only when sending a string
|
77
|
+
// Sending FormData will automatically set Content-Type to multipart/form-data
|
78
|
+
if (typeof options.data === "string") {
|
79
|
+
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
80
|
+
}
|
81
|
+
|
82
|
+
if (!options.crossDomain) {
|
83
|
+
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
|
84
|
+
CSRFProtection(xhr)
|
85
|
+
}
|
86
|
+
|
87
|
+
xhr.withCredentials = !!options.withCredentials
|
88
|
+
xhr.onreadystatechange = function () {
|
89
|
+
if (xhr.readyState === XMLHttpRequest.DONE) {
|
90
|
+
return done(xhr)
|
91
|
+
}
|
92
|
+
}
|
93
|
+
return xhr
|
94
|
+
}
|
95
|
+
|
96
|
+
const processResponse = (response, type) => {
|
97
|
+
if (typeof response === "string" && typeof type === "string") {
|
98
|
+
if (type.match(/\bjson\b/)) {
|
99
|
+
try {
|
100
|
+
response = JSON.parse(response)
|
101
|
+
} catch (error) {
|
102
|
+
// no-op...
|
103
|
+
}
|
104
|
+
} else if (type.match(/\b(?:java|ecma)script\b/)) {
|
105
|
+
const script = document.createElement("script")
|
106
|
+
script.setAttribute("nonce", cspNonce())
|
107
|
+
script.text = response
|
108
|
+
document.head.appendChild(script).parentNode.removeChild(script)
|
109
|
+
} else if (type.match(/\b(xml|html|svg)\b/)) {
|
110
|
+
const parser = new DOMParser()
|
111
|
+
type = type.replace(/;.+/, "") // remove something like ';charset=utf-8'
|
112
|
+
|
113
|
+
try {
|
114
|
+
response = parser.parseFromString(response, type)
|
115
|
+
} catch (error1) {}
|
116
|
+
}
|
117
|
+
}
|
118
|
+
|
119
|
+
return response
|
120
|
+
}
|
121
|
+
|
122
|
+
// Default way to get an element's href. May be overridden at Rails.href.
|
123
|
+
export const href = (element) => element.href
|
124
|
+
|
125
|
+
// Determines if the request is a cross domain request.
|
126
|
+
export const isCrossDomain = (url) => {
|
127
|
+
const originAnchor = document.createElement("a")
|
128
|
+
originAnchor.href = location.href
|
129
|
+
const urlAnchor = document.createElement("a")
|
130
|
+
|
131
|
+
try {
|
132
|
+
urlAnchor.href = url
|
133
|
+
// If URL protocol is false or is a string containing a single colon
|
134
|
+
// *and* host are false, assume it is not a cross-domain request
|
135
|
+
// (should only be the case for IE7 and IE compatibility mode).
|
136
|
+
// Otherwise, evaluate protocol and host of the URL against the origin
|
137
|
+
// protocol and host.
|
138
|
+
return !(
|
139
|
+
((!urlAnchor.protocol || urlAnchor.protocol === ":") && !urlAnchor.host) ||
|
140
|
+
originAnchor.protocol + "//" + originAnchor.host === urlAnchor.protocol + "//" + urlAnchor.host
|
141
|
+
)
|
142
|
+
} catch (e) {
|
143
|
+
// If there is an error parsing the URL, assume it is crossDomain.
|
144
|
+
return true
|
145
|
+
}
|
146
|
+
}
|
@@ -0,0 +1,20 @@
|
|
1
|
+
// This code was heavily inspired by the rails-ujs project.
|
2
|
+
// Copyright (c) 2007-2021 Rails Core team.
|
3
|
+
let nonce = null
|
4
|
+
|
5
|
+
const loadCSPNonce = () => {
|
6
|
+
if (nonce) {
|
7
|
+
return nonce
|
8
|
+
}
|
9
|
+
|
10
|
+
const cspMetaTag: HTMLMetaElement = document.querySelector("meta[name=csp-nonce]")
|
11
|
+
|
12
|
+
if (cspMetaTag) {
|
13
|
+
nonce = cspMetaTag.content
|
14
|
+
}
|
15
|
+
|
16
|
+
return nonce
|
17
|
+
}
|
18
|
+
|
19
|
+
// Returns the Content-Security-Policy nonce for inline scripts.
|
20
|
+
export const cspNonce = () => (nonce != null ? nonce : loadCSPNonce())
|
@@ -0,0 +1,33 @@
|
|
1
|
+
// This code was heavily inspired by the rails-ujs project.
|
2
|
+
// Copyright (c) 2007-2021 Rails Core team.
|
3
|
+
const $ = (selector) => Array.prototype.slice.call(document.querySelectorAll(selector))
|
4
|
+
|
5
|
+
// Up-to-date Cross-Site Request Forgery token
|
6
|
+
export const csrfToken = () => {
|
7
|
+
const meta: HTMLMetaElement = document.querySelector("meta[name=csrf-token]")
|
8
|
+
return meta && meta.content
|
9
|
+
}
|
10
|
+
|
11
|
+
// URL param that must contain the CSRF token
|
12
|
+
export const csrfParam = () => {
|
13
|
+
const meta: HTMLMetaElement = document.querySelector("meta[name=csrf-param]")
|
14
|
+
return meta && meta.content
|
15
|
+
}
|
16
|
+
|
17
|
+
// Make sure that every Ajax request sends the CSRF token
|
18
|
+
export const CSRFProtection = (xhr) => {
|
19
|
+
const token = csrfToken()
|
20
|
+
if (token != null) {
|
21
|
+
return xhr.setRequestHeader("X-CSRF-Token", token)
|
22
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
// Make sure that all forms have actual up-to-date tokens (cached forms contain old ones)
|
26
|
+
export const refreshCSRFTokens = () => {
|
27
|
+
const token = csrfToken()
|
28
|
+
const param = csrfParam()
|
29
|
+
|
30
|
+
if (token != null && param != null) {
|
31
|
+
return $('sl-form input[name="' + param + '"]').forEach((input) => (input.value = token))
|
32
|
+
}
|
33
|
+
}
|
@@ -0,0 +1,40 @@
|
|
1
|
+
// This code was heavily inspired by the rails-ujs project.
|
2
|
+
// Copyright (c) 2007-2021 Rails Core team.
|
3
|
+
const elementPrototype = Element.prototype as any
|
4
|
+
|
5
|
+
const m: (this: Element, selector: string) => boolean =
|
6
|
+
elementPrototype.matches ||
|
7
|
+
elementPrototype.matchesSelector ||
|
8
|
+
elementPrototype.mozMatchesSelector ||
|
9
|
+
elementPrototype.msMatchesSelector ||
|
10
|
+
elementPrototype.oMatchesSelector ||
|
11
|
+
elementPrototype.webkitMatchesSelector
|
12
|
+
|
13
|
+
// Checks if the given native dom element matches the selector
|
14
|
+
// element::
|
15
|
+
// native DOM element
|
16
|
+
// selector::
|
17
|
+
// CSS selector string or
|
18
|
+
// a JavaScript object with `selector` and `exclude` properties
|
19
|
+
// Examples: "form", { selector: "form", exclude: "form[data-remote='true']"}
|
20
|
+
export const matches = (element, selector) => {
|
21
|
+
if (selector.exclude != null) {
|
22
|
+
return m.call(element, selector.selector) && !m.call(element, selector.exclude)
|
23
|
+
} else {
|
24
|
+
return m.call(element, selector)
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
// get and set data on a given element using "expando properties"
|
29
|
+
// See: https://developer.mozilla.org/en-US/docs/Glossary/Expando
|
30
|
+
const expando = "_ujsData"
|
31
|
+
|
32
|
+
export const getData = (element, key) => (element[expando] != null ? element[expando][key] : undefined)
|
33
|
+
|
34
|
+
export const setData = (element, key, value) => {
|
35
|
+
if (element[expando] == null) {
|
36
|
+
element[expando] = {}
|
37
|
+
}
|
38
|
+
|
39
|
+
return (element[expando][key] = value)
|
40
|
+
}
|
@@ -0,0 +1,57 @@
|
|
1
|
+
// This code was heavily inspired by the rails-ujs project.
|
2
|
+
// Copyright (c) 2007-2021 Rails Core team.
|
3
|
+
import { matches } from "./dom"
|
4
|
+
|
5
|
+
// Triggers a custom event on an element and returns false if the event result is false
|
6
|
+
// obj::
|
7
|
+
// a native DOM element
|
8
|
+
// name::
|
9
|
+
// string that corresponds to the event you want to trigger
|
10
|
+
// e.g. 'click', 'submit'
|
11
|
+
// data::
|
12
|
+
// data you want to pass when you dispatch an event
|
13
|
+
export const fire = (obj, name, data = {}) => {
|
14
|
+
const event = new CustomEvent(name, {
|
15
|
+
bubbles: true,
|
16
|
+
cancelable: true,
|
17
|
+
detail: data,
|
18
|
+
})
|
19
|
+
|
20
|
+
obj.dispatchEvent(event)
|
21
|
+
return !event.defaultPrevented
|
22
|
+
}
|
23
|
+
|
24
|
+
// Helper function, needed to provide consistent behavior in IE
|
25
|
+
export const stopEverything = (event) => {
|
26
|
+
fire(event.target, "ujs:everythingStopped")
|
27
|
+
|
28
|
+
event.preventDefault()
|
29
|
+
event.stopPropagation()
|
30
|
+
|
31
|
+
return event.stopImmediatePropagation()
|
32
|
+
}
|
33
|
+
|
34
|
+
// Delegates events
|
35
|
+
// to a specified parent `element`, which fires event `handler`
|
36
|
+
// for the specified `selector` when an event of `eventType` is triggered
|
37
|
+
// element::
|
38
|
+
// parent element that will listen for events e.g. document
|
39
|
+
// selector::
|
40
|
+
// CSS selector; or an object that has `selector` and `exclude` properties (see: Rails.matches)
|
41
|
+
// eventType::
|
42
|
+
// string representing the event e.g. 'submit', 'click'
|
43
|
+
// handler::
|
44
|
+
// the event handler to be called
|
45
|
+
export const delegate = (element, selector, eventType, handler) =>
|
46
|
+
element.addEventListener(eventType, function (event) {
|
47
|
+
let { target } = event
|
48
|
+
|
49
|
+
while (!!(target instanceof Element) && !matches(target, selector)) {
|
50
|
+
target = target.parentNode
|
51
|
+
}
|
52
|
+
|
53
|
+
if (target instanceof Element && handler.call(target, event) === false) {
|
54
|
+
event.preventDefault()
|
55
|
+
return event.stopPropagation()
|
56
|
+
}
|
57
|
+
})
|
@@ -0,0 +1,58 @@
|
|
1
|
+
// This code was heavily inspired by the rails-ujs project.
|
2
|
+
// Copyright (c) 2007-2021 Rails Core team.
|
3
|
+
import { matches } from "./dom"
|
4
|
+
|
5
|
+
const toArray = (e) => Array.prototype.slice.call(e)
|
6
|
+
|
7
|
+
export const serializeElement = (element, additionalParam) => {
|
8
|
+
let inputs = [element]
|
9
|
+
if (matches(element, "form")) {
|
10
|
+
inputs = toArray(element.elements)
|
11
|
+
}
|
12
|
+
const params = []
|
13
|
+
|
14
|
+
inputs.forEach(function (input) {
|
15
|
+
if (!input.name || input.disabled) {
|
16
|
+
return
|
17
|
+
}
|
18
|
+
|
19
|
+
if (matches(input, "fieldset[disabled] *")) {
|
20
|
+
return
|
21
|
+
}
|
22
|
+
|
23
|
+
if (matches(input, "select")) {
|
24
|
+
return toArray(input.options).forEach(function (option) {
|
25
|
+
if (option.selected) {
|
26
|
+
return params.push({ name: input.name, value: option.value })
|
27
|
+
}
|
28
|
+
})
|
29
|
+
} else if (input.checked || ["radio", "checkbox", "submit"].indexOf(input.type) === -1) {
|
30
|
+
return params.push({ name: input.name, value: input.value })
|
31
|
+
}
|
32
|
+
})
|
33
|
+
|
34
|
+
if (additionalParam) {
|
35
|
+
params.push(additionalParam)
|
36
|
+
}
|
37
|
+
|
38
|
+
return params
|
39
|
+
.map(function (param) {
|
40
|
+
if (param.name != null) {
|
41
|
+
return `${encodeURIComponent(param.name)}=${encodeURIComponent(param.value)}`
|
42
|
+
} else {
|
43
|
+
return param
|
44
|
+
}
|
45
|
+
})
|
46
|
+
.join("&")
|
47
|
+
}
|
48
|
+
|
49
|
+
// Helper function that returns form elements that match the specified CSS selector
|
50
|
+
// If form is actually a "form" element this will return associated elements outside the from that have
|
51
|
+
// the html form attribute set
|
52
|
+
export const formElements = (form, selector) => {
|
53
|
+
if (matches(form, "form")) {
|
54
|
+
return toArray(form.elements).filter((el) => matches(el, selector))
|
55
|
+
} else {
|
56
|
+
return toArray(form.querySelectorAll(selector))
|
57
|
+
}
|
58
|
+
}
|
@@ -0,0 +1,19 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
gem 'rails', '~> 7.0.0'
|
4
|
+
gem 'webrick'
|
5
|
+
gem 'bootsnap', '>= 1.4.4', require: false
|
6
|
+
gem 'rexml'
|
7
|
+
gem 'shoelace-rails', path: '../../../shoelace-rails'
|
8
|
+
gem 'webpacker', '6.0.0.rc.5'
|
9
|
+
|
10
|
+
group :development, :test do
|
11
|
+
gem 'pry-byebug'
|
12
|
+
end
|
13
|
+
|
14
|
+
group :test do
|
15
|
+
gem 'capybara', '>= 3.26'
|
16
|
+
gem 'capybara-shadowdom'
|
17
|
+
gem 'selenium-webdriver', '>= 4.0.0.rc3'
|
18
|
+
gem 'webdrivers'
|
19
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
class HotwireFormsController < ActionController::Base
|
2
|
+
layout "application"
|
3
|
+
|
4
|
+
def show
|
5
|
+
@user = User.new(session[:user])
|
6
|
+
end
|
7
|
+
|
8
|
+
# GET /users/new
|
9
|
+
def new
|
10
|
+
@user = User.new(params[:user]&.permit!)
|
11
|
+
|
12
|
+
render 'form'
|
13
|
+
end
|
14
|
+
|
15
|
+
# GET /users/1/edit
|
16
|
+
def edit
|
17
|
+
@user = User.new(name: "Yuki Nishijima")
|
18
|
+
|
19
|
+
render 'form'
|
20
|
+
end
|
21
|
+
|
22
|
+
# POST /users
|
23
|
+
def create
|
24
|
+
@user = User.new(user_params)
|
25
|
+
|
26
|
+
if @user.valid?
|
27
|
+
session[:user] = user_params.to_h
|
28
|
+
redirect_to hotwire_form_path(1)
|
29
|
+
else
|
30
|
+
render 'form', status: 422
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# PATCH/PUT /users/1
|
35
|
+
def update
|
36
|
+
@user = User.new(user_params)
|
37
|
+
|
38
|
+
render(@user.valid? ? 'show' : 'form')
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def user_params
|
44
|
+
params.require(:user).permit!
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class TurbolinksFormsController < ActionController::Base
|
2
|
+
layout "application"
|
3
|
+
|
4
|
+
# GET /users/new
|
5
|
+
def new
|
6
|
+
@user = User.new
|
7
|
+
|
8
|
+
render 'form'
|
9
|
+
end
|
10
|
+
|
11
|
+
# GET /users/1/edit
|
12
|
+
def edit
|
13
|
+
@user = User.new(name: "Yuki Nishijima")
|
14
|
+
|
15
|
+
render 'form'
|
16
|
+
end
|
17
|
+
|
18
|
+
# POST /users
|
19
|
+
def create
|
20
|
+
@user = User.new(user_params)
|
21
|
+
|
22
|
+
render(@user.valid? ? 'show' : 'form')
|
23
|
+
end
|
24
|
+
|
25
|
+
# PATCH/PUT /users/1
|
26
|
+
def update
|
27
|
+
@user = User.new(user_params)
|
28
|
+
|
29
|
+
render(@user.valid? ? 'show' : 'form')
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def user_params
|
35
|
+
params.require(:user).permit!
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
class User
|
2
|
+
include ActiveModel::Model
|
3
|
+
include ActiveModel::Attributes
|
4
|
+
|
5
|
+
attribute :name
|
6
|
+
attribute :description
|
7
|
+
attribute :color
|
8
|
+
attribute :score
|
9
|
+
attribute :current_city
|
10
|
+
attribute :previous_city
|
11
|
+
attribute :past_cities
|
12
|
+
attribute :remember_me
|
13
|
+
attribute :subscribe_to_emails
|
14
|
+
|
15
|
+
validates :name, presence: true
|
16
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
import "@hotwired/turbo-rails"
|
@@ -0,0 +1,45 @@
|
|
1
|
+
<% locations = { tokyo: "Tokyo", new_york: "New York", london: "London" } %>
|
2
|
+
<%= sl_turbo_form_for(@user, url: hotwire_forms_path) do |form| %>
|
3
|
+
<div>
|
4
|
+
<%= form.text_field :name do %>
|
5
|
+
<span slot="help-text" style="color: rgb(var(--sl-color-danger-600));">
|
6
|
+
<%= @user.errors.full_messages_for(:name).first %>
|
7
|
+
</span>
|
8
|
+
<% end %>
|
9
|
+
</div>
|
10
|
+
|
11
|
+
<div>
|
12
|
+
<%= form.color_field :color %>
|
13
|
+
</div>
|
14
|
+
|
15
|
+
<div>
|
16
|
+
<%= form.range_field :score, min: 0, max: 100, step: 1 %>
|
17
|
+
</div>
|
18
|
+
|
19
|
+
<div>
|
20
|
+
<%= form.collection_radio_buttons :current_city, locations, :first, :last %>
|
21
|
+
</div>
|
22
|
+
|
23
|
+
<div>
|
24
|
+
<%= form.collection_select :previous_city, locations, :first, :last, {}, { placeholder: "Select one" } %>
|
25
|
+
</div>
|
26
|
+
|
27
|
+
<div>
|
28
|
+
<%= form.collection_select :past_cities, locations, :first, :last, {}, { placeholder: "Select two or more", multiple: true, clearable: true } %>
|
29
|
+
</div>
|
30
|
+
|
31
|
+
<div>
|
32
|
+
<%= form.check_box :remember_me %>
|
33
|
+
</div>
|
34
|
+
|
35
|
+
<div>
|
36
|
+
<%= form.switch_field :subscribe_to_emails, value: "1" %>
|
37
|
+
</div>
|
38
|
+
|
39
|
+
<div>
|
40
|
+
<%= form.text_area :description %>
|
41
|
+
</div>
|
42
|
+
|
43
|
+
<%= form.submit %>
|
44
|
+
<%= form.submit "Submit without Turbo", data: { turbo: false } %>
|
45
|
+
<% end %>
|
@@ -0,0 +1,39 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Shoelace Test</title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<%= csrf_meta_tags %>
|
7
|
+
<%= csp_meta_tag %>
|
8
|
+
|
9
|
+
<style>
|
10
|
+
body {
|
11
|
+
font-family: var(--sl-font-sans);
|
12
|
+
font-size: var(--sl-font-size-medium);
|
13
|
+
font-weight: var(--sl-font-weight-normal);
|
14
|
+
letter-spacing: var(--sl-letter-spacing-normal);
|
15
|
+
color: var(--sl-color-gray-800);
|
16
|
+
line-height: var(--sl-line-height-normal);
|
17
|
+
}
|
18
|
+
|
19
|
+
sl-form div, sl-turbo-form div {
|
20
|
+
margin-bottom: 2em;
|
21
|
+
}
|
22
|
+
</style>
|
23
|
+
|
24
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.65/dist/themes/light.css">
|
25
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.65/dist/shoelace.js"></script>
|
26
|
+
|
27
|
+
<% if request.path.include?("hotwire") %>
|
28
|
+
<%= javascript_pack_tag 'hotwire' %>
|
29
|
+
<% else %>
|
30
|
+
<%= javascript_pack_tag 'turbolinks' %>
|
31
|
+
<% end %>
|
32
|
+
</head>
|
33
|
+
|
34
|
+
<body>
|
35
|
+
<div style="width: 950px; margin: auto">
|
36
|
+
<%= yield %>
|
37
|
+
</div>
|
38
|
+
</body>
|
39
|
+
</html>
|