lucos_search_component 4.0.0 → 4.0.2
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/CLAUDE.md +7 -0
- package/README.md +1 -0
- package/dist/index.js +98 -16
- package/example/index.html +18 -0
- package/package.json +1 -1
- package/test/filter.test.mjs +20 -0
- package/test/form-serialise.test.mjs +111 -0
- package/web-components/filter.js +8 -4
- package/web-components/form-serialise.js +29 -0
- package/web-components/lucos-search.js +60 -11
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
|
@@ -36,6 +36,7 @@ The following attributes can be added to the lucos-search span:
|
|
|
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.
|
|
39
|
+
* **data-create** — Boolean presence attribute. When present, enables inline creation of new entities: if the user types a name with no matching arachne result, they can pick "Add new `<type>`: `<name>`…" from the dropdown. On form submit, the created entry is serialised as a name-only value (`[name]` set, `[uri]` omitted) so the consumer can route it to a create-on-write path. **Opt-in per instance** — controlled-vocabulary fields (e.g. language, offence) must not set this; only open entity types (e.g. Person) should allow inline creation. The `[uri]` + `[name]` serialisation for existing arachne-selected entries is unaffected.
|
|
39
40
|
|
|
40
41
|
#### Language selector
|
|
41
42
|
|
package/dist/index.js
CHANGED
|
@@ -5691,17 +5691,18 @@ TomSelect.define('virtual_scroll', plugin);
|
|
|
5691
5691
|
|
|
5692
5692
|
var tomSelectStylesheet = "/**\n * tom-select.css (v2.6.1)\n * Copyright (c) contributors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this\n * file except in compliance with the License. You may obtain a copy of the License at:\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software distributed under\n * the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF\n * ANY KIND, either express or implied. See the License for the specific language\n * governing permissions and limitations under the License.\n *\n */\n.ts-control {\n border: 1px solid #d0d0d0;\n padding: 8px 8px;\n width: 100%;\n overflow: hidden;\n position: relative;\n z-index: 1;\n box-sizing: border-box;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1);\n border-radius: 3px;\n display: flex;\n flex-wrap: wrap;\n}\n.ts-wrapper.multi.has-items .ts-control {\n padding: calc(8px - 2px - 1px) 8px calc(8px - 2px - 3px - 1px);\n}\n.full .ts-control {\n background-color: #fff;\n}\n.disabled .ts-control, .disabled .ts-control * {\n cursor: default !important;\n}\n.focus .ts-control {\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15);\n}\n.ts-control > * {\n vertical-align: baseline;\n display: inline-block;\n}\n.ts-wrapper.multi .ts-control > div {\n cursor: pointer;\n margin: 0 3px 3px 0;\n padding: 2px 6px;\n background: #1da7ee;\n color: #fff;\n border: 1px solid #0073bb;\n overflow: auto;\n}\n.ts-wrapper.multi .ts-control > div.active {\n background: #92c836;\n color: #fff;\n border: 1px solid #00578d;\n}\n.ts-wrapper.multi.disabled .ts-control > div, .ts-wrapper.multi.disabled .ts-control > div.active {\n color: hsl(0, 0%, 130%);\n background: #d2d2d2;\n border: 1px solid #aaaaaa;\n}\n.ts-control > input {\n flex: 1 1 auto;\n min-width: 7rem;\n display: inline-block !important;\n padding: 0 !important;\n min-height: 0 !important;\n max-height: none !important;\n max-width: 100% !important;\n margin: 0 !important;\n text-indent: 0 !important;\n border: 0 none !important;\n background: none !important;\n line-height: inherit !important;\n -webkit-user-select: auto !important;\n -moz-user-select: auto !important;\n -ms-user-select: auto !important;\n user-select: auto !important;\n box-shadow: none !important;\n}\n.ts-control > input::-ms-clear {\n display: none;\n}\n.ts-control > input:focus {\n outline: none !important;\n}\n.has-items .ts-control > input {\n margin: 0px 4px !important;\n}\n.ts-control.rtl {\n text-align: right;\n}\n.ts-control.rtl.single .ts-control:after {\n left: 15px;\n right: auto;\n}\n.ts-control.rtl .ts-control > input {\n margin: 0px 4px 0px -2px !important;\n}\n.disabled .ts-control {\n opacity: 0.5;\n background-color: #fafafa;\n}\n.input-hidden .ts-control > input {\n opacity: 0;\n position: absolute;\n left: -10000px;\n}\n\n.ts-dropdown {\n position: absolute;\n top: 100%;\n left: 0;\n width: 100%;\n z-index: 10;\n border: 1px solid #d0d0d0;\n background: #fff;\n margin: 0.25rem 0 0;\n border-top: 0 none;\n box-sizing: border-box;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n border-radius: 0 0 3px 3px;\n}\n.ts-dropdown [data-selectable] {\n cursor: pointer;\n overflow: hidden;\n}\n.ts-dropdown [data-selectable] .highlight {\n background: rgba(125, 168, 208, 0.2);\n border-radius: 1px;\n}\n.ts-dropdown .option,\n.ts-dropdown .optgroup-header,\n.ts-dropdown .no-results,\n.ts-dropdown .create {\n padding: 5px 8px;\n}\n.ts-dropdown .option, .ts-dropdown [data-disabled], .ts-dropdown [data-disabled] [data-selectable].option {\n cursor: inherit;\n opacity: 0.5;\n}\n.ts-dropdown [data-selectable].option {\n opacity: 1;\n cursor: pointer;\n}\n.ts-dropdown .optgroup:first-child .optgroup-header {\n border-top: 0 none;\n}\n.ts-dropdown .optgroup-header {\n color: #303030;\n background: #fff;\n cursor: default;\n}\n.ts-dropdown .active {\n background-color: #f5fafd;\n color: #495c68;\n}\n.ts-dropdown .active.create {\n color: #495c68;\n}\n.ts-dropdown .create {\n color: rgba(48, 48, 48, 0.5);\n}\n.ts-dropdown .spinner {\n display: inline-block;\n width: 30px;\n height: 30px;\n margin: 5px 8px;\n}\n.ts-dropdown .spinner::after {\n content: \" \";\n display: block;\n width: 24px;\n height: 24px;\n margin: 3px;\n border-radius: 50%;\n border: 5px solid #d0d0d0;\n border-color: #d0d0d0 transparent #d0d0d0 transparent;\n animation: lds-dual-ring 1.2s linear infinite;\n}\n@keyframes lds-dual-ring {\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n}\n\n.ts-dropdown-content {\n overflow: hidden auto;\n max-height: 200px;\n scroll-behavior: smooth;\n}\n\n.ts-wrapper.plugin-drag_drop .ts-dragging {\n color: transparent !important;\n}\n.ts-wrapper.plugin-drag_drop .ts-dragging > * {\n visibility: hidden !important;\n}\n\n.plugin-checkbox_options:not(.rtl) .option input {\n margin-right: 0.5rem;\n}\n\n.plugin-checkbox_options.rtl .option input {\n margin-left: 0.5rem;\n}\n\n/* stylelint-disable function-name-case */\n.plugin-clear_button {\n --ts-pr-clear-button: 1em;\n}\n.plugin-clear_button .clear-button {\n opacity: 0;\n position: absolute;\n top: 50%;\n transform: translateY(-50%);\n right: calc(8px - 6px);\n margin-right: 0 !important;\n background: transparent !important;\n transition: opacity 0.5s;\n cursor: pointer;\n}\n.plugin-clear_button.form-select .clear-button, .plugin-clear_button.single .clear-button {\n right: max(var(--ts-pr-caret), 8px);\n}\n.plugin-clear_button.focus.has-items .clear-button, .plugin-clear_button:not(.disabled):hover.has-items .clear-button {\n opacity: 1;\n}\n\n.ts-wrapper .dropdown-header {\n position: relative;\n padding: 10px 8px;\n border-bottom: 1px solid #d0d0d0;\n background: color-mix(#fff, #d0d0d0, 85%);\n border-radius: 3px 3px 0 0;\n}\n.ts-wrapper .dropdown-header-close {\n position: absolute;\n right: 8px;\n top: 50%;\n color: #303030;\n opacity: 0.4;\n margin-top: -12px;\n line-height: 20px;\n font-size: 20px !important;\n}\n.ts-wrapper .dropdown-header-close:hover {\n color: hsl(0, 0%, -6.1764705882%);\n}\n\n.plugin-dropdown_input.focus.dropdown-active .ts-control {\n box-shadow: none;\n border: 1px solid #d0d0d0;\n}\n.plugin-dropdown_input .dropdown-input {\n border: 1px solid #d0d0d0;\n border-width: 0 0 1px;\n display: block;\n padding: 8px 8px;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1);\n width: 100%;\n background: transparent;\n}\n.plugin-dropdown_input .items-placeholder {\n border: 0 none !important;\n box-shadow: none !important;\n width: 100%;\n}\n.plugin-dropdown_input.has-items .items-placeholder, .plugin-dropdown_input.dropdown-active .items-placeholder {\n display: none !important;\n}\n\n.ts-wrapper.plugin-input_autogrow.has-items .ts-control > input {\n min-width: 0;\n}\n.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input {\n flex: none;\n min-width: 4px;\n}\n.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::-ms-input-placeholder {\n color: transparent;\n}\n.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::placeholder {\n color: transparent;\n}\n\n.ts-dropdown.plugin-optgroup_columns .ts-dropdown-content {\n display: flex;\n}\n.ts-dropdown.plugin-optgroup_columns .optgroup {\n border-right: 1px solid #f2f2f2;\n border-top: 0 none;\n flex-grow: 1;\n flex-basis: 0;\n min-width: 0;\n}\n.ts-dropdown.plugin-optgroup_columns .optgroup:last-child {\n border-right: 0 none;\n}\n.ts-dropdown.plugin-optgroup_columns .optgroup::before {\n display: none;\n}\n.ts-dropdown.plugin-optgroup_columns .optgroup-header {\n border-top: 0 none;\n}\n\n.ts-wrapper.plugin-remove_button .item {\n display: inline-flex;\n align-items: center;\n}\n.ts-wrapper.plugin-remove_button .item .remove {\n color: inherit;\n text-decoration: none;\n vertical-align: middle;\n display: inline-block;\n padding: 0 6px;\n border-radius: 0 2px 2px 0;\n box-sizing: border-box;\n}\n.ts-wrapper.plugin-remove_button .item .remove:hover {\n background: rgba(0, 0, 0, 0.05);\n}\n.ts-wrapper.plugin-remove_button.disabled .item .remove:hover {\n background: none;\n}\n.ts-wrapper.plugin-remove_button .remove-single {\n position: absolute;\n right: 0;\n top: 0;\n font-size: 23px;\n}\n\n.ts-wrapper.plugin-remove_button:not(.rtl) .item {\n padding-right: 0 !important;\n}\n.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {\n border-left: 1px solid #0073bb;\n margin-left: 6px;\n}\n.ts-wrapper.plugin-remove_button:not(.rtl) .item.active .remove {\n border-left-color: #00578d;\n}\n.ts-wrapper.plugin-remove_button:not(.rtl).disabled .item .remove {\n border-left-color: #aaaaaa;\n}\n\n.ts-wrapper.plugin-remove_button.rtl .item {\n padding-left: 0 !important;\n}\n.ts-wrapper.plugin-remove_button.rtl .item .remove {\n border-right: 1px solid #0073bb;\n margin-right: 6px;\n}\n.ts-wrapper.plugin-remove_button.rtl .item.active .remove {\n border-right-color: #00578d;\n}\n.ts-wrapper.plugin-remove_button.rtl.disabled .item .remove {\n border-right-color: #aaaaaa;\n}\n\n:root {\n --ts-pr-clear-button: 0px;\n --ts-pr-caret: 0px;\n --ts-pr-min: .75rem;\n}\n\n.ts-wrapper.single .ts-control, .ts-wrapper.single .ts-control input {\n cursor: pointer;\n}\n\n.ts-control:not(.rtl) {\n padding-right: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important;\n}\n\n.ts-control.rtl {\n padding-left: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important;\n}\n\n.ts-wrapper {\n position: relative;\n}\n\n.ts-dropdown,\n.ts-control,\n.ts-control input {\n color: #303030;\n font-family: inherit;\n font-size: 13px;\n line-height: 18px;\n}\n\n.ts-control,\n.ts-wrapper.single.input-active .ts-control {\n background: #fff;\n cursor: text;\n}\n\n.ts-hidden-accessible {\n border: 0 !important;\n clip: rect(0 0 0 0) !important;\n -webkit-clip-path: inset(50%) !important;\n clip-path: inset(50%) !important;\n overflow: hidden !important;\n padding: 0 !important;\n position: absolute !important;\n width: 1px !important;\n white-space: nowrap !important;\n}\n\n.ts-wrapper.single .ts-control {\n --ts-pr-caret: 2rem;\n}\n.ts-wrapper.single .ts-control::after {\n content: \" \";\n display: block;\n position: absolute;\n top: 50%;\n margin-top: -3px;\n width: 0;\n height: 0;\n border-style: solid;\n border-width: 5px 5px 0 5px;\n border-color: #808080 transparent transparent transparent;\n}\n.ts-wrapper.single .ts-control:not(.rtl)::after {\n right: 15px;\n}\n.ts-wrapper.single .ts-control.rtl::after {\n left: 15px;\n}\n.ts-wrapper.single.dropdown-active .ts-control::after {\n margin-top: -4px;\n border-width: 0 5px 5px 5px;\n border-color: transparent transparent #808080 transparent;\n}\n.ts-wrapper.single.input-active .ts-control, .ts-wrapper.single.input-active .ts-control input {\n cursor: text;\n}\n\n.ts-wrapper {\n display: flex;\n min-height: 36px;\n}\n.ts-wrapper.multi.has-items .ts-control {\n padding-left: 5px;\n --ts-pr-min: 5px;\n}\n.ts-wrapper.multi .ts-control [data-value] {\n text-shadow: 0 1px 0 rgba(0, 51, 83, 0.3);\n border-radius: 3px;\n background-color: color-mix(#1da7ee, #178ee9, 60%);\n background-image: linear-gradient(to bottom, #1da7ee, #178ee9);\n background-repeat: repeat-x;\n box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), inset 0 1px rgba(255, 255, 255, 0.03);\n}\n.ts-wrapper.multi .ts-control [data-value].active {\n background-color: color-mix(#008fd8, #0075cf, 60%);\n background-image: linear-gradient(to bottom, #008fd8, #0075cf);\n background-repeat: repeat-x;\n}\n.ts-wrapper.multi.disabled .ts-control [data-value] {\n color: #999;\n text-shadow: none;\n background: none;\n box-shadow: none;\n}\n.ts-wrapper.multi.disabled .ts-control [data-value], .ts-wrapper.multi.disabled .ts-control [data-value] .remove {\n border-color: #e6e6e6;\n}\n.ts-wrapper.multi.disabled .ts-control [data-value] .remove {\n background: none;\n}\n.ts-wrapper.single .ts-control {\n box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.8);\n background-color: color-mix(#fefefe, #f2f2f2, 60%);\n background-image: linear-gradient(to bottom, #fefefe, #f2f2f2);\n background-repeat: repeat-x;\n}\n\n.ts-wrapper.single .ts-control, .ts-dropdown.single {\n border-color: #b8b8b8;\n}\n\n.dropdown-active .ts-control {\n border-radius: 3px 3px 0 0;\n}\n\n.ts-dropdown .optgroup-header {\n padding-top: 7px;\n font-weight: bold;\n font-size: 0.85em;\n}\n.ts-dropdown .optgroup {\n border-top: 1px solid #f0f0f0;\n}\n.ts-dropdown .optgroup:first-child {\n border-top: 0 none;\n}\n/*# sourceMappingURL=tom-select.default.css.map */";
|
|
5693
5693
|
|
|
5694
|
-
var categoryColoursCSS = "/* Generated by scripts/generate-colours.js — do not edit by hand */\n/* Run `npm run build` to regenerate from the eolas categories endpoint */\n\n.lozenge[data-category=\"People\"] {\n\t--lozenge-background: #044E00;\n\t--lozenge-border: #033100;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Anthropological\"] {\n\t--lozenge-background: #8affe7;\n\t--lozenge-border: #068900;\n\t--lozenge-text: #000000;\n}\n\n.lozenge[data-category=\"Anthropogeographical\"] {\n\t--lozenge-background: #aed0db;\n\t--lozenge-border: #3f6674;\n\t--lozenge-text: #0c1a1b;\n}\n\n.lozenge[data-category=\"Musical\"] {\n\t--lozenge-background: #000060;\n\t--lozenge-border: #000020;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Aquatic\"] {\n\t--lozenge-background: #0085fe;\n\t--lozenge-border: #0036b1;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Terrestrial\"] {\n\t--lozenge-background: #652c17;\n\t--lozenge-border: #321200;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Cosmic\"] {\n\t--lozenge-background: #15163a;\n\t--lozenge-border: #000000;\n\t--lozenge-text: #feffe8;\n}\n\n.lozenge[data-category=\"Supernatural\"] {\n\t--lozenge-background: #f1ff5f;\n\t--lozenge-border: #674800;\n\t--lozenge-text: #352005;\n}\n\n.lozenge[data-category=\"Historical\"] {\n\t--lozenge-background: #740909;\n\t--lozenge-border: #470202;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Temporal\"] {\n\t--lozenge-background: #fffc33;\n\t--lozenge-border: #7f7e00;\n\t--lozenge-text: #0f0f00;\n}\n\n.lozenge[data-category=\"Mathematical\"] {\n\t--lozenge-background: #f53b0e;\n\t--lozenge-border: #7e3d2e;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Technological\"] {\n\t--lozenge-background: #c70f7a;\n\t--lozenge-border: #8f125b;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Meteorological\"] {\n\t--lozenge-background: #ffffff;\n\t--lozenge-border: #333333;\n\t--lozenge-text: #000000;\n}\n\n.lozenge[data-category=\"Meta\"] {\n\t--lozenge-background: #4a5568;\n\t--lozenge-border: #2d3748;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Dramaturgical\"] {\n\t--lozenge-background: #5f0086;\n\t--lozenge-border: #59007d;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Literary\"] {\n\t--lozenge-background: #a22400;\n\t--lozenge-border: #5e1500;\n\t--lozenge-text: #ffffff;\n}\n";
|
|
5694
|
+
var categoryColoursCSS = "/* Generated by scripts/generate-colours.js — do not edit by hand */\n/* Run `npm run build` to regenerate from the eolas categories endpoint */\n\n.lozenge[data-category=\"People\"] {\n\t--lozenge-background: #044E00;\n\t--lozenge-border: #033100;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Anthropological\"] {\n\t--lozenge-background: #8affe7;\n\t--lozenge-border: #068900;\n\t--lozenge-text: #000000;\n}\n\n.lozenge[data-category=\"Advisory\"] {\n\t--lozenge-background: #010102;\n\t--lozenge-border: #000020;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Anthropogeographical\"] {\n\t--lozenge-background: #aed0db;\n\t--lozenge-border: #3f6674;\n\t--lozenge-text: #0c1a1b;\n}\n\n.lozenge[data-category=\"Musical\"] {\n\t--lozenge-background: #000060;\n\t--lozenge-border: #000020;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Aquatic\"] {\n\t--lozenge-background: #0085fe;\n\t--lozenge-border: #0036b1;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Terrestrial\"] {\n\t--lozenge-background: #652c17;\n\t--lozenge-border: #321200;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Cosmic\"] {\n\t--lozenge-background: #15163a;\n\t--lozenge-border: #000000;\n\t--lozenge-text: #feffe8;\n}\n\n.lozenge[data-category=\"Supernatural\"] {\n\t--lozenge-background: #f1ff5f;\n\t--lozenge-border: #674800;\n\t--lozenge-text: #352005;\n}\n\n.lozenge[data-category=\"Historical\"] {\n\t--lozenge-background: #740909;\n\t--lozenge-border: #470202;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Temporal\"] {\n\t--lozenge-background: #fffc33;\n\t--lozenge-border: #7f7e00;\n\t--lozenge-text: #0f0f00;\n}\n\n.lozenge[data-category=\"Mathematical\"] {\n\t--lozenge-background: #f53b0e;\n\t--lozenge-border: #7e3d2e;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Technological\"] {\n\t--lozenge-background: #c70f7a;\n\t--lozenge-border: #8f125b;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Meteorological\"] {\n\t--lozenge-background: #ffffff;\n\t--lozenge-border: #333333;\n\t--lozenge-text: #000000;\n}\n\n.lozenge[data-category=\"Meta\"] {\n\t--lozenge-background: #4a5568;\n\t--lozenge-border: #2d3748;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Dramaturgical\"] {\n\t--lozenge-background: #5f0086;\n\t--lozenge-border: #59007d;\n\t--lozenge-text: #ffffff;\n}\n\n.lozenge[data-category=\"Literary\"] {\n\t--lozenge-background: #a22400;\n\t--lozenge-border: #5e1500;\n\t--lozenge-text: #ffffff;\n}\n";
|
|
5695
5695
|
|
|
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,12 +5714,45 @@ 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
|
|
|
5723
|
+
/**
|
|
5724
|
+
* Build formData key/value pairs for a lucos-search field.
|
|
5725
|
+
*
|
|
5726
|
+
* Arachne-selected entries emit both [uri] and [name]; created entries (tagged
|
|
5727
|
+
* with option.created = true) emit [name] only so consumers can route them to a
|
|
5728
|
+
* create-on-write path without a URI.
|
|
5729
|
+
*
|
|
5730
|
+
* @param {string} name - The field name (from select.name)
|
|
5731
|
+
* @param {string|string[]} values - Selected TomSelect values (ts.getValue())
|
|
5732
|
+
* @param {Object} optionMap - TomSelect options map (ts.options)
|
|
5733
|
+
* @returns {Array<[string, string]>} Array of [key, value] pairs to append to FormData
|
|
5734
|
+
*/
|
|
5735
|
+
function buildFormDataEntries(name, values, optionMap) {
|
|
5736
|
+
const valueArray = Array.isArray(values) ? values : (values ? [values] : []);
|
|
5737
|
+
const entries = [];
|
|
5738
|
+
valueArray.forEach((id, idx) => {
|
|
5739
|
+
const option = optionMap[id];
|
|
5740
|
+
if (!option) return;
|
|
5741
|
+
if (option.created) {
|
|
5742
|
+
// Created (no arachne match) entry: name only, no URI
|
|
5743
|
+
entries.push([`${name}[${idx}][name]`, option.pref_label]);
|
|
5744
|
+
} else {
|
|
5745
|
+
// Arachne-selected entry: URI + name
|
|
5746
|
+
entries.push([`${name}[${idx}][uri]`, id]);
|
|
5747
|
+
entries.push([`${name}[${idx}][name]`, option.pref_label]);
|
|
5748
|
+
}
|
|
5749
|
+
});
|
|
5750
|
+
return entries;
|
|
5751
|
+
}
|
|
5752
|
+
|
|
5719
5753
|
class LucosSearchComponent extends HTMLSpanElement {
|
|
5720
5754
|
static get observedAttributes() {
|
|
5721
|
-
return ['data-api-key','data-types','data-exclude_types','data-is-contact','data-label-override-zxx','data-common','data-preload'];
|
|
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'];
|
|
5722
5756
|
}
|
|
5723
5757
|
constructor() {
|
|
5724
5758
|
super();
|
|
@@ -5810,6 +5844,18 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5810
5844
|
color: inherit;
|
|
5811
5845
|
text-decoration: none;
|
|
5812
5846
|
}
|
|
5847
|
+
|
|
5848
|
+
/* Pre-save visual indicator for unsaved created entries.
|
|
5849
|
+
* Cream background distinguishes from both the default unknown-category grey (#555)
|
|
5850
|
+
* and the white used by the Meteorological category.
|
|
5851
|
+
* border-style !important needed to override TomSelect's base border shorthand. */
|
|
5852
|
+
.lozenge.lozenge-pending {
|
|
5853
|
+
--lozenge-background: #fffbea;
|
|
5854
|
+
--lozenge-border: #999999;
|
|
5855
|
+
--lozenge-text: #333333;
|
|
5856
|
+
border-style: dashed !important;
|
|
5857
|
+
font-style: italic;
|
|
5858
|
+
}
|
|
5813
5859
|
`;
|
|
5814
5860
|
shadow.appendChild(mainStyle);
|
|
5815
5861
|
|
|
@@ -5820,8 +5866,33 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5820
5866
|
const selector = component.querySelector("select");
|
|
5821
5867
|
if (!selector) throw new Error("Can't find select element in lucos-search");
|
|
5822
5868
|
selector.setAttribute("multiple", "multiple");
|
|
5869
|
+
|
|
5870
|
+
// Derive a noun for the "Add new <type>: <name>" create prompt when data-types is a single type
|
|
5871
|
+
function getCreateNoun() {
|
|
5872
|
+
const dataTypes = component.getAttribute("data-types");
|
|
5873
|
+
if (!dataTypes) return null;
|
|
5874
|
+
const types = dataTypes.split(",").map(t => t.trim()).filter(Boolean);
|
|
5875
|
+
return types.length === 1 ? types[0] : null;
|
|
5876
|
+
}
|
|
5877
|
+
|
|
5878
|
+
if (component.hasAttribute("data-create")) {
|
|
5879
|
+
const createTypes = component.getAttribute("data-types");
|
|
5880
|
+
const createTypeList = createTypes ? createTypes.split(",").map(t => t.trim()).filter(Boolean) : [];
|
|
5881
|
+
if (createTypeList.length > 1) {
|
|
5882
|
+
console.warn(
|
|
5883
|
+
`lucos-search: data-create is set alongside data-types="${createTypes}" which specifies ${createTypeList.length} types. ` +
|
|
5884
|
+
`Server-side there will be no way to determine which type to create — data-create should only be used with a single data-types value.`
|
|
5885
|
+
);
|
|
5886
|
+
}
|
|
5887
|
+
}
|
|
5888
|
+
|
|
5823
5889
|
new TomSelect(selector, {
|
|
5824
5890
|
...(component.isLanguageMode || component.getAttribute("data-common") ? { optgroupField: 'lang_family', lockOptgroupOrder: true } : {}),
|
|
5891
|
+
...(component.hasAttribute("data-create") ? {
|
|
5892
|
+
create: function(input) {
|
|
5893
|
+
return { id: input, pref_label: input, created: true };
|
|
5894
|
+
},
|
|
5895
|
+
} : {}),
|
|
5825
5896
|
valueField: 'id',
|
|
5826
5897
|
labelField: 'pref_label',
|
|
5827
5898
|
searchField: [],
|
|
@@ -5867,6 +5938,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5867
5938
|
component.getAttribute("data-types"),
|
|
5868
5939
|
component.getAttribute("data-exclude_types"),
|
|
5869
5940
|
component.getAttribute("data-is-contact"),
|
|
5941
|
+
component.getAttribute("data-allowed-origins"),
|
|
5870
5942
|
);
|
|
5871
5943
|
if (filterBy) queryParams.set("filter_by", filterBy);
|
|
5872
5944
|
try {
|
|
@@ -5945,6 +6017,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5945
6017
|
component.getAttribute("data-types"),
|
|
5946
6018
|
component.getAttribute("data-exclude_types"),
|
|
5947
6019
|
component.getAttribute("data-is-contact"),
|
|
6020
|
+
component.getAttribute("data-allowed-origins"),
|
|
5948
6021
|
);
|
|
5949
6022
|
// per_page: 250 acts as an upper bound — data-preload is intended for finite datasets
|
|
5950
6023
|
const preloadParams = new URLSearchParams({ q: '*', per_page: 250 });
|
|
@@ -5984,8 +6057,12 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
5984
6057
|
}
|
|
5985
6058
|
},
|
|
5986
6059
|
onItemSelect: function (item) {
|
|
5987
|
-
// Tom-select prevents clicking on link in an item to work as normal, so force it here
|
|
5988
|
-
|
|
6060
|
+
// Tom-select prevents clicking on link in an item to work as normal, so force it here.
|
|
6061
|
+
// Skip navigation for created (unsaved) entries — they have no arachne URI.
|
|
6062
|
+
const value = item.dataset.value;
|
|
6063
|
+
const option = this.options[value];
|
|
6064
|
+
if (option && option.created) return;
|
|
6065
|
+
window.open(value, '_blank').focus();
|
|
5989
6066
|
},
|
|
5990
6067
|
render:{
|
|
5991
6068
|
option: function(data, escape) {
|
|
@@ -6016,8 +6093,19 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
6016
6093
|
const displayLabel = (zxxOverride && data.id === 'https://eolas.l42.eu/metadata/language/zxx/')
|
|
6017
6094
|
? zxxOverride
|
|
6018
6095
|
: data.pref_label;
|
|
6096
|
+
// Created (unsaved) entries: no URI to link to, render with pending indicator
|
|
6097
|
+
if (data.created) {
|
|
6098
|
+
return `<div class="lozenge lozenge-pending" data-type="" data-category="">${escape(displayLabel)}</div>`;
|
|
6099
|
+
}
|
|
6019
6100
|
return `<div class="lozenge" data-type="${escape(data.type)}" data-category="${escape(data.category)}"><a href="${data.id}" target="_blank">${escape(displayLabel)}</a></div>`;
|
|
6020
6101
|
},
|
|
6102
|
+
option_create: function(data, escape) {
|
|
6103
|
+
const noun = getCreateNoun();
|
|
6104
|
+
if (noun) {
|
|
6105
|
+
return `<div class="create">Add new ${escape(noun)}: <strong>${escape(data.input)}</strong>…</div>`;
|
|
6106
|
+
}
|
|
6107
|
+
return `<div class="create">Add <strong>${escape(data.input)}</strong>…</div>`;
|
|
6108
|
+
},
|
|
6021
6109
|
},
|
|
6022
6110
|
});
|
|
6023
6111
|
|
|
@@ -6092,16 +6180,10 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
6092
6180
|
const ts = selector.tomselect;
|
|
6093
6181
|
if (!ts) return;
|
|
6094
6182
|
const name = selector.name;
|
|
6095
|
-
const values = ts.getValue();
|
|
6096
|
-
const valueArray = Array.isArray(values) ? values : (values ? [values] : []);
|
|
6097
6183
|
// Remove the native select values so consumers only receive the structured pairs
|
|
6098
6184
|
event.formData.delete(name);
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
if (option) {
|
|
6102
|
-
event.formData.append(`${name}[${idx}][uri]`, id);
|
|
6103
|
-
event.formData.append(`${name}[${idx}][name]`, option.pref_label);
|
|
6104
|
-
}
|
|
6185
|
+
buildFormDataEntries(name, ts.getValue(), ts.options).forEach(([key, value]) => {
|
|
6186
|
+
event.formData.append(key, value);
|
|
6105
6187
|
});
|
|
6106
6188
|
};
|
|
6107
6189
|
form.addEventListener('formdata', this._formdataHandler);
|
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>
|
|
@@ -61,6 +62,13 @@
|
|
|
61
62
|
<button type="submit">Submit</button>
|
|
62
63
|
</form>
|
|
63
64
|
<pre id="form-output"></pre>
|
|
65
|
+
<h1>Inline create (data-create)</h1>
|
|
66
|
+
<p>Person field with <code>data-create</code>: type a name with no match and pick "Add new Person: …" to create a new entry. Created entries render with a cream dashed lozenge and serialise as <code>[name]</code>-only (no <code>[uri]</code>) on submit.</p>
|
|
67
|
+
<form id="create-form" onsubmit="handleCreateSubmit(event)">
|
|
68
|
+
<label for="composer">Composer:</label><span is="lucos-search" data-api-key="${KEY_LUCOS_ARACHNE}" data-types="Person" data-create><select id="composer" name="composer" multiple></select></span>
|
|
69
|
+
<button type="submit">Submit</button>
|
|
70
|
+
</form>
|
|
71
|
+
<pre id="create-output"></pre>
|
|
64
72
|
<script>
|
|
65
73
|
function handleSubmit(event) {
|
|
66
74
|
event.preventDefault();
|
|
@@ -72,6 +80,16 @@
|
|
|
72
80
|
}
|
|
73
81
|
document.getElementById('form-output').textContent = JSON.stringify(output, null, 2);
|
|
74
82
|
}
|
|
83
|
+
function handleCreateSubmit(event) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
const data = new FormData(event.target);
|
|
86
|
+
const output = {};
|
|
87
|
+
for (const [key, value] of data.entries()) {
|
|
88
|
+
if (!output[key]) output[key] = [];
|
|
89
|
+
output[key].push(value);
|
|
90
|
+
}
|
|
91
|
+
document.getElementById('create-output').textContent = JSON.stringify(output, null, 2);
|
|
92
|
+
}
|
|
75
93
|
</script>
|
|
76
94
|
<script src="./built.js"></script>
|
|
77
95
|
</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
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildFormDataEntries } from '../web-components/form-serialise.js';
|
|
4
|
+
|
|
5
|
+
// --- Arachne-selected entries ---
|
|
6
|
+
|
|
7
|
+
test('arachne-selected entry emits [uri] and [name]', () => {
|
|
8
|
+
const options = {
|
|
9
|
+
'https://eolas.l42.eu/people/1': { pref_label: 'John Lennon', id: 'https://eolas.l42.eu/people/1' },
|
|
10
|
+
};
|
|
11
|
+
const entries = buildFormDataEntries('composer', 'https://eolas.l42.eu/people/1', options);
|
|
12
|
+
assert.deepEqual(entries, [
|
|
13
|
+
['composer[0][uri]', 'https://eolas.l42.eu/people/1'],
|
|
14
|
+
['composer[0][name]', 'John Lennon'],
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('arachne-selected entry with multiple values uses contiguous indices', () => {
|
|
19
|
+
const options = {
|
|
20
|
+
'https://eolas.l42.eu/people/1': { pref_label: 'John Lennon', id: 'https://eolas.l42.eu/people/1' },
|
|
21
|
+
'https://eolas.l42.eu/people/2': { pref_label: 'Paul McCartney', id: 'https://eolas.l42.eu/people/2' },
|
|
22
|
+
};
|
|
23
|
+
const entries = buildFormDataEntries(
|
|
24
|
+
'composer',
|
|
25
|
+
['https://eolas.l42.eu/people/1', 'https://eolas.l42.eu/people/2'],
|
|
26
|
+
options,
|
|
27
|
+
);
|
|
28
|
+
assert.deepEqual(entries, [
|
|
29
|
+
['composer[0][uri]', 'https://eolas.l42.eu/people/1'],
|
|
30
|
+
['composer[0][name]', 'John Lennon'],
|
|
31
|
+
['composer[1][uri]', 'https://eolas.l42.eu/people/2'],
|
|
32
|
+
['composer[1][name]', 'Paul McCartney'],
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// --- Created entries ---
|
|
37
|
+
|
|
38
|
+
test('created entry emits [name] only, no [uri]', () => {
|
|
39
|
+
const options = {
|
|
40
|
+
'Ringo Starr': { pref_label: 'Ringo Starr', id: 'Ringo Starr', created: true },
|
|
41
|
+
};
|
|
42
|
+
const entries = buildFormDataEntries('composer', 'Ringo Starr', options);
|
|
43
|
+
assert.deepEqual(entries, [
|
|
44
|
+
['composer[0][name]', 'Ringo Starr'],
|
|
45
|
+
]);
|
|
46
|
+
// Confirm [uri] is absent
|
|
47
|
+
const keys = entries.map(([k]) => k);
|
|
48
|
+
assert.ok(!keys.includes('composer[0][uri]'), 'uri key must not be present for created entries');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- Mixed entries ---
|
|
52
|
+
|
|
53
|
+
test('mixed created and arachne-selected entries serialise with contiguous indices and correct shapes', () => {
|
|
54
|
+
const options = {
|
|
55
|
+
'https://eolas.l42.eu/people/1': { pref_label: 'John Lennon', id: 'https://eolas.l42.eu/people/1' },
|
|
56
|
+
'George Harrison': { pref_label: 'George Harrison', id: 'George Harrison', created: true },
|
|
57
|
+
'https://eolas.l42.eu/people/3': { pref_label: 'Paul McCartney', id: 'https://eolas.l42.eu/people/3' },
|
|
58
|
+
};
|
|
59
|
+
const entries = buildFormDataEntries(
|
|
60
|
+
'composer',
|
|
61
|
+
['https://eolas.l42.eu/people/1', 'George Harrison', 'https://eolas.l42.eu/people/3'],
|
|
62
|
+
options,
|
|
63
|
+
);
|
|
64
|
+
assert.deepEqual(entries, [
|
|
65
|
+
['composer[0][uri]', 'https://eolas.l42.eu/people/1'],
|
|
66
|
+
['composer[0][name]', 'John Lennon'],
|
|
67
|
+
['composer[1][name]', 'George Harrison'], // created — name only
|
|
68
|
+
['composer[2][uri]', 'https://eolas.l42.eu/people/3'],
|
|
69
|
+
['composer[2][name]', 'Paul McCartney'],
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// --- Edge cases ---
|
|
74
|
+
|
|
75
|
+
test('no values produces no entries', () => {
|
|
76
|
+
const entries = buildFormDataEntries('composer', [], {});
|
|
77
|
+
assert.deepEqual(entries, []);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('empty string values produces no entries', () => {
|
|
81
|
+
const entries = buildFormDataEntries('composer', '', {});
|
|
82
|
+
assert.deepEqual(entries, []);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('null values produces no entries', () => {
|
|
86
|
+
const entries = buildFormDataEntries('composer', null, {});
|
|
87
|
+
assert.deepEqual(entries, []);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('value with no matching option is silently skipped', () => {
|
|
91
|
+
// Matches the existing behaviour: unknown value keys are ignored
|
|
92
|
+
const options = {
|
|
93
|
+
'https://eolas.l42.eu/people/1': { pref_label: 'John Lennon', id: 'https://eolas.l42.eu/people/1' },
|
|
94
|
+
};
|
|
95
|
+
const entries = buildFormDataEntries(
|
|
96
|
+
'composer',
|
|
97
|
+
['https://eolas.l42.eu/people/1', 'https://eolas.l42.eu/people/99'],
|
|
98
|
+
options,
|
|
99
|
+
);
|
|
100
|
+
// Only the known option contributes entries; index gaps are absent (idx 0 only)
|
|
101
|
+
assert.deepEqual(entries, [
|
|
102
|
+
['composer[0][uri]', 'https://eolas.l42.eu/people/1'],
|
|
103
|
+
['composer[0][name]', 'John Lennon'],
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('field name is preserved verbatim in keys', () => {
|
|
108
|
+
const options = { 'https://example.com/1': { pref_label: 'Test', id: 'https://example.com/1' } };
|
|
109
|
+
const entries = buildFormDataEntries('my_field_name', 'https://example.com/1', options);
|
|
110
|
+
assert.ok(entries.every(([k]) => k.startsWith('my_field_name[')));
|
|
111
|
+
});
|
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
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build formData key/value pairs for a lucos-search field.
|
|
3
|
+
*
|
|
4
|
+
* Arachne-selected entries emit both [uri] and [name]; created entries (tagged
|
|
5
|
+
* with option.created = true) emit [name] only so consumers can route them to a
|
|
6
|
+
* create-on-write path without a URI.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} name - The field name (from select.name)
|
|
9
|
+
* @param {string|string[]} values - Selected TomSelect values (ts.getValue())
|
|
10
|
+
* @param {Object} optionMap - TomSelect options map (ts.options)
|
|
11
|
+
* @returns {Array<[string, string]>} Array of [key, value] pairs to append to FormData
|
|
12
|
+
*/
|
|
13
|
+
export function buildFormDataEntries(name, values, optionMap) {
|
|
14
|
+
const valueArray = Array.isArray(values) ? values : (values ? [values] : []);
|
|
15
|
+
const entries = [];
|
|
16
|
+
valueArray.forEach((id, idx) => {
|
|
17
|
+
const option = optionMap[id];
|
|
18
|
+
if (!option) return;
|
|
19
|
+
if (option.created) {
|
|
20
|
+
// Created (no arachne match) entry: name only, no URI
|
|
21
|
+
entries.push([`${name}[${idx}][name]`, option.pref_label]);
|
|
22
|
+
} else {
|
|
23
|
+
// Arachne-selected entry: URI + name
|
|
24
|
+
entries.push([`${name}[${idx}][uri]`, id]);
|
|
25
|
+
entries.push([`${name}[${idx}][name]`, option.pref_label]);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return entries;
|
|
29
|
+
}
|
|
@@ -2,10 +2,11 @@ import TomSelect from 'tom-select';
|
|
|
2
2
|
import tomSelectStylesheet from 'tom-select/dist/css/tom-select.default.css';
|
|
3
3
|
import categoryColoursCSS from './generated/category-colours.css';
|
|
4
4
|
import { buildFilterBy } from './filter.js';
|
|
5
|
+
import { buildFormDataEntries } from './form-serialise.js';
|
|
5
6
|
|
|
6
7
|
class LucosSearchComponent extends HTMLSpanElement {
|
|
7
8
|
static get observedAttributes() {
|
|
8
|
-
return ['data-api-key','data-types','data-exclude_types','data-is-contact','data-label-override-zxx','data-common','data-preload'];
|
|
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'];
|
|
9
10
|
}
|
|
10
11
|
constructor() {
|
|
11
12
|
super();
|
|
@@ -97,6 +98,18 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
97
98
|
color: inherit;
|
|
98
99
|
text-decoration: none;
|
|
99
100
|
}
|
|
101
|
+
|
|
102
|
+
/* Pre-save visual indicator for unsaved created entries.
|
|
103
|
+
* Cream background distinguishes from both the default unknown-category grey (#555)
|
|
104
|
+
* and the white used by the Meteorological category.
|
|
105
|
+
* border-style !important needed to override TomSelect's base border shorthand. */
|
|
106
|
+
.lozenge.lozenge-pending {
|
|
107
|
+
--lozenge-background: #fffbea;
|
|
108
|
+
--lozenge-border: #999999;
|
|
109
|
+
--lozenge-text: #333333;
|
|
110
|
+
border-style: dashed !important;
|
|
111
|
+
font-style: italic;
|
|
112
|
+
}
|
|
100
113
|
`;
|
|
101
114
|
shadow.appendChild(mainStyle);
|
|
102
115
|
|
|
@@ -107,8 +120,33 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
107
120
|
const selector = component.querySelector("select");
|
|
108
121
|
if (!selector) throw new Error("Can't find select element in lucos-search");
|
|
109
122
|
selector.setAttribute("multiple", "multiple");
|
|
123
|
+
|
|
124
|
+
// Derive a noun for the "Add new <type>: <name>" create prompt when data-types is a single type
|
|
125
|
+
function getCreateNoun() {
|
|
126
|
+
const dataTypes = component.getAttribute("data-types");
|
|
127
|
+
if (!dataTypes) return null;
|
|
128
|
+
const types = dataTypes.split(",").map(t => t.trim()).filter(Boolean);
|
|
129
|
+
return types.length === 1 ? types[0] : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (component.hasAttribute("data-create")) {
|
|
133
|
+
const createTypes = component.getAttribute("data-types");
|
|
134
|
+
const createTypeList = createTypes ? createTypes.split(",").map(t => t.trim()).filter(Boolean) : [];
|
|
135
|
+
if (createTypeList.length > 1) {
|
|
136
|
+
console.warn(
|
|
137
|
+
`lucos-search: data-create is set alongside data-types="${createTypes}" which specifies ${createTypeList.length} types. ` +
|
|
138
|
+
`Server-side there will be no way to determine which type to create — data-create should only be used with a single data-types value.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
110
143
|
new TomSelect(selector, {
|
|
111
144
|
...(component.isLanguageMode || component.getAttribute("data-common") ? { optgroupField: 'lang_family', lockOptgroupOrder: true } : {}),
|
|
145
|
+
...(component.hasAttribute("data-create") ? {
|
|
146
|
+
create: function(input) {
|
|
147
|
+
return { id: input, pref_label: input, created: true };
|
|
148
|
+
},
|
|
149
|
+
} : {}),
|
|
112
150
|
valueField: 'id',
|
|
113
151
|
labelField: 'pref_label',
|
|
114
152
|
searchField: [],
|
|
@@ -154,6 +192,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
154
192
|
component.getAttribute("data-types"),
|
|
155
193
|
component.getAttribute("data-exclude_types"),
|
|
156
194
|
component.getAttribute("data-is-contact"),
|
|
195
|
+
component.getAttribute("data-allowed-origins"),
|
|
157
196
|
);
|
|
158
197
|
if (filterBy) queryParams.set("filter_by", filterBy);
|
|
159
198
|
try {
|
|
@@ -232,6 +271,7 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
232
271
|
component.getAttribute("data-types"),
|
|
233
272
|
component.getAttribute("data-exclude_types"),
|
|
234
273
|
component.getAttribute("data-is-contact"),
|
|
274
|
+
component.getAttribute("data-allowed-origins"),
|
|
235
275
|
);
|
|
236
276
|
// per_page: 250 acts as an upper bound — data-preload is intended for finite datasets
|
|
237
277
|
const preloadParams = new URLSearchParams({ q: '*', per_page: 250 });
|
|
@@ -271,8 +311,12 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
271
311
|
}
|
|
272
312
|
},
|
|
273
313
|
onItemSelect: function (item) {
|
|
274
|
-
// Tom-select prevents clicking on link in an item to work as normal, so force it here
|
|
275
|
-
|
|
314
|
+
// Tom-select prevents clicking on link in an item to work as normal, so force it here.
|
|
315
|
+
// Skip navigation for created (unsaved) entries — they have no arachne URI.
|
|
316
|
+
const value = item.dataset.value;
|
|
317
|
+
const option = this.options[value];
|
|
318
|
+
if (option && option.created) return;
|
|
319
|
+
window.open(value, '_blank').focus();
|
|
276
320
|
},
|
|
277
321
|
render:{
|
|
278
322
|
option: function(data, escape) {
|
|
@@ -303,8 +347,19 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
303
347
|
const displayLabel = (zxxOverride && data.id === 'https://eolas.l42.eu/metadata/language/zxx/')
|
|
304
348
|
? zxxOverride
|
|
305
349
|
: data.pref_label;
|
|
350
|
+
// Created (unsaved) entries: no URI to link to, render with pending indicator
|
|
351
|
+
if (data.created) {
|
|
352
|
+
return `<div class="lozenge lozenge-pending" data-type="" data-category="">${escape(displayLabel)}</div>`;
|
|
353
|
+
}
|
|
306
354
|
return `<div class="lozenge" data-type="${escape(data.type)}" data-category="${escape(data.category)}"><a href="${data.id}" target="_blank">${escape(displayLabel)}</a></div>`;
|
|
307
355
|
},
|
|
356
|
+
option_create: function(data, escape) {
|
|
357
|
+
const noun = getCreateNoun();
|
|
358
|
+
if (noun) {
|
|
359
|
+
return `<div class="create">Add new ${escape(noun)}: <strong>${escape(data.input)}</strong>…</div>`;
|
|
360
|
+
}
|
|
361
|
+
return `<div class="create">Add <strong>${escape(data.input)}</strong>…</div>`;
|
|
362
|
+
},
|
|
308
363
|
},
|
|
309
364
|
});
|
|
310
365
|
|
|
@@ -379,16 +434,10 @@ class LucosSearchComponent extends HTMLSpanElement {
|
|
|
379
434
|
const ts = selector.tomselect;
|
|
380
435
|
if (!ts) return;
|
|
381
436
|
const name = selector.name;
|
|
382
|
-
const values = ts.getValue();
|
|
383
|
-
const valueArray = Array.isArray(values) ? values : (values ? [values] : []);
|
|
384
437
|
// Remove the native select values so consumers only receive the structured pairs
|
|
385
438
|
event.formData.delete(name);
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (option) {
|
|
389
|
-
event.formData.append(`${name}[${idx}][uri]`, id);
|
|
390
|
-
event.formData.append(`${name}[${idx}][name]`, option.pref_label);
|
|
391
|
-
}
|
|
439
|
+
buildFormDataEntries(name, ts.getValue(), ts.options).forEach(([key, value]) => {
|
|
440
|
+
event.formData.append(key, value);
|
|
392
441
|
});
|
|
393
442
|
};
|
|
394
443
|
form.addEventListener('formdata', this._formdataHandler);
|