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.
@@ -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.19.0
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 }}
@@ -46,7 +46,7 @@ jobs:
46
46
 
47
47
  steps:
48
48
  - name: Checkout repository
49
- uses: actions/checkout@v6
49
+ uses: actions/checkout@v7
50
50
 
51
51
  # Initializes the CodeQL tools for scanning.
52
52
  - name: Initialize CodeQL
@@ -9,4 +9,4 @@ permissions:
9
9
 
10
10
  jobs:
11
11
  convention-check:
12
- uses: lucas42/.github/.github/workflows/reusable-convention-check.yml@v1.19.0
12
+ uses: lucas42/.github/.github/workflows/reusable-convention-check.yml@v1.21.0
@@ -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.19.0
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 - 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")
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
- return { id: input, pref_label: input, created: true };
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
- valueField: 'id',
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.id));
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.id));
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.id));
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.id));
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.id))
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: `id:[${commonIds.join(",")}]`,
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.id));
6022
- preloaded.filter(r => !commonSet.has(r.id)).forEach(r => this.addOption(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
- const preloadedIds = component._preloadedOptions ? new Set(component._preloadedOptions.map(r => r.id)) : new Set();
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: `id:[${idsToFetch.join(",")}]`,
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.id, 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
- if (ids.includes(opt.id)) this.updateOption(opt.id, opt);
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
- if (ids.includes(opt.id)) this.updateOption(opt.id, opt);
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
- return `<div class="lozenge" data-type="${escape(data.type)}" data-category="${escape(data.category)}"><a href="${data.id}" target="_blank">${escape(displayLabel)}</a></div>`;
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>');
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lucos_search_component",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "description": "Web Components for searching lucOS data",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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
+ });
@@ -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 - 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")
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
- return { id: input, pref_label: input, created: true };
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
- valueField: 'id',
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.id));
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.id));
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.id));
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.id));
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.id))
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: `id:[${commonIds.join(",")}]`,
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.id));
280
- preloaded.filter(r => !commonSet.has(r.id)).forEach(r => this.addOption(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
- const preloadedIds = component._preloadedOptions ? new Set(component._preloadedOptions.map(r => r.id)) : new Set();
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: `id:[${idsToFetch.join(",")}]`,
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.id, 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
- if (ids.includes(opt.id)) this.updateOption(opt.id, opt);
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
- if (ids.includes(opt.id)) this.updateOption(opt.id, opt);
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
- return `<div class="lozenge" data-type="${escape(data.type)}" data-category="${escape(data.category)}"><a href="${data.id}" target="_blank">${escape(displayLabel)}</a></div>`;
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>');