dynaspan 0.1.4 → 1.0.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 +5 -5
- data/CHANGELOG.md +105 -0
- data/LICENSE +1 -1
- data/README.md +245 -155
- data/app/assets/javascripts/dynaspan/dynaspan.js +271 -0
- data/app/assets/stylesheets/dynaspan/dynaspan.css +44 -0
- data/app/helpers/dynaspan/application_helper.rb +161 -48
- data/app/views/dynaspan/_dynaspan.html.erb +32 -0
- data/config/importmap.rb +3 -0
- data/lib/dynaspan/engine.rb +32 -0
- data/lib/dynaspan/version.rb +3 -1
- data/lib/dynaspan.rb +4 -28
- metadata +81 -28
- data/.gitignore +0 -19
- data/CODE_OF_CONDUCT.md +0 -3
- data/Gemfile +0 -4
- data/Rakefile +0 -13
- data/app/views/dynaspan/_dynaspan_text_area.html.erb +0 -24
- data/app/views/dynaspan/_dynaspan_text_field.html.erb +0 -24
- data/app/views/dynaspan/_dynaspan_text_select.html.erb +0 -26
- data/config/routes.rb +0 -2
- data/dynaspan.gemspec +0 -20
- data/lib/assets/javascripts/dynaspan/basepath.js.erb +0 -6
- data/lib/assets/javascripts/dynaspan-jquery.js +0 -2
- data/lib/dynaspan/tasks.rake +0 -13
- data/vendor/assets/javascripts/dynaspan/dynaspan.js +0 -56
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Dynaspan: click-to-edit, in-place AJAX text editing for Rails.
|
|
2
|
+
// https://github.com/danielpclark/dynaspan
|
|
3
|
+
// MIT License. Copyright (c) 2014-2026 Daniel P. Clark
|
|
4
|
+
//
|
|
5
|
+
// Works with Sprockets (`//= require dynaspan/dynaspan`), Propshaft
|
|
6
|
+
// (`javascript_include_tag "dynaspan/dynaspan"`) and importmap-rails
|
|
7
|
+
// (`import "dynaspan"`). No jQuery, rails-ujs or Turbo required.
|
|
8
|
+
(function (window, document) {
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
if (!window || !document || (window.Dynaspan && window.Dynaspan.version)) return;
|
|
12
|
+
|
|
13
|
+
var BLOCK_SELECTOR = '[data-dynaspan]';
|
|
14
|
+
|
|
15
|
+
function parts(uid) {
|
|
16
|
+
return {
|
|
17
|
+
block: document.getElementById('dyna_span_block' + uid),
|
|
18
|
+
container: document.getElementById('dyna_span_div' + uid),
|
|
19
|
+
field: document.getElementById('dyna_span_field_val_' + uid),
|
|
20
|
+
span: document.getElementById('dyna_span_span' + uid),
|
|
21
|
+
last: document.getElementById('last_dyna_span_val_' + uid)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function uidFor(element) {
|
|
26
|
+
var block = element && element.closest && element.closest(BLOCK_SELECTOR);
|
|
27
|
+
return block ? block.getAttribute('data-dynaspan') : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function dispatch(target, name, detail, cancelable) {
|
|
31
|
+
var event = new CustomEvent('dynaspan:' + name, { bubbles: true, cancelable: !!cancelable, detail: detail });
|
|
32
|
+
return target.dispatchEvent(event);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fieldValue(field) {
|
|
36
|
+
if (field.multiple) {
|
|
37
|
+
return Array.prototype.map.call(field.selectedOptions, function (option) { return option.value; }).join(',');
|
|
38
|
+
}
|
|
39
|
+
return field.value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fieldText(field) {
|
|
43
|
+
if (field.tagName === 'SELECT') {
|
|
44
|
+
return Array.prototype.map.call(field.selectedOptions, function (option) { return option.text; }).join(', ');
|
|
45
|
+
}
|
|
46
|
+
return field.value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isTextInput(field) {
|
|
50
|
+
return field.tagName === 'TEXTAREA' || (field.tagName === 'INPUT' && /^(text|search|url|tel|email|password)?$/i.test(field.type));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Resets the field to the last value that was saved.
|
|
54
|
+
function restore(uid) {
|
|
55
|
+
var p = parts(uid);
|
|
56
|
+
if (p.field && p.last && !p.field.multiple) p.field.value = p.last.value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function show(uid) {
|
|
60
|
+
var p = parts(uid);
|
|
61
|
+
if (!p.block || !p.container || !p.field || !p.span) return;
|
|
62
|
+
if (!p.block.classList.contains('ds-dialog-open')) {
|
|
63
|
+
restore(uid);
|
|
64
|
+
p.span.setAttribute('data-ds-display', p.span.style.display);
|
|
65
|
+
p.span.style.display = 'none';
|
|
66
|
+
p.container.style.display = '';
|
|
67
|
+
p.block.classList.add('ds-dialog-open');
|
|
68
|
+
dispatch(p.block, 'open', { selector: '#' + p.block.id, value: fieldValue(p.field) });
|
|
69
|
+
}
|
|
70
|
+
p.field.focus();
|
|
71
|
+
if (isTextInput(p.field) && p.field.setSelectionRange) {
|
|
72
|
+
var end = p.field.value.length;
|
|
73
|
+
try { p.field.setSelectionRange(end, end); } catch (e) { /* not supported for this input type */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hide(uid, options) {
|
|
78
|
+
options = options || {};
|
|
79
|
+
var p = parts(uid);
|
|
80
|
+
if (!p.block || !p.container || !p.field || !p.span) return;
|
|
81
|
+
if (!p.block.classList.contains('ds-dialog-open')) return;
|
|
82
|
+
|
|
83
|
+
if (options.cancel) restore(uid);
|
|
84
|
+
|
|
85
|
+
var value = fieldValue(p.field);
|
|
86
|
+
var text = fieldText(p.field);
|
|
87
|
+
var changed = !options.cancel && (!p.last || p.last.value !== value);
|
|
88
|
+
|
|
89
|
+
p.block.classList.remove('ds-dialog-open');
|
|
90
|
+
p.container.style.display = 'none';
|
|
91
|
+
p.span.style.display = p.span.getAttribute('data-ds-display') || '';
|
|
92
|
+
|
|
93
|
+
if (changed) {
|
|
94
|
+
p.span.textContent = text;
|
|
95
|
+
if (p.last) p.last.value = value;
|
|
96
|
+
p.block.classList.toggle('ds-content-present', text.length > 0);
|
|
97
|
+
save(p, text, value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
dispatch(p.block, 'close', { selector: '#' + p.block.id, value: value, changed: changed });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Appends a parameter to a JavaScript call written as a string:
|
|
104
|
+
// "foo('a');" => "foo('a',param);"
|
|
105
|
+
function appendParameter(command, param) {
|
|
106
|
+
var withArgs = /\(([^)]+)\);?$/.exec(command);
|
|
107
|
+
var withoutArgs = /\(\);?$/.exec(command);
|
|
108
|
+
if (withArgs) return command.replace(withArgs[0], '(' + withArgs[1] + ',' + param + ');');
|
|
109
|
+
if (withoutArgs) return command.replace(withoutArgs[0], '(' + param + ');');
|
|
110
|
+
return command.replace(/;$/, '') + '(' + param + ');';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function runCallback(block, code, values) {
|
|
114
|
+
try {
|
|
115
|
+
if (values) {
|
|
116
|
+
new Function('__dynaspan_values__', appendParameter(code, '__dynaspan_values__')).call(block, values);
|
|
117
|
+
} else {
|
|
118
|
+
new Function(code).call(block);
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (window.console) window.console.error('Dynaspan callback failed:', error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function csrfToken(form) {
|
|
126
|
+
var field = form.querySelector('input[name="authenticity_token"]');
|
|
127
|
+
if (field && field.value) return field.value;
|
|
128
|
+
var meta = document.querySelector('meta[name="csrf-token"]');
|
|
129
|
+
return meta ? meta.getAttribute('content') : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cspNonce() {
|
|
133
|
+
var meta = document.querySelector('meta[name="csp-nonce"]');
|
|
134
|
+
return meta ? meta.getAttribute('content') : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Handles `update.turbo_stream.erb` and `update.js.erb` style responses.
|
|
138
|
+
function handleResponse(response, body) {
|
|
139
|
+
var type = response.headers.get('Content-Type') || '';
|
|
140
|
+
if (/turbo-stream/.test(type) && window.Turbo && window.Turbo.renderStreamMessage) {
|
|
141
|
+
window.Turbo.renderStreamMessage(body);
|
|
142
|
+
} else if (/(java|ecma)script/.test(type) && body) {
|
|
143
|
+
var script = document.createElement('script');
|
|
144
|
+
var nonce = cspNonce();
|
|
145
|
+
if (nonce) script.setAttribute('nonce', nonce);
|
|
146
|
+
script.text = body;
|
|
147
|
+
document.head.appendChild(script).parentNode.removeChild(script);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function save(p, text, value) {
|
|
152
|
+
var block = p.block;
|
|
153
|
+
var form = p.container.querySelector('form');
|
|
154
|
+
var detail = { selector: '#' + block.id, input: text, value: value, form: form };
|
|
155
|
+
|
|
156
|
+
if (!form || !dispatch(block, 'update', detail, true)) return;
|
|
157
|
+
|
|
158
|
+
var headers = {
|
|
159
|
+
Accept: 'text/vnd.turbo-stream.html, text/javascript, application/javascript, application/json, text/html;q=0.9, */*;q=0.1',
|
|
160
|
+
'X-Requested-With': 'XMLHttpRequest'
|
|
161
|
+
};
|
|
162
|
+
var token = csrfToken(form);
|
|
163
|
+
if (token) headers['X-CSRF-Token'] = token;
|
|
164
|
+
|
|
165
|
+
block.classList.add('ds-saving');
|
|
166
|
+
block.classList.remove('ds-error');
|
|
167
|
+
|
|
168
|
+
window.fetch(form.action, { method: 'POST', body: new FormData(form), headers: headers, credentials: 'same-origin' })
|
|
169
|
+
.then(function (response) {
|
|
170
|
+
return response.text().then(function (body) {
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
var error = new Error('Dynaspan update failed with HTTP status ' + response.status);
|
|
173
|
+
error.response = response;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
handleResponse(response, body);
|
|
177
|
+
detail.response = response;
|
|
178
|
+
dispatch(block, 'success', detail);
|
|
179
|
+
});
|
|
180
|
+
})
|
|
181
|
+
.catch(function (error) {
|
|
182
|
+
block.classList.add('ds-error');
|
|
183
|
+
detail.error = error;
|
|
184
|
+
detail.response = error.response;
|
|
185
|
+
dispatch(block, 'error', detail);
|
|
186
|
+
})
|
|
187
|
+
.then(function () {
|
|
188
|
+
block.classList.remove('ds-saving');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
var withValues = block.getAttribute('data-ds-callback-with-values');
|
|
192
|
+
if (withValues) runCallback(block, withValues, { ds_selector: '#' + block.id, ds_input: text });
|
|
193
|
+
var onUpdate = block.getAttribute('data-ds-callback-on-update');
|
|
194
|
+
if (onUpdate) runCallback(block, onUpdate);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isTrigger(element) {
|
|
198
|
+
return element.closest('.dyna-span-text, .dyna-span-edit-text');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isInput(element) {
|
|
202
|
+
return element.classList && element.classList.contains('dyna-span-input');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
document.addEventListener('click', function (event) {
|
|
206
|
+
var uid = uidFor(event.target);
|
|
207
|
+
if (uid && isTrigger(event.target)) show(uid);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
document.addEventListener('focusout', function (event) {
|
|
211
|
+
var uid = uidFor(event.target);
|
|
212
|
+
if (uid && isInput(event.target)) hide(uid);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
document.addEventListener('keydown', function (event) {
|
|
216
|
+
var target = event.target;
|
|
217
|
+
var uid = uidFor(target);
|
|
218
|
+
if (!uid) return;
|
|
219
|
+
|
|
220
|
+
if (target.classList.contains('dyna-span-text')) {
|
|
221
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
show(uid);
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!isInput(target)) return;
|
|
229
|
+
|
|
230
|
+
var textarea = target.tagName === 'TEXTAREA';
|
|
231
|
+
var submitKey = event.key === 'Enter' && (!textarea || event.ctrlKey || event.metaKey);
|
|
232
|
+
if (event.key === 'Escape' || (submitKey && target.tagName !== 'SELECT')) {
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
hide(uid, { cancel: event.key === 'Escape' });
|
|
235
|
+
var span = parts(uid).span;
|
|
236
|
+
if (span) span.focus();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Never let the browser, rails-ujs or Turbo submit a Dynaspan form.
|
|
241
|
+
document.addEventListener('submit', function (event) {
|
|
242
|
+
var uid = uidFor(event.target);
|
|
243
|
+
if (!uid) return;
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
event.stopPropagation();
|
|
246
|
+
hide(uid);
|
|
247
|
+
}, true);
|
|
248
|
+
|
|
249
|
+
var Dynaspan = {
|
|
250
|
+
version: '1.0.0',
|
|
251
|
+
show: show,
|
|
252
|
+
hide: function (uid) { hide(uid); },
|
|
253
|
+
cancel: function (uid) { hide(uid, { cancel: true }); },
|
|
254
|
+
appendParameter: appendParameter
|
|
255
|
+
};
|
|
256
|
+
window.Dynaspan = Dynaspan;
|
|
257
|
+
|
|
258
|
+
// Backwards compatibility with the jQuery API of Dynaspan 0.x:
|
|
259
|
+
// $().dynaspan.upShow(id), $().dynaspan.upHide(id), $().dynaspan.upLast(id)
|
|
260
|
+
function installJQueryApi() {
|
|
261
|
+
var $ = window.jQuery;
|
|
262
|
+
if (!$ || !$.fn || $.fn.dynaspan) return;
|
|
263
|
+
$.fn.dynaspan = function () { return this; };
|
|
264
|
+
$.fn.dynaspan.upShow = show;
|
|
265
|
+
$.fn.dynaspan.upHide = Dynaspan.hide;
|
|
266
|
+
$.fn.dynaspan.upLast = restore;
|
|
267
|
+
$.fn.dynaspan.appendParameter = appendParameter;
|
|
268
|
+
}
|
|
269
|
+
installJQueryApi();
|
|
270
|
+
document.addEventListener('DOMContentLoaded', installJQueryApi);
|
|
271
|
+
})(typeof window !== 'undefined' ? window : undefined, typeof document !== 'undefined' ? document : undefined);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Dynaspan default styles (optional).
|
|
3
|
+
* Load with stylesheet_link_tag "dynaspan/dynaspan", require it from your
|
|
4
|
+
* Sprockets manifest, or write your own.
|
|
5
|
+
*/
|
|
6
|
+
.dyna-span .dyna-span-text {
|
|
7
|
+
cursor: text;
|
|
8
|
+
min-height: 1.5em;
|
|
9
|
+
border-radius: 4px;
|
|
10
|
+
outline-offset: 2px;
|
|
11
|
+
transition: background-color 0.15s ease-in-out;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.dyna-span .dyna-span-text:hover,
|
|
15
|
+
.dyna-span .dyna-span-text:focus-visible {
|
|
16
|
+
background-color: rgba(59, 130, 246, 0.08);
|
|
17
|
+
box-shadow: inset 0 -1px 0 rgba(59, 130, 246, 0.6);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.dyna-span[data-dynaspan-kind="text_area"] .dyna-span-text {
|
|
21
|
+
white-space: pre-wrap;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.dyna-span .dyna-span-input {
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
width: 100%;
|
|
27
|
+
font: inherit;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.dyna-span .dyna-span-edit-text {
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.dyna-span.ds-dialog-open > .dyna-span-edit-text {
|
|
35
|
+
display: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.dyna-span.ds-saving .dyna-span-text {
|
|
39
|
+
opacity: 0.6;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.dyna-span.ds-error .dyna-span-text {
|
|
43
|
+
box-shadow: inset 0 -2px 0 #dc2626;
|
|
44
|
+
}
|
|
@@ -1,67 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
1
5
|
module Dynaspan
|
|
6
|
+
# View helpers that render a piece of text which turns into a form field
|
|
7
|
+
# when clicked and saves itself over AJAX when the field loses focus.
|
|
8
|
+
#
|
|
9
|
+
# Every helper takes the same arguments:
|
|
10
|
+
#
|
|
11
|
+
# dynaspan_text_field(record, :attribute)
|
|
12
|
+
# dynaspan_text_field(record, :attribute, '[edit]', options)
|
|
13
|
+
# dynaspan_text_field(record, nested_record, :attribute, '[edit]', options)
|
|
14
|
+
#
|
|
15
|
+
# See the README for the full list of options.
|
|
2
16
|
module ApplicationHelper
|
|
17
|
+
RESERVED_HTML_OPTIONS = %w[id onblur onfocus].freeze
|
|
18
|
+
INPUT_CLASSES = 'dyna-span form-control dyna-span-input'
|
|
3
19
|
|
|
4
|
-
def dynaspan_text_field(master_ds_object
|
|
5
|
-
|
|
20
|
+
def dynaspan_text_field(master_ds_object, *parameters)
|
|
21
|
+
dynaspan_render(:text_field, master_ds_object, parameters)
|
|
6
22
|
end
|
|
7
23
|
|
|
8
|
-
def dynaspan_text_area(master_ds_object
|
|
9
|
-
|
|
24
|
+
def dynaspan_text_area(master_ds_object, *parameters)
|
|
25
|
+
dynaspan_render(:text_area, master_ds_object, parameters)
|
|
10
26
|
end
|
|
11
27
|
|
|
12
|
-
def dynaspan_select(master_ds_object
|
|
13
|
-
|
|
28
|
+
def dynaspan_select(master_ds_object, *parameters, &block)
|
|
29
|
+
dynaspan_render(:select, master_ds_object, parameters, &block)
|
|
14
30
|
end
|
|
15
31
|
|
|
16
32
|
private
|
|
17
|
-
module_function
|
|
18
33
|
|
|
19
|
-
def
|
|
20
|
-
|
|
34
|
+
def dynaspan_render(kind, master_ds_object, parameters, &block)
|
|
35
|
+
raise ArgumentError, 'Dynaspan needs a record to update as its first argument.' if master_ds_object.nil?
|
|
36
|
+
|
|
37
|
+
parameters = parameters.dup
|
|
38
|
+
options = parameters.last.is_a?(Hash) ? parameters.pop.with_indifferent_access : ActiveSupport::HashWithIndifferentAccess.new
|
|
39
|
+
attr_object, attrib, edit_text = dynaspan_parse_arguments(parameters)
|
|
40
|
+
|
|
41
|
+
association = dynaspan_nested_association(master_ds_object, attr_object)
|
|
42
|
+
target = attr_object || master_ds_object
|
|
43
|
+
value = target.public_send(attrib)
|
|
44
|
+
unique_ref_id = options.fetch(:unique_id) { dynaspan_unique_id(master_ds_object, attr_object, attrib) }.to_s
|
|
45
|
+
choices = options[:choices]
|
|
46
|
+
|
|
47
|
+
render(
|
|
48
|
+
partial: 'dynaspan/dynaspan',
|
|
49
|
+
locals: {
|
|
50
|
+
kind: kind,
|
|
51
|
+
master_ds_object: master_ds_object,
|
|
52
|
+
attr_object: attr_object,
|
|
53
|
+
association: association,
|
|
54
|
+
attrib: attrib,
|
|
55
|
+
value: value,
|
|
56
|
+
display_text: kind == :select ? dynaspan_choice_text(choices, value) || value : value,
|
|
57
|
+
unique_ref_id: unique_ref_id,
|
|
58
|
+
dyna_span_edit_text: edit_text,
|
|
59
|
+
hidden_fields: options[:hidden_fields] || {},
|
|
60
|
+
ds_callback_on_update: options[:callback_on_update],
|
|
61
|
+
ds_callback_with_values: options[:callback_with_values],
|
|
62
|
+
form_for_options: dynaspan_form_options(options[:form_for]),
|
|
63
|
+
choices: choices,
|
|
64
|
+
select_options: (options[:options] || {}).to_h.symbolize_keys,
|
|
65
|
+
html_options: dynaspan_html_options(options[:html_options], kind, unique_ref_id),
|
|
66
|
+
block: block
|
|
67
|
+
}
|
|
68
|
+
)
|
|
21
69
|
end
|
|
22
70
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if parameters.first.is_a?
|
|
71
|
+
# Accepts (attribute, edit_text?) or (nested_record, attribute, edit_text?).
|
|
72
|
+
def dynaspan_parse_arguments(parameters)
|
|
73
|
+
if parameters.first.is_a?(Symbol)
|
|
26
74
|
attr_object = nil
|
|
27
|
-
attrib = parameters
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
attr_object, attrib = parameters
|
|
31
|
-
edit_text = parameters.try(:[],2)
|
|
75
|
+
attrib, edit_text = parameters
|
|
76
|
+
elsif parameters[1].is_a?(Symbol)
|
|
77
|
+
attr_object, attrib, edit_text = parameters
|
|
32
78
|
else
|
|
33
|
-
raise '
|
|
79
|
+
raise ArgumentError, 'Dynaspan needs a Symbol naming the attribute to edit, e.g. dynaspan_text_field(user, :name).'
|
|
34
80
|
end
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
)
|
|
81
|
+
|
|
82
|
+
[attr_object, attrib, edit_text.is_a?(String) ? edit_text : nil]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Finds the `accepts_nested_attributes_for` association on the master
|
|
86
|
+
# record that the nested record belongs to.
|
|
87
|
+
def dynaspan_nested_association(master_ds_object, attr_object)
|
|
88
|
+
return if attr_object.nil?
|
|
89
|
+
|
|
90
|
+
klass = master_ds_object.class
|
|
91
|
+
names = klass.respond_to?(:nested_attributes_options) ? klass.nested_attributes_options.keys.map(&:to_s) : []
|
|
92
|
+
|
|
93
|
+
name = names.find do |association_name|
|
|
94
|
+
reflection = klass.try(:reflect_on_association, association_name)
|
|
95
|
+
reflection && !reflection.polymorphic? && attr_object.is_a?(reflection.klass)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
name ||= begin
|
|
99
|
+
model_name = attr_object.class.model_name
|
|
100
|
+
candidates = [model_name.singular, model_name.plural, model_name.element, attr_object.class.try(:table_name)].compact
|
|
101
|
+
names.find { |association_name| candidates.any? { |candidate| association_name.include?(candidate) } }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
unless name
|
|
105
|
+
raise ArgumentError,
|
|
106
|
+
"Dynaspan could not find a nested association for #{attr_object.class.name} on #{klass.name}. " \
|
|
107
|
+
"Add `accepts_nested_attributes_for` for it to #{klass.name}."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
reflection = klass.try(:reflect_on_association, name)
|
|
111
|
+
{ name: name.to_sym, collection: reflection.respond_to?(:collection?) && reflection.collection? }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def dynaspan_unique_id(master_ds_object, attr_object, attrib)
|
|
115
|
+
[dynaspan_record_id(master_ds_object), dynaspan_record_id(attr_object), attrib, SecureRandom.hex(3)]
|
|
116
|
+
.join.gsub(/[^A-Za-z0-9_-]/, '_')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def dynaspan_record_id(record)
|
|
120
|
+
record ? "#{record.class.name}#{record.try(:id)}" : ''
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def dynaspan_form_options(custom)
|
|
124
|
+
{ method: :patch, authenticity_token: true }
|
|
125
|
+
.merge((custom || {}).to_h.symbolize_keys)
|
|
126
|
+
.tap do |form_options|
|
|
127
|
+
form_options[:html] = (form_options[:html] || {}).to_h.symbolize_keys
|
|
128
|
+
form_options[:html][:data] = { turbo: false }.merge((form_options[:html][:data] || {}).to_h.symbolize_keys)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def dynaspan_html_options(custom, kind, unique_ref_id)
|
|
133
|
+
html_options = (custom || {}).to_h.stringify_keys.except(*RESERVED_HTML_OPTIONS)
|
|
134
|
+
html_options['class'] = [INPUT_CLASSES, html_options['class']].compact.join(' ').strip
|
|
135
|
+
html_options['id'] = "dyna_span_field_val_#{unique_ref_id}"
|
|
136
|
+
html_options['data'] = (html_options['data'] || {}).to_h.merge('dynaspan-input' => kind)
|
|
137
|
+
html_options.symbolize_keys
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def dynaspan_input(builder, kind, attrib, locals)
|
|
141
|
+
case kind
|
|
142
|
+
when :text_field then builder.text_field(attrib, locals[:html_options])
|
|
143
|
+
when :text_area then builder.text_area(attrib, locals[:html_options])
|
|
144
|
+
when :select
|
|
145
|
+
builder.select(attrib, locals[:choices], locals[:select_options], locals[:html_options], &locals[:block])
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Looks up the label shown for `value` among select choices. Supports the
|
|
150
|
+
# formats Rails' `select` accepts: arrays, hashes, grouped choices and
|
|
151
|
+
# pre-rendered option tags (e.g. from `options_for_select`).
|
|
152
|
+
def dynaspan_choice_text(choices, value)
|
|
153
|
+
return if choices.blank?
|
|
154
|
+
|
|
155
|
+
target = value.to_s
|
|
156
|
+
if choices.is_a?(String)
|
|
157
|
+
require 'nokogiri'
|
|
158
|
+
option = Nokogiri::HTML::DocumentFragment.parse(choices).css('option').find do |node|
|
|
159
|
+
(node['value'] || node.text) == target
|
|
160
|
+
end
|
|
161
|
+
return option&.text
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
choices = choices.to_a if choices.is_a?(Hash)
|
|
165
|
+
choices.each do |element|
|
|
166
|
+
parts = element.is_a?(Array) ? element.reject { |part| part.is_a?(Hash) } : [element, element]
|
|
167
|
+
label = parts.first
|
|
168
|
+
option_value = parts.last
|
|
169
|
+
|
|
170
|
+
if option_value.is_a?(Array) || option_value.is_a?(Hash)
|
|
171
|
+
found = dynaspan_choice_text(option_value, value)
|
|
172
|
+
return found if found
|
|
173
|
+
elsif option_value.to_s == target
|
|
174
|
+
return label.to_s
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
nil
|
|
65
178
|
end
|
|
66
179
|
end
|
|
67
180
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%# Rendered by dynaspan_text_field, dynaspan_text_area and dynaspan_select. %>
|
|
2
|
+
<%= tag.div id: "dyna_span_block#{unique_ref_id}",
|
|
3
|
+
class: ['dyna-span', ('ds-content-present' if value.present?)],
|
|
4
|
+
data: {
|
|
5
|
+
dynaspan: unique_ref_id,
|
|
6
|
+
dynaspan_kind: kind,
|
|
7
|
+
ds_callback_on_update: ds_callback_on_update.presence,
|
|
8
|
+
ds_callback_with_values: ds_callback_with_values.presence
|
|
9
|
+
} do %>
|
|
10
|
+
<%= tag.input type: 'hidden', id: "last_dyna_span_val_#{unique_ref_id}", value: value, autocomplete: 'off' %>
|
|
11
|
+
<%= tag.div id: "dyna_span_div#{unique_ref_id}", class: 'dyna-span-form', style: 'display:none;' do %>
|
|
12
|
+
<%= form_for(master_ds_object, **form_for_options) do |f| %>
|
|
13
|
+
<% if association %>
|
|
14
|
+
<%= f.fields_for association[:name], (association[:collection] ? [attr_object] : attr_object) do |nested| %>
|
|
15
|
+
<%= dynaspan_input(nested, kind, attrib, local_assigns) %>
|
|
16
|
+
<% hidden_fields.each do |name, hidden_value| %>
|
|
17
|
+
<%= nested.hidden_field name, value: hidden_value %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% else %>
|
|
21
|
+
<%= dynaspan_input(f, kind, attrib, local_assigns) %>
|
|
22
|
+
<% hidden_fields.each do |name, hidden_value| %>
|
|
23
|
+
<%= f.hidden_field name, value: hidden_value %>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% end %>
|
|
28
|
+
<%= tag.span display_text, id: "dyna_span_span#{unique_ref_id}", class: 'dyna-span dyna-span-text', style: 'display:block;', tabindex: 0 %>
|
|
29
|
+
<% if dyna_span_edit_text %>
|
|
30
|
+
<%= tag.div dyna_span_edit_text, class: 'dyna-span-edit-text pull-right', style: 'cursor:pointer;' %>
|
|
31
|
+
<% end %>
|
|
32
|
+
<% end %>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails'
|
|
4
|
+
|
|
5
|
+
module Dynaspan
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace Dynaspan
|
|
8
|
+
|
|
9
|
+
ASSETS = %w[dynaspan/dynaspan.js dynaspan/dynaspan.css].freeze
|
|
10
|
+
|
|
11
|
+
# Make the view helpers available in every view of the host application.
|
|
12
|
+
initializer 'dynaspan.helpers' do
|
|
13
|
+
ActiveSupport.on_load(:action_view) do
|
|
14
|
+
include Dynaspan::ApplicationHelper
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Sprockets needs to be told about stand-alone assets. Propshaft serves
|
|
19
|
+
# everything in the engine's app/assets directories automatically.
|
|
20
|
+
initializer 'dynaspan.assets' do |app|
|
|
21
|
+
if app.config.respond_to?(:assets)
|
|
22
|
+
precompile = app.config.assets.precompile
|
|
23
|
+
precompile.concat(ASSETS) if precompile.respond_to?(:concat)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# With importmap-rails, `import "dynaspan"` works without any extra pins.
|
|
28
|
+
initializer 'dynaspan.importmap', before: 'importmap' do |app|
|
|
29
|
+
app.config.importmap.paths << root.join('config/importmap.rb') if app.config.respond_to?(:importmap)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/dynaspan/version.rb
CHANGED
data/lib/dynaspan.rb
CHANGED
|
@@ -1,32 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'dynaspan/version'
|
|
4
|
+
require 'dynaspan/engine'
|
|
3
5
|
|
|
6
|
+
# Click-to-edit, in-place AJAX text editing for Rails.
|
|
4
7
|
module Dynaspan
|
|
5
|
-
ASSETS = %W(dynaspan/*.js dynaspan/*.css dynaspan/*.png dynaspan/*.gif dynaspan/*.html dynaspan/*.md)
|
|
6
|
-
INITIALIZER = "initializer 'dynaspan.assets.precompile', group: :all do |app| app.config.assets.precompile += Dynaspan::ASSETS; end"
|
|
7
|
-
|
|
8
|
-
class Engine < ::Rails::Engine
|
|
9
|
-
isolate_namespace Dynaspan
|
|
10
|
-
|
|
11
|
-
eval Dynaspan::INITIALIZER
|
|
12
|
-
|
|
13
|
-
initializer 'dynaspan.action_controller' do |app|
|
|
14
|
-
ActiveSupport.on_load :action_controller do
|
|
15
|
-
helper Dynaspan::ApplicationHelper
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
if ::Rails.version.to_s =~ /^4/
|
|
20
|
-
rake_tasks do
|
|
21
|
-
load "dynaspan/tasks.rake"
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
if ::Rails.version.to_s =~ /^3\.[0]/
|
|
27
|
-
class Railtie < ::Rails::Railtie
|
|
28
|
-
eval Dynaspan::INITIALIZER
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
8
|
end
|
|
32
|
-
|