shoelace-rails 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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>
|