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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +25 -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/date.rb +2 -1
- 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 +94 -0
- data/lib/formatic/version.rb +1 -1
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1c52f37c072f5cbebbfa6718393edae33f687911b3e426db8a97a3cd6b5a0c72
|
|
4
|
+
data.tar.gz: bea3f5680a56ba9b447e7fa925eb60aaa809a756d0c1b9d8d7b573b967752f03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,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;;;
|
|
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
|
-
|
|
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'
|
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'
|
|
@@ -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
|
data/lib/formatic/version.rb
CHANGED
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
|
+
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
|