formatic 0.4.4 → 0.6.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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +38 -0
- data/app/assets/javascript/declarations/search.d.ts +39 -0
- data/app/assets/javascript/declarations/search_record.d.ts +9 -0
- data/app/assets/javascript/declarations/search_requests.d.ts +13 -0
- data/app/assets/javascript/formatic/search.js +188 -0
- data/app/assets/javascript/formatic/search.js.map +1 -0
- data/app/assets/javascript/formatic/search_record.js +21 -0
- data/app/assets/javascript/formatic/search_record.js.map +1 -0
- data/app/assets/javascript/formatic/search_requests.js +72 -0
- data/app/assets/javascript/formatic/search_requests.js.map +1 -0
- data/app/assets/javascript/formatic/setup.js +6 -0
- data/app/assets/javascript/src/search.ts +242 -0
- data/app/assets/javascript/src/search_record.ts +24 -0
- data/app/assets/javascript/src/search_requests.ts +94 -0
- data/app/assets/stylesheets/formatic/components/search.sass +51 -0
- data/app/assets/stylesheets/formatic/formatic.css +25 -0
- data/app/assets/stylesheets/formatic/formatic.css.map +1 -1
- data/app/assets/stylesheets/formatic.sass +1 -0
- data/app/components/formatic/file.rb +27 -20
- data/app/components/formatic/search/choice.rb +14 -0
- data/app/components/formatic/search/clear.rb +12 -0
- data/app/components/formatic/search/frontend.rb +21 -0
- data/app/components/formatic/search/selected.rb +19 -0
- data/app/components/formatic/search.rb +30 -0
- data/config/importmap.rb +3 -0
- data/lib/formatic/engine.rb +5 -0
- data/lib/formatic/templates/search.rb +51 -0
- data/lib/formatic/testing/capybara.rb +20 -0
- data/lib/formatic/testing/search.rb +87 -0
- data/lib/formatic/version.rb +1 -1
- metadata +22 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { SearchRecord } from 'formatic/search_record'
|
|
2
|
+
import { SearchRequests } from 'formatic/search_requests'
|
|
3
|
+
|
|
4
|
+
export class Search {
|
|
5
|
+
private el: HTMLElement
|
|
6
|
+
private requests: SearchRequests
|
|
7
|
+
private choiceRecords: Map<HTMLElement, SearchRecord>
|
|
8
|
+
private timeoutId: ReturnType<typeof setTimeout>
|
|
9
|
+
|
|
10
|
+
constructor(el: HTMLElement) {
|
|
11
|
+
this.el = el
|
|
12
|
+
this.requests = new SearchRequests
|
|
13
|
+
this.choiceRecords = new Map()
|
|
14
|
+
this.setupBindings()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Setup
|
|
18
|
+
|
|
19
|
+
private setupBindings() {
|
|
20
|
+
if (!this.queryInput) return
|
|
21
|
+
|
|
22
|
+
this.resultsContainer.addEventListener('click', (event) => {
|
|
23
|
+
const choice = this.clicked('.js-formatic-search__choice', event)
|
|
24
|
+
if (!choice) return
|
|
25
|
+
|
|
26
|
+
event.preventDefault()
|
|
27
|
+
this.actionChoose(choice)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// The clear control arrives inside the selected card, so the listener
|
|
31
|
+
// stays on the search root.
|
|
32
|
+
this.el.addEventListener('click', (event) => {
|
|
33
|
+
const clear = this.clicked('.js-formatic-search__clear', event)
|
|
34
|
+
if (!clear) return
|
|
35
|
+
|
|
36
|
+
event.preventDefault()
|
|
37
|
+
this.actionClear()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
this.queryInput.addEventListener('input', (event) => {
|
|
41
|
+
event.preventDefault()
|
|
42
|
+
this.actionSearch()
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Helpers
|
|
47
|
+
|
|
48
|
+
private clicked(selector: string, event: Event) {
|
|
49
|
+
const target = event.target
|
|
50
|
+
const receiver = target instanceof Element ? target : target instanceof Node ? target.parentElement : null
|
|
51
|
+
const match = receiver?.closest<HTMLElement>(selector)
|
|
52
|
+
if (!match || !this.el.contains(match)) return null
|
|
53
|
+
|
|
54
|
+
return match
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private debounce(fn: Function) {
|
|
58
|
+
return () => {
|
|
59
|
+
clearTimeout(this.timeoutId)
|
|
60
|
+
this.timeoutId = setTimeout(() => fn(), 400)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Actions
|
|
65
|
+
|
|
66
|
+
private actionSearch() {
|
|
67
|
+
this.guiWaitingForQuery()
|
|
68
|
+
this.debounce(this.actionSearchNow.bind(this))()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private actionSearchNow() {
|
|
72
|
+
if (this.queryInput.value.length == 0) {
|
|
73
|
+
this.resultsContainer.innerHTML = ''
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.guiSearchingRecords()
|
|
78
|
+
this.requests.search(this.resourceUrl, this.queryInput.value, this.searchParams)
|
|
79
|
+
.then(result => {
|
|
80
|
+
// On multiple parallel ajax requests, the currently wanted response wins.
|
|
81
|
+
if (result?.query != this.queryInput.value) return
|
|
82
|
+
|
|
83
|
+
return result.data.records.map((attributes: any) => new SearchRecord(attributes))
|
|
84
|
+
}).then(records => {
|
|
85
|
+
if (records) this.guiPopulateRecordChoices(records)
|
|
86
|
+
}).catch(err => {
|
|
87
|
+
console.warn(`Finding records failed: ${err}`)
|
|
88
|
+
this.guiFailed()
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private actionChoose(choice: HTMLElement) {
|
|
93
|
+
clearTimeout(this.timeoutId)
|
|
94
|
+
this.requests.abort()
|
|
95
|
+
|
|
96
|
+
const record = this.choiceRecords.get(choice)
|
|
97
|
+
if (!record) throw new Error('No search result for the chosen element')
|
|
98
|
+
|
|
99
|
+
this.guiChooseRecord(record)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private actionClear() {
|
|
103
|
+
clearTimeout(this.timeoutId)
|
|
104
|
+
this.requests.abort()
|
|
105
|
+
this.guiClear()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// GUI
|
|
109
|
+
|
|
110
|
+
private guiWaitingForQuery() {
|
|
111
|
+
this.resultsContainer.textContent = 'Waiting for input...'
|
|
112
|
+
this.guiMayNotAddNew()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private guiSearchingRecords() {
|
|
116
|
+
this.resultsContainer.textContent = 'Searching...'
|
|
117
|
+
this.guiMayNotAddNew()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private guiFailed() {
|
|
121
|
+
this.choiceRecords.clear()
|
|
122
|
+
this.resultsContainer.textContent = 'Search failed.'
|
|
123
|
+
this.guiMayNotAddNew()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private guiPopulateRecordChoices(records: Array<SearchRecord>) {
|
|
127
|
+
this.resultsContainer.innerHTML = records.map((record) => record.renderedChoice).join('')
|
|
128
|
+
this.choiceRecords = this.choicesFor(records)
|
|
129
|
+
if (records.length == 0) this.resultsContainer.textContent = 'No records found.'
|
|
130
|
+
this.guiMayAddNew()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private guiChooseRecord(record: SearchRecord) {
|
|
134
|
+
this.valueInput.value = record.recordId
|
|
135
|
+
this.selectedContainer.innerHTML = record.renderedSelected
|
|
136
|
+
this.resultsContainer.innerHTML = ''
|
|
137
|
+
this.queryInput.value = ''
|
|
138
|
+
this.choiceRecords.clear()
|
|
139
|
+
this.guiMayNotAddNew()
|
|
140
|
+
this.notifyValueChanged()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private guiClear() {
|
|
144
|
+
this.valueInput.value = ''
|
|
145
|
+
this.selectedContainer.innerHTML = ''
|
|
146
|
+
this.resultsContainer.innerHTML = ''
|
|
147
|
+
this.queryInput.value = ''
|
|
148
|
+
this.choiceRecords.clear()
|
|
149
|
+
this.guiMayNotAddNew()
|
|
150
|
+
this.notifyValueChanged()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private guiMayAddNew() {
|
|
154
|
+
this.addRecordContainer.hidden = false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private guiMayNotAddNew() {
|
|
158
|
+
this.addRecordContainer.hidden = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private notifyValueChanged() {
|
|
162
|
+
this.valueInput.dispatchEvent(new Event('change', { bubbles: true }))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private choicesFor(records: Array<SearchRecord>) {
|
|
166
|
+
const choices = Array.from(this.choices)
|
|
167
|
+
if (choices.length != records.length) {
|
|
168
|
+
throw new Error(`Expected ${records.length} .js-formatic-search__choice elements, found ${choices.length}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return new Map(choices.map((choice, index) => [choice, records[index]]))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Attributes
|
|
175
|
+
|
|
176
|
+
private get searchParams() {
|
|
177
|
+
const params: { [key: string]: string } = {
|
|
178
|
+
record_name: this.recordName,
|
|
179
|
+
attribute_name: this.attributeName,
|
|
180
|
+
}
|
|
181
|
+
if (this.excludeIds) params.exclude_ids = this.excludeIds
|
|
182
|
+
return params
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private get resourceUrl() {
|
|
186
|
+
return this.resourceInput.value
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private get recordName() {
|
|
190
|
+
return this.recordNameInput.value
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private get attributeName() {
|
|
194
|
+
return this.attributeNameInput.value
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private get excludeIds() {
|
|
198
|
+
return this.excludeIdsInput.value
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// DOM elements
|
|
202
|
+
|
|
203
|
+
private get resourceInput() {
|
|
204
|
+
return this.el.querySelector<HTMLInputElement>('.js-formatic-search__url')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private get recordNameInput() {
|
|
208
|
+
return this.el.querySelector<HTMLInputElement>('.js-formatic-search__record-name')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private get attributeNameInput() {
|
|
212
|
+
return this.el.querySelector<HTMLInputElement>('.js-formatic-search__attribute-name')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private get excludeIdsInput() {
|
|
216
|
+
return this.el.querySelector<HTMLInputElement>('.js-formatic-search__exclude-ids')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private get queryInput() {
|
|
220
|
+
return this.el.querySelector<HTMLInputElement>('.js-formatic-search__query-input')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private get valueInput() {
|
|
224
|
+
return this.el.querySelector<HTMLInputElement>('.js-formatic-search__value')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private get selectedContainer() {
|
|
228
|
+
return this.el.querySelector<HTMLElement>('.js-formatic-search__selected')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private get resultsContainer() {
|
|
232
|
+
return this.el.querySelector<HTMLElement>('.js-formatic-search__results')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private get choices() {
|
|
236
|
+
return this.resultsContainer.querySelectorAll<HTMLElement>('.js-formatic-search__choice')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private get addRecordContainer() {
|
|
240
|
+
return this.el.querySelector<HTMLElement>('.js-formatic-search__add-record')
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class SearchRecord {
|
|
2
|
+
id: string | number
|
|
3
|
+
choice: string
|
|
4
|
+
selected: string
|
|
5
|
+
|
|
6
|
+
constructor(attributes: Partial<SearchRecord>) {
|
|
7
|
+
Object.assign(this, attributes)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public get recordId() {
|
|
11
|
+
if (this.id == null || this.id == '') throw new Error('Search record is missing id')
|
|
12
|
+
return String(this.id)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public get renderedChoice() {
|
|
16
|
+
if (!this.choice) throw new Error('Search record is missing choice')
|
|
17
|
+
return this.choice
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public get renderedSelected() {
|
|
21
|
+
if (!this.selected) throw new Error('Search record is missing selected')
|
|
22
|
+
return this.selected
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// search(client, '/records.json', 'hello', { record_name: 'customer' })
|
|
2
|
+
// GET /records.json?record_name=customer&query=hello
|
|
3
|
+
|
|
4
|
+
export class SearchRequests {
|
|
5
|
+
private previousController: AbortController
|
|
6
|
+
private currentController: AbortController
|
|
7
|
+
|
|
8
|
+
search(resource: string, searchterm: string, params: { [key: string]: string }): Promise<any> {
|
|
9
|
+
const options = new Options
|
|
10
|
+
options.resource = resource
|
|
11
|
+
options.params = { ...params }
|
|
12
|
+
options.params.query = searchterm
|
|
13
|
+
return this.perform(options)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
abort() {
|
|
17
|
+
if (this.previousController) this.previousController.abort()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private perform(options: Options): Promise<any> {
|
|
21
|
+
this.abortPreviousRequests()
|
|
22
|
+
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
if (options.params.query == '') return resolve(null)
|
|
25
|
+
|
|
26
|
+
console.debug(`SearchRequests GET ${options.url}`)
|
|
27
|
+
fetch(options.url, this.requestOptions)
|
|
28
|
+
.then(response => this.parseResponse(response))
|
|
29
|
+
.then(data => {
|
|
30
|
+
resolve(new Result({ data: data, options: options, query: options.params.query }))
|
|
31
|
+
}).catch(err => {
|
|
32
|
+
this.fail(err, options, reject)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private abortPreviousRequests() {
|
|
38
|
+
this.abort()
|
|
39
|
+
this.currentController = new AbortController()
|
|
40
|
+
this.previousController = this.currentController
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private parseResponse(response: Response) {
|
|
44
|
+
if (!response.ok) throw Error(response.statusText)
|
|
45
|
+
return response.json()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private fail(err: any, options: Options, reject: (reason?: any) => void) {
|
|
49
|
+
if (err.name == 'AbortError') {
|
|
50
|
+
// A newer query started, then this request was aborted.
|
|
51
|
+
// The promise stays unsettled so this response cannot overwrite the newer result.
|
|
52
|
+
console.debug(`Cancelled GET ${options.url}`)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.debug(`Failed to GET ${options.url}`)
|
|
57
|
+
reject(err)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private get requestOptions() {
|
|
61
|
+
return {
|
|
62
|
+
method: 'GET',
|
|
63
|
+
headers: {
|
|
64
|
+
'Accept': 'application/json',
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
},
|
|
67
|
+
signal: this.currentController.signal,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class Options {
|
|
73
|
+
resource: string
|
|
74
|
+
params: { [key: string]: string }
|
|
75
|
+
|
|
76
|
+
public get url() {
|
|
77
|
+
const requestParams = new URLSearchParams()
|
|
78
|
+
for (const [key, value] of Object.entries(this.params)) {
|
|
79
|
+
requestParams.set(key, value)
|
|
80
|
+
}
|
|
81
|
+
const separator = this.resource.includes('?') ? '&' : '?'
|
|
82
|
+
return this.resource + separator + requestParams
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class Result {
|
|
87
|
+
data: any
|
|
88
|
+
options: Options
|
|
89
|
+
query: string
|
|
90
|
+
|
|
91
|
+
constructor(attributes: Partial<Result>) {
|
|
92
|
+
Object.assign(this, attributes)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
.c-formatic-search
|
|
2
|
+
display: grid
|
|
3
|
+
grid-template-columns: auto
|
|
4
|
+
gap: 0.4rem
|
|
5
|
+
|
|
6
|
+
&__query
|
|
7
|
+
display: flex
|
|
8
|
+
align-items: center
|
|
9
|
+
gap: 0.2rem
|
|
10
|
+
|
|
11
|
+
input
|
|
12
|
+
flex-grow: 1
|
|
13
|
+
min-width: 0
|
|
14
|
+
|
|
15
|
+
&__selected,
|
|
16
|
+
&__results,
|
|
17
|
+
&__clear
|
|
18
|
+
display: grid
|
|
19
|
+
gap: 0.2rem
|
|
20
|
+
|
|
21
|
+
&__selection
|
|
22
|
+
display: flex
|
|
23
|
+
align-items: center
|
|
24
|
+
justify-content: space-between
|
|
25
|
+
gap: 0.4rem
|
|
26
|
+
|
|
27
|
+
&__selected-record
|
|
28
|
+
flex-grow: 1
|
|
29
|
+
min-width: 0
|
|
30
|
+
|
|
31
|
+
&__actions
|
|
32
|
+
flex-shrink: 0
|
|
33
|
+
align-self: stretch
|
|
34
|
+
height: 100%
|
|
35
|
+
|
|
36
|
+
&__results:empty
|
|
37
|
+
display: none
|
|
38
|
+
|
|
39
|
+
&__add-record:empty
|
|
40
|
+
display: none
|
|
41
|
+
|
|
42
|
+
@at-root a.c-formatic-search__clear
|
|
43
|
+
speak: none
|
|
44
|
+
font-style: normal
|
|
45
|
+
font-weight: normal
|
|
46
|
+
font-variant: normal
|
|
47
|
+
text-transform: none
|
|
48
|
+
text-decoration: none
|
|
49
|
+
|
|
50
|
+
&::before
|
|
51
|
+
content: "\00d7"
|
|
@@ -159,6 +159,31 @@
|
|
|
159
159
|
background-size: 200% 200%;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
.c-formatic-search {
|
|
163
|
+
display: grid;
|
|
164
|
+
grid-template-columns: auto;
|
|
165
|
+
gap: 0.4rem;
|
|
166
|
+
}
|
|
167
|
+
.c-formatic-search__query {
|
|
168
|
+
display: flex;
|
|
169
|
+
align-items: center;
|
|
170
|
+
gap: 0.2rem;
|
|
171
|
+
}
|
|
172
|
+
.c-formatic-search__query input {
|
|
173
|
+
flex-grow: 1;
|
|
174
|
+
min-width: 0;
|
|
175
|
+
}
|
|
176
|
+
.c-formatic-search__selected, .c-formatic-search__results, .c-formatic-search__clear {
|
|
177
|
+
display: grid;
|
|
178
|
+
gap: 0.2rem;
|
|
179
|
+
}
|
|
180
|
+
.c-formatic-search__results:empty {
|
|
181
|
+
display: none;
|
|
182
|
+
}
|
|
183
|
+
.c-formatic-search__add-record:empty {
|
|
184
|
+
display: none;
|
|
185
|
+
}
|
|
186
|
+
|
|
162
187
|
.c-formatic-wrapper.is-required .c-formatic-select {
|
|
163
188
|
border-left-color: rgb(240, 30, 80);
|
|
164
189
|
border-left-width: 2px;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sourceRoot":"","sources":["generics/flip.sass","components/wrapper.sass","../../../../node_modules/iglu/iglu/font-size/tools/_sizing.sass","../../../../node_modules/iglu/iglu/spacing/tools/_margin.sass","settings/_colors.sass","components/checklist.sass","components/date.sass","tools/_mandatory.sass","../../../../node_modules/iglu/iglu/spacing/tools/_padding_immediate.sass","tools/_link.sass","tools/_terminal.sass","tools/_cloud.sass","components/stepper.sass","components/time.sass","components/toggle.sass","utilities/container.sass","scopes/form.sass","tools/_theme.sass"],"names":[],"mappings":"AAAA;EACE;IACE;;;ACGJ;EACE;EACA;EACA;EACA;;ACGD;EACE;;ACiED;EACE;;AFlEF;EACE;;AAGA;EACE;;AADF;EACE;;AADF;EACE;;AADF;EACE;;AADF;EACE;;AAEJ;EAEE;;AAEF;EACE,OGpBY;;AHsBZ;EACE;EACA;EACA;EACA;EACA;EACA;;AAEJ;EACE;;AE8BF;EACE;;AD/DH;EACE;;ADmCD;EACE;EACA;EACA;;;AAEJ;EACE;IACE;IACA;IACA;IACA;;;AIlDJ;EACE;EACA;;;ACQA;EACE;EACA;EACA;;AH6DF;EACE;;AIxEF;EACE,mBHCY;EGAZ;;ADeF;EACE;EAEA;EACA;EACA;EACA;;AACA;EACE;;AAEJ;EACE;EACA;EACA;EACA;EACA;EACA;;AHIF;EACE;;AKnCF;EACE;;ACNF;EHuCI,OFtCE;;AFcP;EACE;;AMHD;EACE;;ANHH;EACE;;AQZD;EACE;;AJgDF;EACE;;AJ/CH;EACE;;AIiDD;EACE;;AItDF;EACE;;AREH;EACE;;AIsDD;EACE;;AAEF;EACE;;AG9DF;EHgEI,OF9DE;;AKFN;EHoEI,OFhEU;;AEkEd;EACE;EACA;EACA;;;
|
|
1
|
+
{"version":3,"sourceRoot":"","sources":["generics/flip.sass","components/wrapper.sass","../../../../node_modules/iglu/iglu/font-size/tools/_sizing.sass","../../../../node_modules/iglu/iglu/spacing/tools/_margin.sass","settings/_colors.sass","components/checklist.sass","components/date.sass","tools/_mandatory.sass","../../../../node_modules/iglu/iglu/spacing/tools/_padding_immediate.sass","tools/_link.sass","tools/_terminal.sass","components/search.sass","tools/_cloud.sass","components/stepper.sass","components/time.sass","components/toggle.sass","utilities/container.sass","scopes/form.sass","tools/_theme.sass"],"names":[],"mappings":"AAAA;EACE;IACE;;;ACGJ;EACE;EACA;EACA;EACA;;ACGD;EACE;;ACiED;EACE;;AFlEF;EACE;;AAGA;EACE;;AADF;EACE;;AADF;EACE;;AADF;EACE;;AADF;EACE;;AAEJ;EAEE;;AAEF;EACE,OGpBY;;AHsBZ;EACE;EACA;EACA;EACA;EACA;EACA;;AAEJ;EACE;;AE8BF;EACE;;AD/DH;EACE;;ADmCD;EACE;EACA;EACA;;;AAEJ;EACE;IACE;IACA;IACA;IACA;;;AIlDJ;EACE;EACA;;;ACQA;EACE;EACA;EACA;;AH6DF;EACE;;AIxEF;EACE,mBHCY;EGAZ;;ADeF;EACE;EAEA;EACA;EACA;EACA;;AACA;EACE;;AAEJ;EACE;EACA;EACA;EACA;EACA;EACA;;AHIF;EACE;;AKnCF;EACE;;ACNF;EHuCI,OFtCE;;AFcP;EACE;;AMHD;EACE;;ANHH;EACE;;AQZD;EACE;;AJgDF;EACE;;AJ/CH;EACE;;AIiDD;EACE;;AItDF;EACE;;AREH;EACE;;AIsDD;EACE;;AAEF;EACE;;AG9DF;EHgEI,OF9DE;;AKFN;EHoEI,OFhEU;;AEkEd;EACE;EACA;EACA;;;AK1EJ;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAEJ;EAGE;EACA;;AAEF;EACE;;AAEF;EACE;;;AJrBF;EACE,mBHCY;EGAZ;;AKJF;EAGE;EACA;EACA;;AAEF;EACE;;AAEF;EACE;;AAEF;EACE;EACA;;;ACXJ;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AXIH;EACE;;AWFD;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AXhBH;EACE;;ACyDD;EACE;;AUvCF;EACE,OT3BU;;;AMTZ;EACE;;AEDF;EAGE;EACA;EACA;;AAEF;EACE;;AAEF;EACE;;AAEF;EACE;EACA;;ALbF;EACE,mBHCY;EGAZ;;;AKJF;EAGE;EACA;EACA;;AAEF;EACE;;AAEF;EACE;;AAEF;EACE;EACA;;ALbF;EACE,mBHCY;EGAZ;;;AOCF;EACE;EACA;EACA;;AXiEF;EACE;;AIxEF;EACE,mBHCY;EGAZ;;;AQFJ;EAGE;EACA;EACA;;AbID;EACE;;AaFD;EACE;EAGA;EACA;;AAEA;EACE;;AbXL;EACE;;AaaD;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,YXrBU;;AWuBV;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,kBXvCE;EWwCF;;AAGJ;EACE;EACA;;AAGA;EACE,kBX7CO;;AW+CP;EACE,kBXlDQ;;AWqDZ;EACE;EACA,kBXzDE;;AW4DJ;EACE;;AACF;EACE;;AAGF;EACE;;AACF;EACE;;AAGJ;EACE;EACA;EACA;EACA;;AAGF;AAAA;EAEE;EACA;EACA,OX7EW;;AFLd;AAAA;EACE;;AasFD;EACE;EACA;EACA;;;AC9FJ;EACE;;;ACKA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EASE;EACA;EACA;EACA;EACA;EACA,ebdU;EaeV;EACA;EACA;EACA;EACA;EACA;EACA;;AffH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EACE;;AKVD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EACE,mBHCY;EGAZ;;AU4BF;EACE;;AAEF;EACE;EACA;EACA;EACA;EACA;;AAEF;EACE;;;AC3CF;ED+CE;IACE","file":"formatic.css"}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
@use "formatic/components/wrapper.sass" // Wrapper comes first, rest alphabetically
|
|
6
6
|
@use "formatic/components/checklist.sass"
|
|
7
7
|
@use "formatic/components/date.sass"
|
|
8
|
+
@use "formatic/components/search.sass"
|
|
8
9
|
@use "formatic/components/select.sass"
|
|
9
10
|
@use "formatic/components/stepper.sass"
|
|
10
11
|
@use "formatic/components/string.sass"
|
|
@@ -49,18 +49,22 @@ module Formatic
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
51
|
def validate_attribute_name!
|
|
52
|
+
return unless validate_attribute_name?
|
|
53
|
+
return if f.object.respond_to?(attribute_name)
|
|
54
|
+
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"attribute `#{attribute_name}` does not exist on #{f.object.class}."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_attribute_name?
|
|
52
60
|
# A manually passed value means `attribute_name` is just a param name,
|
|
53
61
|
# not necessarily a method on the model.
|
|
54
|
-
return if manual_value != :_fetch_from_record
|
|
55
|
-
return if f.nil? || f.object.nil?
|
|
62
|
+
return false if manual_value != :_fetch_from_record
|
|
63
|
+
return false if f.nil? || f.object.nil?
|
|
56
64
|
|
|
57
65
|
# Only enforce this for real ActiveModel/ActiveRecord records.
|
|
58
66
|
# Custom objects (e.g. slugs) are not expected to respond to it.
|
|
59
|
-
|
|
60
|
-
return if f.object.respond_to?(attribute_name)
|
|
61
|
-
|
|
62
|
-
raise ArgumentError,
|
|
63
|
-
"attribute `#{attribute_name}` does not exist on #{f.object.class}."
|
|
67
|
+
f.object.respond_to?(:model_name)
|
|
64
68
|
end
|
|
65
69
|
|
|
66
70
|
def attachments
|
|
@@ -77,19 +81,22 @@ module Formatic
|
|
|
77
81
|
# Filepond doesn't work well with invalid form submits anyway.
|
|
78
82
|
return if multiple
|
|
79
83
|
|
|
80
|
-
attachments.map
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
attachments.map { |attachment| entry_json(attachment) }.to_json
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def entry_json(attachment)
|
|
88
|
+
{
|
|
89
|
+
source: attachment.signed_id,
|
|
90
|
+
options: { type: 'local', file: file_json(attachment) }
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def file_json(attachment)
|
|
95
|
+
{
|
|
96
|
+
name: attachment.filename,
|
|
97
|
+
size: attachment.byte_size,
|
|
98
|
+
type: attachment.content_type
|
|
99
|
+
}
|
|
93
100
|
end
|
|
94
101
|
|
|
95
102
|
# The currently attached file, if there is exactly one.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formatic
|
|
4
|
+
class Search
|
|
5
|
+
# One row in the search results.
|
|
6
|
+
class Choice < ::Formatic::ApplicationComponent
|
|
7
|
+
option :record_id
|
|
8
|
+
|
|
9
|
+
erb_template <<~ERB
|
|
10
|
+
<div class="js-formatic-search__choice" data-record-id="<%= record_id %>"><%= content %></div>
|
|
11
|
+
ERB
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formatic
|
|
4
|
+
class Search
|
|
5
|
+
# The control that clears the selected record.
|
|
6
|
+
class Clear < ::Formatic::ApplicationComponent
|
|
7
|
+
erb_template <<~ERB
|
|
8
|
+
<a href="#" class="c-formatic-search__clear js-formatic-search__clear" title="Remove"></a>
|
|
9
|
+
ERB
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formatic
|
|
4
|
+
class Search
|
|
5
|
+
# Builds one search JSON record from the app partials.
|
|
6
|
+
class Frontend
|
|
7
|
+
def self.json(id:, choice:, selected:)
|
|
8
|
+
{
|
|
9
|
+
id: id,
|
|
10
|
+
choice: render_html(::Formatic::Search::Choice.new(record_id: id).with_content(choice)),
|
|
11
|
+
selected: render_html(::Formatic::Search::Selected.new.with_content(selected))
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.render_html(component)
|
|
16
|
+
ApplicationController.render(component)
|
|
17
|
+
end
|
|
18
|
+
private_class_method :render_html
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Formatic
|
|
4
|
+
class Search
|
|
5
|
+
# The record that is currently chosen.
|
|
6
|
+
class Selected < ::Formatic::ApplicationComponent
|
|
7
|
+
option :readonly, default: -> { false }
|
|
8
|
+
|
|
9
|
+
erb_template <<~ERB
|
|
10
|
+
<div class="c-formatic-search__selection">
|
|
11
|
+
<div class="c-formatic-search__selected-record"><%= content %></div>
|
|
12
|
+
<% unless readonly %>
|
|
13
|
+
<div class="c-formatic-search__actions"><%= render ::Formatic::Search::Clear.new %></div>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
ERB
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'formatic/templates/search'
|
|
4
|
+
|
|
5
|
+
module Formatic
|
|
6
|
+
# A search field backed by a JSON endpoint.
|
|
7
|
+
class Search < ::Formatic::Base
|
|
8
|
+
option :url
|
|
9
|
+
option :record_name, as: :manual_record_name, optional: true
|
|
10
|
+
option :exclude_ids, default: -> { [] }
|
|
11
|
+
option :placeholder, as: :manual_placeholder, optional: true
|
|
12
|
+
|
|
13
|
+
renders_one :selected_record
|
|
14
|
+
renders_one :add_record
|
|
15
|
+
|
|
16
|
+
erb_template(::Formatic::Templates::Search.call)
|
|
17
|
+
|
|
18
|
+
def record_name
|
|
19
|
+
manual_record_name || f.object.model_name.param_key
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def query_input_id
|
|
23
|
+
f.field_id(attribute_name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def search_placeholder
|
|
27
|
+
manual_placeholder.presence || wrapper.placeholder
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/config/importmap.rb
CHANGED
|
@@ -5,6 +5,9 @@ pin 'formatic', to: 'formatic/setup.js'
|
|
|
5
5
|
pin 'formatic/date', to: 'formatic/date.js'
|
|
6
6
|
pin 'formatic/file', to: 'formatic/file.js'
|
|
7
7
|
pin 'formatic/select', to: 'formatic/select.js'
|
|
8
|
+
pin 'formatic/search', to: 'formatic/search.js'
|
|
9
|
+
pin 'formatic/search_record', to: 'formatic/search_record.js'
|
|
10
|
+
pin 'formatic/search_requests', to: 'formatic/search_requests.js'
|
|
8
11
|
pin 'formatic/stepper', to: 'formatic/stepper.js'
|
|
9
12
|
pin 'formatic/string', to: 'formatic/string.js'
|
|
10
13
|
pin 'formatic/textarea', to: 'formatic/textarea.js'
|
data/lib/formatic/engine.rb
CHANGED
|
@@ -53,6 +53,11 @@ module Formatic
|
|
|
53
53
|
require_relative '../../app/components/formatic/checklist'
|
|
54
54
|
require_relative '../../app/components/formatic/date'
|
|
55
55
|
require_relative '../../app/components/formatic/select'
|
|
56
|
+
require_relative '../../app/components/formatic/search'
|
|
57
|
+
require_relative '../../app/components/formatic/search/clear'
|
|
58
|
+
require_relative '../../app/components/formatic/search/choice'
|
|
59
|
+
require_relative '../../app/components/formatic/search/selected'
|
|
60
|
+
require_relative '../../app/components/formatic/search/frontend'
|
|
56
61
|
require_relative '../../app/components/formatic/string'
|
|
57
62
|
require_relative '../../app/components/formatic/stepper'
|
|
58
63
|
require_relative '../../app/components/formatic/textarea'
|