lucos_search_component 4.0.1 → 4.0.3
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.
- package/.github/workflows/code-reviewer-auto-merge.yml +1 -1
- package/.github/workflows/codeql-analysis.yml +1 -1
- package/.github/workflows/convention-check.yml +1 -1
- package/.github/workflows/dependabot-auto-merge.yml +1 -1
- package/CLAUDE.md +7 -0
- package/README.md +1 -1
- package/dist/index.js +59 -23
- package/example/index.html +21 -0
- package/package.json +1 -1
- package/test/filter.test.mjs +20 -0
- package/web-components/filter.js +8 -4
- package/web-components/lucos-search.js +51 -19
|
@@ -13,7 +13,7 @@ permissions:
|
|
|
13
13
|
|
|
14
14
|
jobs:
|
|
15
15
|
reusable:
|
|
16
|
-
uses: lucas42/.github/.github/workflows/reusable-code-reviewer-auto-merge.yml@v1.
|
|
16
|
+
uses: lucas42/.github/.github/workflows/reusable-code-reviewer-auto-merge.yml@v1.21.0
|
|
17
17
|
secrets:
|
|
18
18
|
LUCOS_CI_APP_ID: ${{ secrets.LUCOS_CI_APP_ID }}
|
|
19
19
|
LUCOS_CI_PRIVATE_KEY: ${{ secrets.LUCOS_CI_PRIVATE_KEY }}
|
|
@@ -9,7 +9,7 @@ permissions:
|
|
|
9
9
|
|
|
10
10
|
jobs:
|
|
11
11
|
dependabot:
|
|
12
|
-
uses: lucas42/.github/.github/workflows/reusable-dependabot-auto-merge.yml@v1.
|
|
12
|
+
uses: lucas42/.github/.github/workflows/reusable-dependabot-auto-merge.yml@v1.21.0
|
|
13
13
|
secrets:
|
|
14
14
|
LUCOS_CI_APP_ID: ${{ secrets.LUCOS_CI_APP_ID }}
|
|
15
15
|
LUCOS_CI_PRIVATE_KEY: ${{ secrets.LUCOS_CI_PRIVATE_KEY }}
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# lucos_search_component
|
|
2
|
+
|
|
3
|
+
## Public interface changes
|
|
4
|
+
|
|
5
|
+
Any change to the public interface of this component (new attributes, modified attribute behaviour, removed attributes) must be accompanied by a new or updated example in `example/index.html`.
|
|
6
|
+
|
|
7
|
+
For **breaking changes** (removing or renaming an attribute, changing the expected format of an existing attribute), also review all existing examples in `example/index.html` to check whether any need updating or removing entirely.
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ The following attributes can be added to the lucos-search span:
|
|
|
32
32
|
* **data-api-key** \[required\] — a valid API key for the production instance of [lucos_arachne](https://github.com/lucas42/lucos_arachne), as generated by [lucos_creds](https://github.com/lucas42/lucos_creds).
|
|
33
33
|
* **data-types** — A comma separated list of item types to search for (defaults to all types).
|
|
34
34
|
* **data-exclude_types** — A comma separated list of item types to exclude from the search (ignored if `data-types` is set).
|
|
35
|
-
* **data-is-contact** — Filter results to contacts only (`"true"`) or non-contacts only (`"false"`). Omitting the attribute applies no filter. Composes with `data-types` when both are set.
|
|
35
|
+
* **data-is-contact** — Filter results to contacts only (`"true"`) or non-contacts only (`"false"`). Omitting the attribute applies no filter. Composes with `data-types` when both are set. When `"true"`, selected options use the entity's **`lucos_contacts` URI** (`contact_uri`) as their value, and the selected-item lozenge links through to that same `lucos_contacts` URI instead of the eolas knowledge page — form value and lozenge link stay consistent with each other.
|
|
36
36
|
* **data-label-override-zxx** — Override the displayed label for the `https://eolas.l42.eu/metadata/language/zxx/` entry in both the dropdown and selected lozenge. Has no effect on whether `zxx` appears in the list — that depends on whether `zxx` is present in the search index. Only meaningful when `data-types="Language"`.
|
|
37
37
|
* **data-common** — A comma separated list of item URIs to pin in a "Common" group at the top of the list, above the normal results. Only meaningful when `data-types="Language"`.
|
|
38
38
|
* **data-preload** — If present, all options are loaded upfront rather than fetched on search. Suitable for small, finite datasets such as languages.
|
package/dist/index.js
CHANGED
|
@@ -5696,12 +5696,13 @@ var categoryColoursCSS = "/* Generated by scripts/generate-colours.js — do not
|
|
|
5696
5696
|
/**
|
|
5697
5697
|
* Builds a Typesense filter_by expression from the search component's filter attributes.
|
|
5698
5698
|
*
|
|
5699
|
-
* @param {string|null} types
|
|
5700
|
-
* @param {string|null} excludeTypes
|
|
5701
|
-
* @param {string|null} isContact
|
|
5699
|
+
* @param {string|null} types - Value of data-types attribute (e.g. "Person,Place")
|
|
5700
|
+
* @param {string|null} excludeTypes - Value of data-exclude-types attribute (e.g. "Language")
|
|
5701
|
+
* @param {string|null} isContact - Value of data-is-contact attribute ("true" or "false")
|
|
5702
|
+
* @param {string|null} allowedOrigins - Value of data-allowed-origins attribute (e.g. "https://eolas.l42.eu")
|
|
5702
5703
|
* @returns {string|null} filter expression, or null if no filters apply
|
|
5703
5704
|
*/
|
|
5704
|
-
function buildFilterBy(types, excludeTypes, isContact) {
|
|
5705
|
+
function buildFilterBy(types, excludeTypes, isContact, allowedOrigins) {
|
|
5705
5706
|
const parts = [];
|
|
5706
5707
|
if (types) {
|
|
5707
5708
|
parts.push(`types:=[${types}]`);
|
|
@@ -5713,6 +5714,9 @@ function buildFilterBy(types, excludeTypes, isContact) {
|
|
|
5713
5714
|
} else if (isContact === 'false') {
|
|
5714
5715
|
parts.push('is_contact:=false');
|
|
5715
5716
|
}
|
|
5717
|
+
if (allowedOrigins) {
|
|
5718
|
+
parts.push(`origin:=[${allowedOrigins}]`);
|
|
5719
|
+
}
|
|
5716
5720
|
return parts.length > 0 ? parts.join(' && ') : null;
|
|
5717
5721
|
}
|
|
5718
5722
|
|
|
@@ -5748,7 +5752,7 @@ function buildFormDataEntries(name, values, optionMap) {
|
|
|
5748
5752
|
|
|
5749
5753
|
class LucosSearchComponent extends HTMLSpanElement {
|
|
5750
5754
|
static get observedAttributes() {
|
|
5751
|
-
return ['data-api-key','data-types','data-exclude_types','data-is-contact','data-label-override-zxx','data-common','data-preload','data-create'];
|
|
5755
|
+
return ['data-api-key','data-types','data-exclude_types','data-is-contact','data-label-override-zxx','data-common','data-preload','data-create','data-allowed-origins'];
|
|
5752
5756
|
}
|
|
5753
5757
|
constructor() {
|
|
5754
5758
|
super();
|
|
@@ -5886,10 +5890,19 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5886
5890
|
...(component.isLanguageMode || component.getAttribute("data-common") ? { optgroupField: 'lang_family', lockOptgroupOrder: true } : {}),
|
|
5887
5891
|
...(component.hasAttribute("data-create") ? {
|
|
5888
5892
|
create: function(input) {
|
|
5889
|
-
|
|
5893
|
+
// Key must match valueField below (contact_uri in contact mode) — a
|
|
5894
|
+
// hardcoded `id` key here would leave the created option unaddressable
|
|
5895
|
+
// by TomSelect when data-is-contact="true".
|
|
5896
|
+
const created = { pref_label: input, created: true };
|
|
5897
|
+
created[component.valueFieldName] = input;
|
|
5898
|
+
return created;
|
|
5890
5899
|
},
|
|
5891
5900
|
} : {}),
|
|
5892
|
-
|
|
5901
|
+
// In contact mode, the form value, TomSelect's option key, and the lozenge
|
|
5902
|
+
// click-through/link target (render.item below, onItemSelect further down) all
|
|
5903
|
+
// use the lucos_contacts URI instead of the eolas knowledge URI — see
|
|
5904
|
+
// lucos_arachne#712 and the #190 review discussion.
|
|
5905
|
+
valueField: component.valueFieldName,
|
|
5893
5906
|
labelField: 'pref_label',
|
|
5894
5907
|
searchField: [],
|
|
5895
5908
|
closeAfterSelect: true,
|
|
@@ -5903,7 +5916,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5903
5916
|
component._searchAbortController = abortController;
|
|
5904
5917
|
|
|
5905
5918
|
errorMessage.setAttribute('hidden', '');
|
|
5906
|
-
const commonSet = new Set((component._commonOptions || []).map(o => o.
|
|
5919
|
+
const commonSet = new Set((component._commonOptions || []).map(o => o[component.valueFieldName]));
|
|
5907
5920
|
// When preloaded, filter locally instead of hitting Typesense
|
|
5908
5921
|
if (component._preloadedOptions) {
|
|
5909
5922
|
const q = query.toLowerCase();
|
|
@@ -5913,7 +5926,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5913
5926
|
(r.labels && r.labels.some(l => l.toLowerCase().includes(q)))
|
|
5914
5927
|
)
|
|
5915
5928
|
: [...component._preloadedOptions];
|
|
5916
|
-
results = results.filter(r => !commonSet.has(r.
|
|
5929
|
+
results = results.filter(r => !commonSet.has(r[component.valueFieldName]));
|
|
5917
5930
|
this.clearOptions();
|
|
5918
5931
|
if (component._commonOptions) {
|
|
5919
5932
|
const filteredCommon = q
|
|
@@ -5934,6 +5947,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5934
5947
|
component.getAttribute("data-types"),
|
|
5935
5948
|
component.getAttribute("data-exclude_types"),
|
|
5936
5949
|
component.getAttribute("data-is-contact"),
|
|
5950
|
+
component.getAttribute("data-allowed-origins"),
|
|
5937
5951
|
);
|
|
5938
5952
|
if (filterBy) queryParams.set("filter_by", filterBy);
|
|
5939
5953
|
try {
|
|
@@ -5942,7 +5956,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5942
5956
|
this.clearOptions();
|
|
5943
5957
|
// Remove common items from results to avoid duplication; filter by query when non-empty
|
|
5944
5958
|
if (component._commonOptions) {
|
|
5945
|
-
results = results.filter(r => !commonSet.has(r.
|
|
5959
|
+
results = results.filter(r => !commonSet.has(r[component.valueFieldName]));
|
|
5946
5960
|
const q = query.toLowerCase();
|
|
5947
5961
|
const filteredCommon = q
|
|
5948
5962
|
? component._commonOptions.filter(o =>
|
|
@@ -5972,14 +5986,14 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5972
5986
|
},
|
|
5973
5987
|
onFocus: function() {
|
|
5974
5988
|
this.clearOptions();
|
|
5975
|
-
const commonSet = new Set((component._commonOptions || []).map(o => o.
|
|
5989
|
+
const commonSet = new Set((component._commonOptions || []).map(o => o[component.valueFieldName]));
|
|
5976
5990
|
if (component._commonOptions) {
|
|
5977
5991
|
component._commonOptions.forEach(opt => this.addOption(opt));
|
|
5978
5992
|
}
|
|
5979
5993
|
// Re-add preloaded options (excluding common items which are shown separately)
|
|
5980
5994
|
if (component._preloadedOptions) {
|
|
5981
5995
|
component._preloadedOptions
|
|
5982
|
-
.filter(opt => !commonSet.has(opt.
|
|
5996
|
+
.filter(opt => !commonSet.has(opt[component.valueFieldName]))
|
|
5983
5997
|
.forEach(opt => this.addOption(opt));
|
|
5984
5998
|
}
|
|
5985
5999
|
},
|
|
@@ -5992,7 +6006,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5992
6006
|
this.addOptionGroup('x-common', { label: 'Common' });
|
|
5993
6007
|
const commonParams = new URLSearchParams({
|
|
5994
6008
|
q: '*',
|
|
5995
|
-
filter_by:
|
|
6009
|
+
filter_by: `${component.valueFieldName}:[${commonIds.join(",")}]`,
|
|
5996
6010
|
per_page: commonIds.length,
|
|
5997
6011
|
});
|
|
5998
6012
|
const commonResults = await component.searchRequest(commonParams);
|
|
@@ -6012,41 +6026,49 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
6012
6026
|
component.getAttribute("data-types"),
|
|
6013
6027
|
component.getAttribute("data-exclude_types"),
|
|
6014
6028
|
component.getAttribute("data-is-contact"),
|
|
6029
|
+
component.getAttribute("data-allowed-origins"),
|
|
6015
6030
|
);
|
|
6016
6031
|
// per_page: 250 acts as an upper bound — data-preload is intended for finite datasets
|
|
6017
6032
|
const preloadParams = new URLSearchParams({ q: '*', per_page: 250 });
|
|
6018
6033
|
if (filterValue) preloadParams.set("filter_by", filterValue);
|
|
6019
6034
|
const preloaded = await component.searchRequest(preloadParams);
|
|
6020
6035
|
component._preloadedOptions = preloaded;
|
|
6021
|
-
const commonSet = new Set((component._commonOptions || []).map(o => o.
|
|
6022
|
-
preloaded.filter(r => !commonSet.has(r.
|
|
6036
|
+
const commonSet = new Set((component._commonOptions || []).map(o => o[component.valueFieldName]));
|
|
6037
|
+
preloaded.filter(r => !commonSet.has(r[component.valueFieldName])).forEach(r => this.addOption(r));
|
|
6023
6038
|
}
|
|
6024
6039
|
if (ids.length < 1) return;
|
|
6025
|
-
// Fetch real options from Typesense, excluding common/preloaded items
|
|
6026
|
-
|
|
6040
|
+
// Fetch real options from Typesense, excluding common/preloaded items.
|
|
6041
|
+
// `ids` are keyed by whatever valueField is configured (contact_uri in
|
|
6042
|
+
// contact mode, id otherwise), so every set/filter/updateOption below must
|
|
6043
|
+
// use the same key — a stray `.id` here is a silent no-op, not an error
|
|
6044
|
+
// (see lucos_search_component#190 review: updateOption(result.id, ...)
|
|
6045
|
+
// looks up tom-select's options map by valueField, not literally `id`).
|
|
6046
|
+
const preloadedIds = component._preloadedOptions ? new Set(component._preloadedOptions.map(r => r[component.valueFieldName])) : new Set();
|
|
6027
6047
|
const excludeIds = new Set([...commonIds, ...preloadedIds]);
|
|
6028
6048
|
const idsToFetch = ids.filter(id => !excludeIds.has(id));
|
|
6029
6049
|
if (idsToFetch.length > 0) {
|
|
6030
6050
|
const searchParams = new URLSearchParams({
|
|
6031
6051
|
q: '*',
|
|
6032
|
-
filter_by:
|
|
6052
|
+
filter_by: `${component.valueFieldName}:[${idsToFetch.join(",")}]`,
|
|
6033
6053
|
per_page: idsToFetch.length,
|
|
6034
6054
|
});
|
|
6035
6055
|
const results = await component.searchRequest(searchParams);
|
|
6036
6056
|
results.forEach(result => {
|
|
6037
|
-
this.updateOption(result.
|
|
6057
|
+
this.updateOption(result[component.valueFieldName], result);
|
|
6038
6058
|
});
|
|
6039
6059
|
}
|
|
6040
6060
|
// Update any pre-selected common items with fresh data
|
|
6041
6061
|
if (component._commonOptions) {
|
|
6042
6062
|
component._commonOptions.forEach(opt => {
|
|
6043
|
-
|
|
6063
|
+
const key = opt[component.valueFieldName];
|
|
6064
|
+
if (ids.includes(key)) this.updateOption(key, opt);
|
|
6044
6065
|
});
|
|
6045
6066
|
}
|
|
6046
6067
|
// Update any pre-selected preloaded items with fresh data
|
|
6047
6068
|
if (component._preloadedOptions) {
|
|
6048
6069
|
component._preloadedOptions.forEach(opt => {
|
|
6049
|
-
|
|
6070
|
+
const key = opt[component.valueFieldName];
|
|
6071
|
+
if (ids.includes(key)) this.updateOption(key, opt);
|
|
6050
6072
|
});
|
|
6051
6073
|
}
|
|
6052
6074
|
},
|
|
@@ -6091,7 +6113,10 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
6091
6113
|
if (data.created) {
|
|
6092
6114
|
return `<div class="lozenge lozenge-pending" data-type="" data-category="">${escape(displayLabel)}</div>`;
|
|
6093
6115
|
}
|
|
6094
|
-
|
|
6116
|
+
// Link target matches the form value (valueFieldName) — in contact mode
|
|
6117
|
+
// that's contact_uri, not the eolas id, per lucas42's review on #190:
|
|
6118
|
+
// everything should link to the same URI for consistency.
|
|
6119
|
+
return `<div class="lozenge" data-type="${escape(data.type)}" data-category="${escape(data.category)}"><a href="${data[component.valueFieldName]}" target="_blank">${escape(displayLabel)}</a></div>`;
|
|
6095
6120
|
},
|
|
6096
6121
|
option_create: function(data, escape) {
|
|
6097
6122
|
const noun = getCreateNoun();
|
|
@@ -6194,6 +6219,17 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
6194
6219
|
if (!types) return false;
|
|
6195
6220
|
return types.split(",").map(t => t.trim()).includes("Language");
|
|
6196
6221
|
}
|
|
6222
|
+
// The document field TomSelect uses as its option key/form value: contact_uri when
|
|
6223
|
+
// data-is-contact="true", id otherwise. Every place that reads/writes TomSelect's
|
|
6224
|
+
// internal options map (keyed by valueField) or filters Typesense by that key must
|
|
6225
|
+
// go through this — hardcoding `.id` breaks in contact mode (lucos_search_component#190
|
|
6226
|
+
// review: `updateOption(result.id, ...)` silently no-op'd because tom-select hashes
|
|
6227
|
+
// on valueField, not literally `id`). Checks the attribute directly rather than via a
|
|
6228
|
+
// separate isContactMode getter — data-is-contact is also read directly elsewhere
|
|
6229
|
+
// (e.g. buildFilterBy's callers), so a getter here would look more reusable than it is.
|
|
6230
|
+
get valueFieldName() {
|
|
6231
|
+
return this.getAttribute("data-is-contact") === "true" ? 'contact_uri' : 'id';
|
|
6232
|
+
}
|
|
6197
6233
|
get commonIds() {
|
|
6198
6234
|
const common = this.getAttribute("data-common");
|
|
6199
6235
|
if (!common) return [];
|
|
@@ -6239,7 +6275,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
6239
6275
|
searchParams.set('query_by_weights', "10,8,3,1");
|
|
6240
6276
|
searchParams.set('sort_by', "_text_match:desc,pref_label:asc");
|
|
6241
6277
|
searchParams.set('prioritize_num_matching_fields', false);
|
|
6242
|
-
searchParams.set('include_fields', "id,pref_label,type,category,labels,lang_family");
|
|
6278
|
+
searchParams.set('include_fields', "id,pref_label,type,category,labels,lang_family,contact_uri");
|
|
6243
6279
|
searchParams.set('enable_highlight_v1', false);
|
|
6244
6280
|
searchParams.set('highlight_start_tag', '<span class="highlight">');
|
|
6245
6281
|
searchParams.set('highlight_end_tag', '</span>');
|
package/example/index.html
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<label for="search6">Languages with label override for zxx:</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-types="Language" data-label-override-zxx="No Language"><select id="search6"></select></span>
|
|
20
20
|
<label for="search7">Languages with zxx pre-selected:</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-types="Language" data-label-override-zxx="No Language"><select id="search7" multiple><option selected>https://eolas.l42.eu/metadata/language/zxx/</option></select></span>
|
|
21
21
|
<label for="search11">Languages with label override, common and preload:</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-types="Language" data-label-override-zxx="Instrumental / No Language" data-common="https://eolas.l42.eu/metadata/language/en/,https://eolas.l42.eu/metadata/language/ga/,https://eolas.l42.eu/metadata/language/zxx/" data-preload><select id="search11" multiple ></select></span>
|
|
22
|
+
<label for="search_origins">Eolas only (data-allowed-origins):</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-allowed-origins="https://eolas.l42.eu"><select id="search_origins"></select></span>
|
|
22
23
|
<label for="search5">More than 10:</label>
|
|
23
24
|
<span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-exclude_types="Track"><select id="search5" multiple>
|
|
24
25
|
<option selected>https://eolas.l42.eu/metadata/place/125/</option>
|
|
@@ -68,6 +69,16 @@
|
|
|
68
69
|
<button type="submit">Submit</button>
|
|
69
70
|
</form>
|
|
70
71
|
<pre id="create-output"></pre>
|
|
72
|
+
<h1>Contact mode (data-is-contact)</h1>
|
|
73
|
+
<p>Person field restricted to <code>lucos_contacts</code> records (<code>data-is-contact="true"</code>). The selected-item lozenge links through to the <code>lucos_contacts</code> URI (<code>contact_uri</code>), and the value serialised on submit is that same URI — not the eolas URI — see lucos_search_component#189.</p>
|
|
74
|
+
<form id="contact-form" onsubmit="handleContactSubmit(event)">
|
|
75
|
+
<label for="linked-contact">Linked contact:</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-is-contact="true"><select id="linked-contact" name="linked_contact" multiple></select></span>
|
|
76
|
+
<button type="submit">Submit</button>
|
|
77
|
+
</form>
|
|
78
|
+
<pre id="contact-output"></pre>
|
|
79
|
+
<label for="linked-contact-preselected">Linked contact (pre-selected on page load):</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-is-contact="true"><select id="linked-contact-preselected" multiple>
|
|
80
|
+
<option selected>https://contacts.l42.eu/people/2</option>
|
|
81
|
+
</select></span>
|
|
71
82
|
<script>
|
|
72
83
|
function handleSubmit(event) {
|
|
73
84
|
event.preventDefault();
|
|
@@ -89,6 +100,16 @@
|
|
|
89
100
|
}
|
|
90
101
|
document.getElementById('create-output').textContent = JSON.stringify(output, null, 2);
|
|
91
102
|
}
|
|
103
|
+
function handleContactSubmit(event) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
const data = new FormData(event.target);
|
|
106
|
+
const output = {};
|
|
107
|
+
for (const [key, value] of data.entries()) {
|
|
108
|
+
if (!output[key]) output[key] = [];
|
|
109
|
+
output[key].push(value);
|
|
110
|
+
}
|
|
111
|
+
document.getElementById('contact-output').textContent = JSON.stringify(output, null, 2);
|
|
112
|
+
}
|
|
92
113
|
</script>
|
|
93
114
|
<script src="./built.js"></script>
|
|
94
115
|
</body>
|
package/package.json
CHANGED
package/test/filter.test.mjs
CHANGED
|
@@ -31,3 +31,23 @@ test('data-exclude-types produces types:!= filter', () => {
|
|
|
31
31
|
const result = buildFilterBy(null, 'Track', null);
|
|
32
32
|
assert.equal(result, 'types:!=[Track]');
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
test('data-allowed-origins produces origin:= filter', () => {
|
|
36
|
+
const result = buildFilterBy(null, null, null, 'https://eolas.l42.eu');
|
|
37
|
+
assert.equal(result, 'origin:=[https://eolas.l42.eu]');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('data-allowed-origins with multiple comma-separated origins', () => {
|
|
41
|
+
const result = buildFilterBy(null, null, null, 'https://eolas.l42.eu,https://contacts.l42.eu');
|
|
42
|
+
assert.equal(result, 'origin:=[https://eolas.l42.eu,https://contacts.l42.eu]');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('data-allowed-origins combines with data-types', () => {
|
|
46
|
+
const result = buildFilterBy('Person', null, null, 'https://eolas.l42.eu');
|
|
47
|
+
assert.equal(result, 'types:=[Person] && origin:=[https://eolas.l42.eu]');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('absent data-allowed-origins produces no origin filter', () => {
|
|
51
|
+
const result = buildFilterBy('Person', null, null, null);
|
|
52
|
+
assert.equal(result, 'types:=[Person]');
|
|
53
|
+
});
|
package/web-components/filter.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Builds a Typesense filter_by expression from the search component's filter attributes.
|
|
3
3
|
*
|
|
4
|
-
* @param {string|null} types
|
|
5
|
-
* @param {string|null} excludeTypes
|
|
6
|
-
* @param {string|null} isContact
|
|
4
|
+
* @param {string|null} types - Value of data-types attribute (e.g. "Person,Place")
|
|
5
|
+
* @param {string|null} excludeTypes - Value of data-exclude-types attribute (e.g. "Language")
|
|
6
|
+
* @param {string|null} isContact - Value of data-is-contact attribute ("true" or "false")
|
|
7
|
+
* @param {string|null} allowedOrigins - Value of data-allowed-origins attribute (e.g. "https://eolas.l42.eu")
|
|
7
8
|
* @returns {string|null} filter expression, or null if no filters apply
|
|
8
9
|
*/
|
|
9
|
-
export function buildFilterBy(types, excludeTypes, isContact) {
|
|
10
|
+
export function buildFilterBy(types, excludeTypes, isContact, allowedOrigins) {
|
|
10
11
|
const parts = [];
|
|
11
12
|
if (types) {
|
|
12
13
|
parts.push(`types:=[${types}]`);
|
|
@@ -18,5 +19,8 @@ export function buildFilterBy(types, excludeTypes, isContact) {
|
|
|
18
19
|
} else if (isContact === 'false') {
|
|
19
20
|
parts.push('is_contact:=false');
|
|
20
21
|
}
|
|
22
|
+
if (allowedOrigins) {
|
|
23
|
+
parts.push(`origin:=[${allowedOrigins}]`);
|
|
24
|
+
}
|
|
21
25
|
return parts.length > 0 ? parts.join(' && ') : null;
|
|
22
26
|
}
|
|
@@ -6,7 +6,7 @@ import { buildFormDataEntries } from './form-serialise.js';
|
|
|
6
6
|
|
|
7
7
|
class LucosSearchComponent extends HTMLSpanElement {
|
|
8
8
|
static get observedAttributes() {
|
|
9
|
-
return ['data-api-key','data-types','data-exclude_types','data-is-contact','data-label-override-zxx','data-common','data-preload','data-create'];
|
|
9
|
+
return ['data-api-key','data-types','data-exclude_types','data-is-contact','data-label-override-zxx','data-common','data-preload','data-create','data-allowed-origins'];
|
|
10
10
|
}
|
|
11
11
|
constructor() {
|
|
12
12
|
super();
|
|
@@ -144,10 +144,19 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
144
144
|
...(component.isLanguageMode || component.getAttribute("data-common") ? { optgroupField: 'lang_family', lockOptgroupOrder: true } : {}),
|
|
145
145
|
...(component.hasAttribute("data-create") ? {
|
|
146
146
|
create: function(input) {
|
|
147
|
-
|
|
147
|
+
// Key must match valueField below (contact_uri in contact mode) — a
|
|
148
|
+
// hardcoded `id` key here would leave the created option unaddressable
|
|
149
|
+
// by TomSelect when data-is-contact="true".
|
|
150
|
+
const created = { pref_label: input, created: true };
|
|
151
|
+
created[component.valueFieldName] = input;
|
|
152
|
+
return created;
|
|
148
153
|
},
|
|
149
154
|
} : {}),
|
|
150
|
-
|
|
155
|
+
// In contact mode, the form value, TomSelect's option key, and the lozenge
|
|
156
|
+
// click-through/link target (render.item below, onItemSelect further down) all
|
|
157
|
+
// use the lucos_contacts URI instead of the eolas knowledge URI — see
|
|
158
|
+
// lucos_arachne#712 and the #190 review discussion.
|
|
159
|
+
valueField: component.valueFieldName,
|
|
151
160
|
labelField: 'pref_label',
|
|
152
161
|
searchField: [],
|
|
153
162
|
closeAfterSelect: true,
|
|
@@ -161,7 +170,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
161
170
|
component._searchAbortController = abortController;
|
|
162
171
|
|
|
163
172
|
errorMessage.setAttribute('hidden', '');
|
|
164
|
-
const commonSet = new Set((component._commonOptions || []).map(o => o.
|
|
173
|
+
const commonSet = new Set((component._commonOptions || []).map(o => o[component.valueFieldName]));
|
|
165
174
|
// When preloaded, filter locally instead of hitting Typesense
|
|
166
175
|
if (component._preloadedOptions) {
|
|
167
176
|
const q = query.toLowerCase();
|
|
@@ -171,7 +180,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
171
180
|
(r.labels && r.labels.some(l => l.toLowerCase().includes(q)))
|
|
172
181
|
)
|
|
173
182
|
: [...component._preloadedOptions];
|
|
174
|
-
results = results.filter(r => !commonSet.has(r.
|
|
183
|
+
results = results.filter(r => !commonSet.has(r[component.valueFieldName]));
|
|
175
184
|
this.clearOptions();
|
|
176
185
|
if (component._commonOptions) {
|
|
177
186
|
const filteredCommon = q
|
|
@@ -192,6 +201,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
192
201
|
component.getAttribute("data-types"),
|
|
193
202
|
component.getAttribute("data-exclude_types"),
|
|
194
203
|
component.getAttribute("data-is-contact"),
|
|
204
|
+
component.getAttribute("data-allowed-origins"),
|
|
195
205
|
);
|
|
196
206
|
if (filterBy) queryParams.set("filter_by", filterBy);
|
|
197
207
|
try {
|
|
@@ -200,7 +210,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
200
210
|
this.clearOptions();
|
|
201
211
|
// Remove common items from results to avoid duplication; filter by query when non-empty
|
|
202
212
|
if (component._commonOptions) {
|
|
203
|
-
results = results.filter(r => !commonSet.has(r.
|
|
213
|
+
results = results.filter(r => !commonSet.has(r[component.valueFieldName]));
|
|
204
214
|
const q = query.toLowerCase();
|
|
205
215
|
const filteredCommon = q
|
|
206
216
|
? component._commonOptions.filter(o =>
|
|
@@ -230,14 +240,14 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
230
240
|
},
|
|
231
241
|
onFocus: function() {
|
|
232
242
|
this.clearOptions();
|
|
233
|
-
const commonSet = new Set((component._commonOptions || []).map(o => o.
|
|
243
|
+
const commonSet = new Set((component._commonOptions || []).map(o => o[component.valueFieldName]));
|
|
234
244
|
if (component._commonOptions) {
|
|
235
245
|
component._commonOptions.forEach(opt => this.addOption(opt));
|
|
236
246
|
}
|
|
237
247
|
// Re-add preloaded options (excluding common items which are shown separately)
|
|
238
248
|
if (component._preloadedOptions) {
|
|
239
249
|
component._preloadedOptions
|
|
240
|
-
.filter(opt => !commonSet.has(opt.
|
|
250
|
+
.filter(opt => !commonSet.has(opt[component.valueFieldName]))
|
|
241
251
|
.forEach(opt => this.addOption(opt));
|
|
242
252
|
}
|
|
243
253
|
},
|
|
@@ -250,7 +260,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
250
260
|
this.addOptionGroup('x-common', { label: 'Common' });
|
|
251
261
|
const commonParams = new URLSearchParams({
|
|
252
262
|
q: '*',
|
|
253
|
-
filter_by:
|
|
263
|
+
filter_by: `${component.valueFieldName}:[${commonIds.join(",")}]`,
|
|
254
264
|
per_page: commonIds.length,
|
|
255
265
|
});
|
|
256
266
|
const commonResults = await component.searchRequest(commonParams);
|
|
@@ -270,41 +280,49 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
270
280
|
component.getAttribute("data-types"),
|
|
271
281
|
component.getAttribute("data-exclude_types"),
|
|
272
282
|
component.getAttribute("data-is-contact"),
|
|
283
|
+
component.getAttribute("data-allowed-origins"),
|
|
273
284
|
);
|
|
274
285
|
// per_page: 250 acts as an upper bound — data-preload is intended for finite datasets
|
|
275
286
|
const preloadParams = new URLSearchParams({ q: '*', per_page: 250 });
|
|
276
287
|
if (filterValue) preloadParams.set("filter_by", filterValue);
|
|
277
288
|
const preloaded = await component.searchRequest(preloadParams);
|
|
278
289
|
component._preloadedOptions = preloaded;
|
|
279
|
-
const commonSet = new Set((component._commonOptions || []).map(o => o.
|
|
280
|
-
preloaded.filter(r => !commonSet.has(r.
|
|
290
|
+
const commonSet = new Set((component._commonOptions || []).map(o => o[component.valueFieldName]));
|
|
291
|
+
preloaded.filter(r => !commonSet.has(r[component.valueFieldName])).forEach(r => this.addOption(r));
|
|
281
292
|
}
|
|
282
293
|
if (ids.length < 1) return;
|
|
283
|
-
// Fetch real options from Typesense, excluding common/preloaded items
|
|
284
|
-
|
|
294
|
+
// Fetch real options from Typesense, excluding common/preloaded items.
|
|
295
|
+
// `ids` are keyed by whatever valueField is configured (contact_uri in
|
|
296
|
+
// contact mode, id otherwise), so every set/filter/updateOption below must
|
|
297
|
+
// use the same key — a stray `.id` here is a silent no-op, not an error
|
|
298
|
+
// (see lucos_search_component#190 review: updateOption(result.id, ...)
|
|
299
|
+
// looks up tom-select's options map by valueField, not literally `id`).
|
|
300
|
+
const preloadedIds = component._preloadedOptions ? new Set(component._preloadedOptions.map(r => r[component.valueFieldName])) : new Set();
|
|
285
301
|
const excludeIds = new Set([...commonIds, ...preloadedIds]);
|
|
286
302
|
const idsToFetch = ids.filter(id => !excludeIds.has(id));
|
|
287
303
|
if (idsToFetch.length > 0) {
|
|
288
304
|
const searchParams = new URLSearchParams({
|
|
289
305
|
q: '*',
|
|
290
|
-
filter_by:
|
|
306
|
+
filter_by: `${component.valueFieldName}:[${idsToFetch.join(",")}]`,
|
|
291
307
|
per_page: idsToFetch.length,
|
|
292
308
|
});
|
|
293
309
|
const results = await component.searchRequest(searchParams);
|
|
294
310
|
results.forEach(result => {
|
|
295
|
-
this.updateOption(result.
|
|
311
|
+
this.updateOption(result[component.valueFieldName], result);
|
|
296
312
|
});
|
|
297
313
|
}
|
|
298
314
|
// Update any pre-selected common items with fresh data
|
|
299
315
|
if (component._commonOptions) {
|
|
300
316
|
component._commonOptions.forEach(opt => {
|
|
301
|
-
|
|
317
|
+
const key = opt[component.valueFieldName];
|
|
318
|
+
if (ids.includes(key)) this.updateOption(key, opt);
|
|
302
319
|
});
|
|
303
320
|
}
|
|
304
321
|
// Update any pre-selected preloaded items with fresh data
|
|
305
322
|
if (component._preloadedOptions) {
|
|
306
323
|
component._preloadedOptions.forEach(opt => {
|
|
307
|
-
|
|
324
|
+
const key = opt[component.valueFieldName];
|
|
325
|
+
if (ids.includes(key)) this.updateOption(key, opt);
|
|
308
326
|
});
|
|
309
327
|
}
|
|
310
328
|
},
|
|
@@ -349,7 +367,10 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
349
367
|
if (data.created) {
|
|
350
368
|
return `<div class="lozenge lozenge-pending" data-type="" data-category="">${escape(displayLabel)}</div>`;
|
|
351
369
|
}
|
|
352
|
-
|
|
370
|
+
// Link target matches the form value (valueFieldName) — in contact mode
|
|
371
|
+
// that's contact_uri, not the eolas id, per lucas42's review on #190:
|
|
372
|
+
// everything should link to the same URI for consistency.
|
|
373
|
+
return `<div class="lozenge" data-type="${escape(data.type)}" data-category="${escape(data.category)}"><a href="${data[component.valueFieldName]}" target="_blank">${escape(displayLabel)}</a></div>`;
|
|
353
374
|
},
|
|
354
375
|
option_create: function(data, escape) {
|
|
355
376
|
const noun = getCreateNoun();
|
|
@@ -452,6 +473,17 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
452
473
|
if (!types) return false;
|
|
453
474
|
return types.split(",").map(t => t.trim()).includes("Language");
|
|
454
475
|
}
|
|
476
|
+
// The document field TomSelect uses as its option key/form value: contact_uri when
|
|
477
|
+
// data-is-contact="true", id otherwise. Every place that reads/writes TomSelect's
|
|
478
|
+
// internal options map (keyed by valueField) or filters Typesense by that key must
|
|
479
|
+
// go through this — hardcoding `.id` breaks in contact mode (lucos_search_component#190
|
|
480
|
+
// review: `updateOption(result.id, ...)` silently no-op'd because tom-select hashes
|
|
481
|
+
// on valueField, not literally `id`). Checks the attribute directly rather than via a
|
|
482
|
+
// separate isContactMode getter — data-is-contact is also read directly elsewhere
|
|
483
|
+
// (e.g. buildFilterBy's callers), so a getter here would look more reusable than it is.
|
|
484
|
+
get valueFieldName() {
|
|
485
|
+
return this.getAttribute("data-is-contact") === "true" ? 'contact_uri' : 'id';
|
|
486
|
+
}
|
|
455
487
|
get commonIds() {
|
|
456
488
|
const common = this.getAttribute("data-common");
|
|
457
489
|
if (!common) return [];
|
|
@@ -497,7 +529,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
497
529
|
searchParams.set('query_by_weights', "10,8,3,1");
|
|
498
530
|
searchParams.set('sort_by', "_text_match:desc,pref_label:asc");
|
|
499
531
|
searchParams.set('prioritize_num_matching_fields', false);
|
|
500
|
-
searchParams.set('include_fields', "id,pref_label,type,category,labels,lang_family");
|
|
532
|
+
searchParams.set('include_fields', "id,pref_label,type,category,labels,lang_family,contact_uri");
|
|
501
533
|
searchParams.set('enable_highlight_v1', false);
|
|
502
534
|
searchParams.set('highlight_start_tag', '<span class="highlight">')
|
|
503
535
|
searchParams.set('highlight_end_tag', '</span>');
|