formatic 0.4.3 → 0.5.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +25 -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/date.rb +2 -1
  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 +94 -0
  31. data/lib/formatic/version.rb +1 -1
  32. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 142e8c45bd25c9b20ec5287736872e8974af37ae08fff7b202dbe7968c07cd08
4
- data.tar.gz: 221b88ebd0b0b08e73afcec6fb061dfa69267556ffa7d4ad383b76beea528edd
3
+ metadata.gz: 1c52f37c072f5cbebbfa6718393edae33f687911b3e426db8a97a3cd6b5a0c72
4
+ data.tar.gz: bea3f5680a56ba9b447e7fa925eb60aaa809a756d0c1b9d8d7b573b967752f03
5
5
  SHA512:
6
- metadata.gz: 8164cb6a8d6f0effb577340530e0c3d52331417cd8e63395a1a9593dd1fd09be1fa936a41898ab4d57b8119e8f7e6e37d630e0093cc87c7868f1e82285a03d02
7
- data.tar.gz: 33be02040dcb62fc98e62d6c4f94ad923d7b7a661f92656900f7fc58d81e27b8552c492d37c6eab36ae03c0da43b479ed4d182b5b3138656a1ea3f4a587e7b8b
6
+ metadata.gz: f86e74b0b5a1ca07c73d265672b6770ee543f1879e098498cc6a7cb64bf8d9b26972f5120b6c5fb0299242991640a47c671b94c3e002ab3addfb911cbaae3abe
7
+ data.tar.gz: 4dd6d6b3102c55177e11ca879f0ffab0edce31cf7595914ffa0802746c6157da138833f138fcd8dca5ed793139998d8f1393588f7bd099a030123131815004c8
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [0.5.0] 2026-10-26
2
+
3
+ - Add `Formatic::Search`, a JSON-backed search field
4
+ - `Formatic::Search::Frontend.json` wraps the choice and selected partials
5
+ - The selected card includes the clear control. A readonly field omits the query and the clear control
6
+
7
+ ## [0.4.4] 2026-09-18
8
+
9
+ - Restrict year select to current year and later when `skip_past` is true
10
+
1
11
  ## [0.4.3] 2026-09-17
2
12
 
3
13
  - Add `skip_past` option to `Formatic::Date` to start calendar at today instead of 5 days ago
data/README.md CHANGED
@@ -9,6 +9,31 @@ Opinionated ViewComponents for building forms.
9
9
  = render Formatic::String.new(f:, attribute_name: :first_name)
10
10
  ```
11
11
 
12
+ ### Search fields
13
+
14
+ `Formatic::Search` renders a hidden value input and a query input for an endpoint
15
+ that returns JSON in the form `{ records: [...] }`. Each record has an `id`, a
16
+ `label`, `html_for_autocomplete`, and `html_for_input` properties. The
17
+ `html_for_autocomplete` markup must include one `.js-formatic-search__choice`
18
+ element per record, in the same order as `records`. `html_for_input` provides
19
+ the selected-value markup. Search waits 400 ms after the last input. A newer
20
+ query aborts the previous request for that field, and a response is shown only
21
+ when its query still matches the input. An optional `with_clear` slot clears
22
+ the current selection.
23
+ An optional `with_add_record` slot stays hidden while searching and appears
24
+ after a successful response, even when there are no matches.
25
+
26
+ ```ruby
27
+ = render Formatic::Search.new(f:, attribute_name: :commander_id,
28
+ record_name: 'commander',
29
+ url: admin_commanders_path(format: :json))
30
+ ```
31
+
32
+ For Rack tests, include `Formatic::Testing::Capybara::Search` in a
33
+ `Capybara::RackTest` test class. `select_record_from_search 'Bob', from: 'Users'`
34
+ reads the field's URL and parameter names, requests matching JSON directly, and
35
+ fills the hidden input with a result returned by that endpoint.
36
+
12
37
  ## Installation
13
38
 
14
39
  Install the gem and add to the application's Gemfile by executing:
@@ -0,0 +1,39 @@
1
+ export declare class Search {
2
+ private el;
3
+ private requests;
4
+ private choiceRecords;
5
+ private timeoutId;
6
+ constructor(el: HTMLElement);
7
+ private setupBindings;
8
+ private clicked;
9
+ private debounce;
10
+ private actionSearch;
11
+ private actionSearchNow;
12
+ private actionChoose;
13
+ private actionClear;
14
+ private guiWaitingForQuery;
15
+ private guiSearchingRecords;
16
+ private guiFailed;
17
+ private guiPopulateRecordChoices;
18
+ private guiChooseRecord;
19
+ private guiClear;
20
+ private guiMayAddNew;
21
+ private guiMayNotAddNew;
22
+ private notifyValueChanged;
23
+ private choicesFor;
24
+ private get searchParams();
25
+ private get resourceUrl();
26
+ private get recordName();
27
+ private get attributeName();
28
+ private get excludeIds();
29
+ private get resourceInput();
30
+ private get recordNameInput();
31
+ private get attributeNameInput();
32
+ private get excludeIdsInput();
33
+ private get queryInput();
34
+ private get valueInput();
35
+ private get selectedContainer();
36
+ private get resultsContainer();
37
+ private get choices();
38
+ private get addRecordContainer();
39
+ }
@@ -0,0 +1,9 @@
1
+ export declare class SearchRecord {
2
+ id: string | number;
3
+ choice: string;
4
+ selected: string;
5
+ constructor(attributes: Partial<SearchRecord>);
6
+ get recordId(): string;
7
+ get renderedChoice(): string;
8
+ get renderedSelected(): string;
9
+ }
@@ -0,0 +1,13 @@
1
+ export declare class SearchRequests {
2
+ private previousController;
3
+ private currentController;
4
+ search(resource: string, searchterm: string, params: {
5
+ [key: string]: string;
6
+ }): Promise<any>;
7
+ abort(): void;
8
+ private perform;
9
+ private abortPreviousRequests;
10
+ private parseResponse;
11
+ private fail;
12
+ private get requestOptions();
13
+ }
@@ -0,0 +1,188 @@
1
+ import { SearchRecord } from 'formatic/search_record';
2
+ import { SearchRequests } from 'formatic/search_requests';
3
+ export class Search {
4
+ constructor(el) {
5
+ this.el = el;
6
+ this.requests = new SearchRequests;
7
+ this.choiceRecords = new Map();
8
+ this.setupBindings();
9
+ }
10
+ setupBindings() {
11
+ if (!this.queryInput)
12
+ return;
13
+ this.resultsContainer.addEventListener('click', (event) => {
14
+ const choice = this.clicked('.js-formatic-search__choice', event);
15
+ if (!choice)
16
+ return;
17
+ event.preventDefault();
18
+ this.actionChoose(choice);
19
+ });
20
+ this.el.addEventListener('click', (event) => {
21
+ const clear = this.clicked('.js-formatic-search__clear', event);
22
+ if (!clear)
23
+ return;
24
+ event.preventDefault();
25
+ this.actionClear();
26
+ });
27
+ this.queryInput.addEventListener('input', (event) => {
28
+ event.preventDefault();
29
+ this.actionSearch();
30
+ });
31
+ }
32
+ clicked(selector, event) {
33
+ const target = event.target;
34
+ const receiver = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
35
+ const match = receiver?.closest(selector);
36
+ if (!match || !this.el.contains(match))
37
+ return null;
38
+ return match;
39
+ }
40
+ debounce(fn) {
41
+ return () => {
42
+ clearTimeout(this.timeoutId);
43
+ this.timeoutId = setTimeout(() => fn(), 400);
44
+ };
45
+ }
46
+ actionSearch() {
47
+ this.guiWaitingForQuery();
48
+ this.debounce(this.actionSearchNow.bind(this))();
49
+ }
50
+ actionSearchNow() {
51
+ if (this.queryInput.value.length == 0) {
52
+ this.resultsContainer.innerHTML = '';
53
+ return;
54
+ }
55
+ this.guiSearchingRecords();
56
+ this.requests.search(this.resourceUrl, this.queryInput.value, this.searchParams)
57
+ .then(result => {
58
+ if (result?.query != this.queryInput.value)
59
+ return;
60
+ return result.data.records.map((attributes) => new SearchRecord(attributes));
61
+ }).then(records => {
62
+ if (records)
63
+ this.guiPopulateRecordChoices(records);
64
+ }).catch(err => {
65
+ console.warn(`Finding records failed: ${err}`);
66
+ this.guiFailed();
67
+ });
68
+ }
69
+ actionChoose(choice) {
70
+ clearTimeout(this.timeoutId);
71
+ this.requests.abort();
72
+ const record = this.choiceRecords.get(choice);
73
+ if (!record)
74
+ throw new Error('No search result for the chosen element');
75
+ this.guiChooseRecord(record);
76
+ }
77
+ actionClear() {
78
+ clearTimeout(this.timeoutId);
79
+ this.requests.abort();
80
+ this.guiClear();
81
+ }
82
+ guiWaitingForQuery() {
83
+ this.resultsContainer.textContent = 'Waiting for input...';
84
+ this.guiMayNotAddNew();
85
+ }
86
+ guiSearchingRecords() {
87
+ this.resultsContainer.textContent = 'Searching...';
88
+ this.guiMayNotAddNew();
89
+ }
90
+ guiFailed() {
91
+ this.choiceRecords.clear();
92
+ this.resultsContainer.textContent = 'Search failed.';
93
+ this.guiMayNotAddNew();
94
+ }
95
+ guiPopulateRecordChoices(records) {
96
+ this.resultsContainer.innerHTML = records.map((record) => record.renderedChoice).join('');
97
+ this.choiceRecords = this.choicesFor(records);
98
+ if (records.length == 0)
99
+ this.resultsContainer.textContent = 'No records found.';
100
+ this.guiMayAddNew();
101
+ }
102
+ guiChooseRecord(record) {
103
+ this.valueInput.value = record.recordId;
104
+ this.selectedContainer.innerHTML = record.renderedSelected;
105
+ this.resultsContainer.innerHTML = '';
106
+ this.queryInput.value = '';
107
+ this.choiceRecords.clear();
108
+ this.guiMayNotAddNew();
109
+ this.notifyValueChanged();
110
+ }
111
+ guiClear() {
112
+ this.valueInput.value = '';
113
+ this.selectedContainer.innerHTML = '';
114
+ this.resultsContainer.innerHTML = '';
115
+ this.queryInput.value = '';
116
+ this.choiceRecords.clear();
117
+ this.guiMayNotAddNew();
118
+ this.notifyValueChanged();
119
+ }
120
+ guiMayAddNew() {
121
+ this.addRecordContainer.hidden = false;
122
+ }
123
+ guiMayNotAddNew() {
124
+ this.addRecordContainer.hidden = true;
125
+ }
126
+ notifyValueChanged() {
127
+ this.valueInput.dispatchEvent(new Event('change', { bubbles: true }));
128
+ }
129
+ choicesFor(records) {
130
+ const choices = Array.from(this.choices);
131
+ if (choices.length != records.length) {
132
+ throw new Error(`Expected ${records.length} .js-formatic-search__choice elements, found ${choices.length}`);
133
+ }
134
+ return new Map(choices.map((choice, index) => [choice, records[index]]));
135
+ }
136
+ get searchParams() {
137
+ const params = {
138
+ record_name: this.recordName,
139
+ attribute_name: this.attributeName,
140
+ };
141
+ if (this.excludeIds)
142
+ params.exclude_ids = this.excludeIds;
143
+ return params;
144
+ }
145
+ get resourceUrl() {
146
+ return this.resourceInput.value;
147
+ }
148
+ get recordName() {
149
+ return this.recordNameInput.value;
150
+ }
151
+ get attributeName() {
152
+ return this.attributeNameInput.value;
153
+ }
154
+ get excludeIds() {
155
+ return this.excludeIdsInput.value;
156
+ }
157
+ get resourceInput() {
158
+ return this.el.querySelector('.js-formatic-search__url');
159
+ }
160
+ get recordNameInput() {
161
+ return this.el.querySelector('.js-formatic-search__record-name');
162
+ }
163
+ get attributeNameInput() {
164
+ return this.el.querySelector('.js-formatic-search__attribute-name');
165
+ }
166
+ get excludeIdsInput() {
167
+ return this.el.querySelector('.js-formatic-search__exclude-ids');
168
+ }
169
+ get queryInput() {
170
+ return this.el.querySelector('.js-formatic-search__query-input');
171
+ }
172
+ get valueInput() {
173
+ return this.el.querySelector('.js-formatic-search__value');
174
+ }
175
+ get selectedContainer() {
176
+ return this.el.querySelector('.js-formatic-search__selected');
177
+ }
178
+ get resultsContainer() {
179
+ return this.el.querySelector('.js-formatic-search__results');
180
+ }
181
+ get choices() {
182
+ return this.resultsContainer.querySelectorAll('.js-formatic-search__choice');
183
+ }
184
+ get addRecordContainer() {
185
+ return this.el.querySelector('.js-formatic-search__add-record');
186
+ }
187
+ }
188
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.js","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ export class SearchRecord {
2
+ constructor(attributes) {
3
+ Object.assign(this, attributes);
4
+ }
5
+ get recordId() {
6
+ if (this.id == null || this.id == '')
7
+ throw new Error('Search record is missing id');
8
+ return String(this.id);
9
+ }
10
+ get renderedChoice() {
11
+ if (!this.choice)
12
+ throw new Error('Search record is missing choice');
13
+ return this.choice;
14
+ }
15
+ get renderedSelected() {
16
+ if (!this.selected)
17
+ throw new Error('Search record is missing selected');
18
+ return this.selected;
19
+ }
20
+ }
21
+ //# sourceMappingURL=search_record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search_record.js","sourceRoot":"","sources":["../src/search_record.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,YAAY;IAKvB,YAAY,UAAiC;QAC3C,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,CAAA;IACjC,CAAC;IAED,IAAW,QAAQ;QACjB,IAAI,IAAI,CAAC,EAAE,IAAI,IAAI,IAAI,IAAI,CAAC,EAAE,IAAI,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAA;QACpF,OAAO,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IACxB,CAAC;IAED,IAAW,cAAc;QACvB,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAA;QACpE,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;IAED,IAAW,gBAAgB;QACzB,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAA;QACxE,OAAO,IAAI,CAAC,QAAQ,CAAA;IACtB,CAAC;CACF"}
@@ -0,0 +1,72 @@
1
+ export class SearchRequests {
2
+ search(resource, searchterm, params) {
3
+ const options = new Options;
4
+ options.resource = resource;
5
+ options.params = { ...params };
6
+ options.params.query = searchterm;
7
+ return this.perform(options);
8
+ }
9
+ abort() {
10
+ if (this.previousController)
11
+ this.previousController.abort();
12
+ }
13
+ perform(options) {
14
+ this.abortPreviousRequests();
15
+ return new Promise((resolve, reject) => {
16
+ if (options.params.query == '')
17
+ return resolve(null);
18
+ console.debug(`SearchRequests GET ${options.url}`);
19
+ fetch(options.url, this.requestOptions)
20
+ .then(response => this.parseResponse(response))
21
+ .then(data => {
22
+ resolve(new Result({ data: data, options: options, query: options.params.query }));
23
+ }).catch(err => {
24
+ this.fail(err, options, reject);
25
+ });
26
+ });
27
+ }
28
+ abortPreviousRequests() {
29
+ this.abort();
30
+ this.currentController = new AbortController();
31
+ this.previousController = this.currentController;
32
+ }
33
+ parseResponse(response) {
34
+ if (!response.ok)
35
+ throw Error(response.statusText);
36
+ return response.json();
37
+ }
38
+ fail(err, options, reject) {
39
+ if (err.name == 'AbortError') {
40
+ console.debug(`Cancelled GET ${options.url}`);
41
+ return;
42
+ }
43
+ console.debug(`Failed to GET ${options.url}`);
44
+ reject(err);
45
+ }
46
+ get requestOptions() {
47
+ return {
48
+ method: 'GET',
49
+ headers: {
50
+ 'Accept': 'application/json',
51
+ 'Content-Type': 'application/json',
52
+ },
53
+ signal: this.currentController.signal,
54
+ };
55
+ }
56
+ }
57
+ class Options {
58
+ get url() {
59
+ const requestParams = new URLSearchParams();
60
+ for (const [key, value] of Object.entries(this.params)) {
61
+ requestParams.set(key, value);
62
+ }
63
+ const separator = this.resource.includes('?') ? '&' : '?';
64
+ return this.resource + separator + requestParams;
65
+ }
66
+ }
67
+ class Result {
68
+ constructor(attributes) {
69
+ Object.assign(this, attributes);
70
+ }
71
+ }
72
+ //# sourceMappingURL=search_requests.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search_requests.js","sourceRoot":"","sources":["../src/search_requests.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  import { Date } from 'formatic/date'
2
2
  import { Select } from 'formatic/select'
3
+ import { Search } from 'formatic/search'
3
4
  import { Stepper } from 'formatic/stepper'
4
5
  import { String } from 'formatic/string'
5
6
  import { Textarea } from 'formatic/textarea'
@@ -7,6 +8,11 @@ import { Toggle } from 'formatic/toggle'
7
8
  import { FormaticFile } from 'formatic/file'
8
9
 
9
10
  document.addEventListener('DOMContentLoaded', () => {
11
+ document.querySelectorAll('.js-formatic-search').forEach((el) => {
12
+ console.debug('[Formatic] Instantiating Search...')
13
+ new Search(el)
14
+ })
15
+
10
16
  document.querySelectorAll('.js-formatic-file').forEach((el) => {
11
17
  console.debug('[Formatic] Instantiating File...')
12
18
  new FormaticFile(el)
@@ -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"
@@ -111,7 +111,8 @@ module Formatic
111
111
  end
112
112
 
113
113
  def collection_for_year
114
- result = (30.years.ago.year..10.years.from_now.year)
114
+ lower = skip_past ? ::Time.current.year : 30.years.ago.year
115
+ result = (lower..10.years.from_now.year)
115
116
  return result if wrapper.required?
116
117
 
117
118
  result.to_a.prepend nil
@@ -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'
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formatic
4
+ module Templates
5
+ # Holds the ERB template for this component.
6
+ module Search
7
+ TEMPLATE = <<~ERB
8
+ <%= render wrapper do |formatic_wrapper| %>
9
+ <% formatic_wrapper.with_input do %>
10
+ <div class="c-formatic-search s-formatic js-formatic-search">
11
+ <input type="hidden" class="js-formatic-search__url" value="<%= url %>">
12
+ <input type="hidden" class="js-formatic-search__record-name" value="<%= record_name %>">
13
+ <input type="hidden" class="js-formatic-search__attribute-name" value="<%= attribute_name %>">
14
+ <input type="hidden" class="js-formatic-search__exclude-ids" value="<%= exclude_ids.join(',') %>">
15
+
16
+ <%= f.hidden_field attribute_name,
17
+ id: nil,
18
+ value:,
19
+ class: 'js-formatic-search__value' %>
20
+
21
+ <div class="c-formatic-search__selected js-formatic-search__selected">
22
+ <% if selected_record? %>
23
+ <%= render ::Formatic::Search::Selected.new(readonly: readonly).with_content(selected_record) %>
24
+ <% end %>
25
+ </div>
26
+
27
+ <% unless readonly %>
28
+ <div class="c-formatic-search__query">
29
+ <%= helpers.tag.input type: 'search',
30
+ id: query_input_id,
31
+ placeholder: search_placeholder,
32
+ autocomplete: 'off',
33
+ class: 'js-formatic-search__query-input' %>
34
+ </div>
35
+
36
+ <div class="c-formatic-search__results js-formatic-search__results"></div>
37
+ <div class="c-formatic-search__add-record js-formatic-search__add-record" hidden><%= add_record %></div>
38
+ <% end %>
39
+ </div>
40
+ <% end %>
41
+ <% end %>
42
+ ERB
43
+
44
+ private_constant :TEMPLATE
45
+
46
+ def self.call
47
+ TEMPLATE
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'calls'
5
+
6
+ module Formatic
7
+ module Testing
8
+ module Capybara
9
+ module Search
10
+ def select_record_from_search(query, from:)
11
+ SearchSelector.call(page: page, query:, from:)
12
+ end
13
+
14
+ class SearchSelector
15
+ include Calls
16
+
17
+ class SelectionError < ::StandardError; end
18
+
19
+ option :page
20
+ option :query
21
+ option :from
22
+
23
+ def call
24
+ ensure_rack_test_driver!
25
+ query_field = page.find_field(from)
26
+ component = search_component(query_field)
27
+ record = matching_record(search_records(component), query)
28
+
29
+ raise SelectionError, "No result matching #{query.inspect} from #{from.inspect}" unless record
30
+
31
+ apply_selection(component, query_field, record)
32
+ record
33
+ end
34
+
35
+ private
36
+
37
+ def ensure_rack_test_driver!
38
+ require 'capybara/rack_test/driver'
39
+ return if page.driver.is_a?(::Capybara::RackTest::Driver)
40
+
41
+ raise ArgumentError, 'select_record_from_search requires Capybara RackTest'
42
+ end
43
+
44
+ def search_component(query_field)
45
+ query_field.find(:xpath, 'ancestor::div[contains(@class, "js-formatic-search")][1]')
46
+ end
47
+
48
+ def search_records(component)
49
+ response = search_request(configured_value(component, 'resource'), search_params(component))
50
+
51
+ raise SelectionError, "Search for #{from.inspect} returned HTTP #{response.status}" unless response.ok?
52
+
53
+ JSON.parse(response.body).fetch('records')
54
+ end
55
+
56
+ def search_params(component)
57
+ {
58
+ query: query,
59
+ record_name: configured_value(component, 'record-name'),
60
+ attribute_name: configured_value(component, 'attribute-name'),
61
+ exclude_ids: configured_value(component, 'exclude-ids')
62
+ }
63
+ end
64
+
65
+ def configured_value(component, class_suffix)
66
+ component.find(".js-formatic-search__#{class_suffix}", visible: :all).value
67
+ end
68
+
69
+ def search_request(url, params)
70
+ browser = page.driver.browser
71
+ browser.current_session.restore_state do
72
+ browser.get(url, params, 'HTTP_ACCEPT' => 'application/json')
73
+ end
74
+ end
75
+
76
+ def matching_record(records, query)
77
+ records.find do |record|
78
+ text = record.fetch('choice').gsub(/<[^>]*>/, ' ')
79
+ text.downcase.include?(query.downcase)
80
+ end
81
+ end
82
+
83
+ def apply_selection(component, query_field, record)
84
+ component.find('.js-formatic-search__value', visible: :all).set(record.fetch('id').to_s)
85
+ selected = component.find('.js-formatic-search__selected', visible: :all)
86
+ selected.native.inner_html = record.fetch('selected')
87
+ component.find('.js-formatic-search__results', visible: :all).native.inner_html = ''
88
+ query_field.set('')
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Formatic
4
- VERSION = '0.4.3'
4
+ VERSION = '0.5.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: formatic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - halo
@@ -119,6 +119,9 @@ files:
119
119
  - README.md
120
120
  - app/assets/javascript/declarations/date.d.ts
121
121
  - app/assets/javascript/declarations/file.d.ts
122
+ - app/assets/javascript/declarations/search.d.ts
123
+ - app/assets/javascript/declarations/search_record.d.ts
124
+ - app/assets/javascript/declarations/search_requests.d.ts
122
125
  - app/assets/javascript/declarations/select.d.ts
123
126
  - app/assets/javascript/declarations/setup.d.ts
124
127
  - app/assets/javascript/declarations/stepper.d.ts
@@ -129,6 +132,12 @@ files:
129
132
  - app/assets/javascript/formatic/date.js.map
130
133
  - app/assets/javascript/formatic/file.js
131
134
  - app/assets/javascript/formatic/file.js.map
135
+ - app/assets/javascript/formatic/search.js
136
+ - app/assets/javascript/formatic/search.js.map
137
+ - app/assets/javascript/formatic/search_record.js
138
+ - app/assets/javascript/formatic/search_record.js.map
139
+ - app/assets/javascript/formatic/search_requests.js
140
+ - app/assets/javascript/formatic/search_requests.js.map
132
141
  - app/assets/javascript/formatic/select.js
133
142
  - app/assets/javascript/formatic/select.js.map
134
143
  - app/assets/javascript/formatic/setup.js
@@ -142,6 +151,9 @@ files:
142
151
  - app/assets/javascript/formatic/toggle.js.map
143
152
  - app/assets/javascript/src/date.ts
144
153
  - app/assets/javascript/src/file.ts
154
+ - app/assets/javascript/src/search.ts
155
+ - app/assets/javascript/src/search_record.ts
156
+ - app/assets/javascript/src/search_requests.ts
145
157
  - app/assets/javascript/src/select.ts
146
158
  - app/assets/javascript/src/stepper.ts
147
159
  - app/assets/javascript/src/string.ts
@@ -150,6 +162,7 @@ files:
150
162
  - app/assets/stylesheets/formatic.sass
151
163
  - app/assets/stylesheets/formatic/components/checklist.sass
152
164
  - app/assets/stylesheets/formatic/components/date.sass
165
+ - app/assets/stylesheets/formatic/components/search.sass
153
166
  - app/assets/stylesheets/formatic/components/select.sass
154
167
  - app/assets/stylesheets/formatic/components/stepper.sass
155
168
  - app/assets/stylesheets/formatic/components/string.sass
@@ -176,6 +189,11 @@ files:
176
189
  - app/components/formatic/date.rb
177
190
  - app/components/formatic/file.rb
178
191
  - app/components/formatic/files.rb
192
+ - app/components/formatic/search.rb
193
+ - app/components/formatic/search/choice.rb
194
+ - app/components/formatic/search/clear.rb
195
+ - app/components/formatic/search/frontend.rb
196
+ - app/components/formatic/search/selected.rb
179
197
  - app/components/formatic/select.rb
180
198
  - app/components/formatic/stepper.rb
181
199
  - app/components/formatic/string.rb
@@ -196,8 +214,10 @@ files:
196
214
  - lib/formatic/engine.rb
197
215
  - lib/formatic/safe_join.rb
198
216
  - lib/formatic/templates/date.rb
217
+ - lib/formatic/templates/search.rb
199
218
  - lib/formatic/templates/select.rb
200
219
  - lib/formatic/templates/wrapper.rb
220
+ - lib/formatic/testing/capybara.rb
201
221
  - lib/formatic/version.rb
202
222
  - lib/formatic/vscode_snippets.rb
203
223
  - lib/formatic/wrappers/alternative_attribute_name.rb