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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +38 -0
  4. data/app/assets/javascript/declarations/search.d.ts +39 -0
  5. data/app/assets/javascript/declarations/search_record.d.ts +9 -0
  6. data/app/assets/javascript/declarations/search_requests.d.ts +13 -0
  7. data/app/assets/javascript/formatic/search.js +188 -0
  8. data/app/assets/javascript/formatic/search.js.map +1 -0
  9. data/app/assets/javascript/formatic/search_record.js +21 -0
  10. data/app/assets/javascript/formatic/search_record.js.map +1 -0
  11. data/app/assets/javascript/formatic/search_requests.js +72 -0
  12. data/app/assets/javascript/formatic/search_requests.js.map +1 -0
  13. data/app/assets/javascript/formatic/setup.js +6 -0
  14. data/app/assets/javascript/src/search.ts +242 -0
  15. data/app/assets/javascript/src/search_record.ts +24 -0
  16. data/app/assets/javascript/src/search_requests.ts +94 -0
  17. data/app/assets/stylesheets/formatic/components/search.sass +51 -0
  18. data/app/assets/stylesheets/formatic/formatic.css +25 -0
  19. data/app/assets/stylesheets/formatic/formatic.css.map +1 -1
  20. data/app/assets/stylesheets/formatic.sass +1 -0
  21. data/app/components/formatic/file.rb +27 -20
  22. data/app/components/formatic/search/choice.rb +14 -0
  23. data/app/components/formatic/search/clear.rb +12 -0
  24. data/app/components/formatic/search/frontend.rb +21 -0
  25. data/app/components/formatic/search/selected.rb +19 -0
  26. data/app/components/formatic/search.rb +30 -0
  27. data/config/importmap.rb +3 -0
  28. data/lib/formatic/engine.rb +5 -0
  29. data/lib/formatic/templates/search.rb +51 -0
  30. data/lib/formatic/testing/capybara.rb +20 -0
  31. data/lib/formatic/testing/search.rb +87 -0
  32. data/lib/formatic/version.rb +1 -1
  33. 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;;;ACvEF;EACE,mBHCY;EGAZ;;AIJF;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;;AVIH;EACE;;AUFD;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AVhBH;EACE;;ACyDD;EACE;;ASvCF;EACE,OR3BU;;;AMTZ;EACE;;ACDF;EAGE;EACA;EACA;;AAEF;EACE;;AAEF;EACE;;AAEF;EACE;EACA;;AJbF;EACE,mBHCY;EGAZ;;;AIJF;EAGE;EACA;EACA;;AAEF;EACE;;AAEF;EACE;;AAEF;EACE;EACA;;AJbF;EACE,mBHCY;EGAZ;;;AMCF;EACE;EACA;EACA;;AViEF;EACE;;AIxEF;EACE,mBHCY;EGAZ;;;AOFJ;EAGE;EACA;EACA;;AZID;EACE;;AYFD;EACE;EAGA;EACA;;AAEA;EACE;;AZXL;EACE;;AYaD;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,YVrBU;;AUuBV;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,kBVvCE;EUwCF;;AAGJ;EACE;EACA;;AAGA;EACE,kBV7CO;;AU+CP;EACE,kBVlDQ;;AUqDZ;EACE;EACA,kBVzDE;;AU4DJ;EACE;;AACF;EACE;;AAGF;EACE;;AACF;EACE;;AAGJ;EACE;EACA;EACA;EACA;;AAGF;AAAA;EAEE;EACA;EACA,OV7EW;;AFLd;AAAA;EACE;;AYsFD;EACE;EACA;EACA;;;AC9FJ;EACE;;;ACKA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EASE;EACA;EACA;EACA;EACA;EACA,eZdU;EYeV;EACA;EACA;EACA;EACA;EACA;EACA;;AdfH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EACE;;AKVD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EACE,mBHCY;EGAZ;;AS4BF;EACE;;AAEF;EACE;EACA;EACA;EACA;EACA;;AAEF;EACE;;;AC3CF;ED+CE;IACE","file":"formatic.css"}
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
- return unless f.object.respond_to?(:model_name)
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 do |attachment|
81
- {
82
- source: attachment.signed_id,
83
- options: {
84
- type: 'local',
85
- file: {
86
- name: attachment.filename,
87
- size: attachment.byte_size,
88
- type: attachment.content_type
89
- }
90
- }
91
- }
92
- end.to_json
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'
@@ -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'