From 14e438c70d200306097ae250778d795e201ce23e Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Mon, 9 Nov 2026 02:08:24 +0300 Subject: [PATCH 1/9] Move `instancesSearch` into the corresponding sub-directory --- src/widgets/{ => instancesSearch}/instancesSearch.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/widgets/{ => instancesSearch}/instancesSearch.tsx (100%) diff --git a/src/widgets/instancesSearch.tsx b/src/widgets/instancesSearch/instancesSearch.tsx similarity index 100% rename from src/widgets/instancesSearch.tsx rename to src/widgets/instancesSearch/instancesSearch.tsx From 7ffcbafcca3327ffce8387fcd54b1310db6a9f21 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Mon, 9 Nov 2026 02:31:56 +0300 Subject: [PATCH 2/9] Add customizable AST-based search patterns to `DataProvider` and entity search components: `DataProvider` changes: * Support AST-based `LookupPattern` via `SparqlDataProvider.lookup()`; * Add `lookupElementTypes()`, `lookupPropertyTypes()` and `lookupLinkTypes()` methods to `DataProvider`; * Refactor `SparqlDataProvider` query generation into separate module `SparqlGenerator`; * Always compute counts for implicit element/link/property types in `RdfDataProvider`; `InstancesSearch`/`SearchSectionEntities` changes: * Add `SearchPattern` (separate from `LookupPattern`) to allow customizable search pattern dispaly and editors; * Add built-in search criterion options: `SearchCriterionAllOf`, `SearchCriterionAnyOf`, `SearchCriterionHasType` `SearchCriterionConnectedTo`, `SearchCriterionFromValues`, `SearchCriterionHasProperty`; * Add utility 'building blocks" for custom search criterions: `SearchCriterionBlock`, `SearchCriterionWhereGroup`, `SearchCriterionWhere`; Utility changes: * Use `@floating-ui/react-dom` for properly placed dropdown floating panels; * Add base `Autocomplete` component based on `Dropdown` and `SearchInput`; * Refactor `SearchInput` and its usages to have proper slots to display additional components inside; * Add `EntitySelect` component based on `Autocomplete` to lookup one or multiple entities by label; * Add `SchemaTypeSelect` component with default suggestions for select element/property/link types; * Extract `SchemaTypeView` from class tree to display an element/property/link type; * Add `ErrorMessage` component to display a formatted error message from an exception when possible; * Add block display mode to `InlineEntity`; * Add `useCombinedRef()`, extract `useFetchStatus()` hook; * Clarify `useAsync()` behavior on `undefined` return value from `load` callback; --- i18n/i18n.schema.json | 42 + .../en.reactodia-translation.json | 48 +- package-lock.json | 92 +- package.json | 5 +- src/coreUtils/hooks.ts | 43 +- src/data/composite/composite.ts | 61 +- src/data/composite/mergeUtils.ts | 74 +- src/data/dataProvider.ts | 164 +++- src/data/decorated/decoratedDataProvider.ts | 32 + src/data/decorated/emptyDataProvider.ts | 31 +- src/data/indexedDb/indexedDbCachedProvider.ts | 211 +++- src/data/rdf/datasetLookup.ts | 277 ++++++ src/data/rdf/rdfDataProvider.ts | 408 ++++---- src/data/rdf/rdfModel.ts | 2 +- src/data/sparql/responseHandler.ts | 13 +- src/data/sparql/sparqlDataProvider.ts | 593 ++++-------- src/data/sparql/sparqlDataProviderSettings.ts | 291 ++++-- src/data/sparql/sparqlGenerator.ts | 822 ++++++++++++++++ src/diagram/spinner.tsx | 8 +- src/editor/dataLocaleProvider.ts | 2 +- src/editor/withFetchStatus.tsx | 72 +- src/widgets/classTree/classTreeResults.tsx | 50 +- .../connectionsMenu/connectionsMenu.tsx | 18 +- src/widgets/editorForms/editRelationForm.tsx | 2 +- .../editorForms/elementTypeSelector.tsx | 12 +- src/widgets/instancesSearch/entitySelect.tsx | 110 +++ src/widgets/instancesSearch/index.ts | 16 + .../instancesSearch/instanceCriteria.tsx | 165 ++++ .../instancesSearch/instancesSearch.tsx | 249 ++--- .../instancesSearch/schemaTypeSelect.tsx | 296 ++++++ .../instancesSearch/searchCriterion.tsx | 904 ++++++++++++++++++ src/widgets/instancesSearch/searchPattern.ts | 134 +++ src/widgets/unifiedSearch/unifiedSearch.tsx | 119 ++- src/widgets/utility/autocomplete.tsx | 319 ++++++ src/widgets/utility/dropdown.tsx | 69 +- src/widgets/utility/errorMessage.tsx | 24 + src/widgets/utility/inlineEntity.tsx | 19 +- src/widgets/utility/listElementView.tsx | 2 +- src/widgets/utility/schemaTypeView.tsx | 99 ++ src/widgets/utility/searchInput.tsx | 49 +- src/workspace.ts | 9 + styles/editor/_elementSelector.scss | 11 +- styles/main.scss | 6 + styles/mixin/_zIndex.scss | 1 + styles/shared/_btnGroup.scss | 5 +- styles/utility/_autocomplete.scss | 75 ++ styles/utility/_dropdown.scss | 16 +- styles/utility/_errorMessage.scss | 11 + styles/utility/_inlineEntity.scss | 10 + styles/utility/_listElementView.scss | 5 +- styles/utility/_schemaTypeView.scss | 91 ++ styles/utility/_searchInput.scss | 72 +- styles/widgets/_classTree.scss | 50 - styles/widgets/_connectionsMenu.scss | 9 +- styles/widgets/_entitySelect.scss | 32 + styles/widgets/_instancesSearch.scss | 83 +- styles/widgets/_schemaTypeSelect.scss | 42 + styles/widgets/_searchCriterion.scss | 133 +++ styles/widgets/_toolbar.scss | 1 + styles/widgets/_unifiedSearch.scss | 36 +- test/data/indexedDbCachedProvider.test.ts | 74 ++ test/data/sparql/sparqlProviderBasic.test.ts | 38 + test/mock/mockDataProvider.ts | 81 ++ vite.config.mts | 1 + 64 files changed, 5583 insertions(+), 1256 deletions(-) create mode 100644 src/data/rdf/datasetLookup.ts create mode 100644 src/data/sparql/sparqlGenerator.ts create mode 100644 src/widgets/instancesSearch/entitySelect.tsx create mode 100644 src/widgets/instancesSearch/index.ts create mode 100644 src/widgets/instancesSearch/instanceCriteria.tsx create mode 100644 src/widgets/instancesSearch/schemaTypeSelect.tsx create mode 100644 src/widgets/instancesSearch/searchCriterion.tsx create mode 100644 src/widgets/instancesSearch/searchPattern.ts create mode 100644 src/widgets/utility/autocomplete.tsx create mode 100644 src/widgets/utility/errorMessage.tsx create mode 100644 src/widgets/utility/schemaTypeView.tsx create mode 100644 styles/utility/_autocomplete.scss create mode 100644 styles/utility/_errorMessage.scss create mode 100644 styles/utility/_schemaTypeView.scss create mode 100644 styles/widgets/_entitySelect.scss create mode 100644 styles/widgets/_schemaTypeSelect.scss create mode 100644 styles/widgets/_searchCriterion.scss diff --git a/i18n/i18n.schema.json b/i18n/i18n.schema.json index 5f5e306e..7064d5bd 100644 --- a/i18n/i18n.schema.json +++ b/i18n/i18n.schema.json @@ -263,18 +263,60 @@ "$ref": "#/$defs/Group", "additionalProperties": false, "properties": { + "add_criterion.label": { "$ref": "#/$defs/Value" }, + "add_criterion.title": { "$ref": "#/$defs/Value" }, "add_group.label": { "$ref": "#/$defs/Value" }, "add_group.title": { "$ref": "#/$defs/Value" }, "add_selected.label": { "$ref": "#/$defs/Value" }, "add_selected.title": { "$ref": "#/$defs/Value" }, + "apply_criteria.label": { "$ref": "#/$defs/Value" }, + "apply_criteria.title": { "$ref": "#/$defs/Value" }, + "cancel_criteria.label": { "$ref": "#/$defs/Value" }, + "cancel_criteria.title": { "$ref": "#/$defs/Value" }, + "clear_criteria.label": { "$ref": "#/$defs/Value" }, + "clear_criteria.title": { "$ref": "#/$defs/Value" }, + "criteria_all_of_add.label": { "$ref": "#/$defs/Value" }, + "criteria_all_of_add.title": { "$ref": "#/$defs/Value" }, + "criteria_all_of_joiner": { "$ref": "#/$defs/Value" }, + "criteria_any_of_add.label": { "$ref": "#/$defs/Value" }, + "criteria_any_of_add.title": { "$ref": "#/$defs/Value" }, + "criteria_any_of_joiner": { "$ref": "#/$defs/Value" }, + "criteria_from_values": { "$ref": "#/$defs/Value" }, + "criteria_from_values.any-entity": { "$ref": "#/$defs/Value" }, + "criteria_from_values.many-entities": { "$ref": "#/$defs/Value" }, + "criteria_from_values.header": { "$ref": "#/$defs/Value" }, + "criteria_from_values.joiner": { "$ref": "#/$defs/Value" }, + "criteria_from_values.label": { "$ref": "#/$defs/Value" }, + "criteria_has_property": { "$ref": "#/$defs/Value" }, + "criteria_has_property.any-property": { "$ref": "#/$defs/Value" }, + "criteria_has_property.header": { "$ref": "#/$defs/Value" }, + "criteria_has_property.label": { "$ref": "#/$defs/Value" }, "criteria_has_type": { "$ref": "#/$defs/Value" }, + "criteria_has_type.any_type": { "$ref": "#/$defs/Value" }, + "criteria_has_type.header": { "$ref": "#/$defs/Value" }, + "criteria_has_type.label": { "$ref": "#/$defs/Value" }, "criteria_connected": { "$ref": "#/$defs/Value" }, + "criteria_connected.any-link": { "$ref": "#/$defs/Value" }, + "criteria_connected.header": { "$ref": "#/$defs/Value" }, + "criteria_connected.label": { "$ref": "#/$defs/Value" }, + "criteria_connected.target-part": { "$ref": "#/$defs/Value" }, "criteria_connected_via": { "$ref": "#/$defs/Value" }, "criteria_connected_to_source": { "$ref": "#/$defs/Value" }, "criteria_connected_to_target": { "$ref": "#/$defs/Value" }, + "criteria_placeholder_any": { "$ref": "#/$defs/Value" }, + "criteria_placeholder_unknown": { "$ref": "#/$defs/Value" }, + "criteria_where_caption": { "$ref": "#/$defs/Value" }, + "edit_criteria.label": { "$ref": "#/$defs/Value" }, + "edit_criteria.title": { "$ref": "#/$defs/Value" }, + "error.required_at_least_one_pattern": { "$ref": "#/$defs/Value" }, + "error.required_element_type": { "$ref": "#/$defs/Value" }, + "error.required_property_type": { "$ref": "#/$defs/Value" }, "input.placeholder": { "$ref": "#/$defs/Value" }, "place_elements.command": { "$ref": "#/$defs/Value" }, "query_progress.title": { "$ref": "#/$defs/Value" }, + "remove_criterion.title": { "$ref": "#/$defs/Value" }, + "search_by.label": { "$ref": "#/$defs/Value" }, + "search_by.title": { "$ref": "#/$defs/Value" }, "show_more_results.label": { "$ref": "#/$defs/Value" }, "show_more_results.title": { "$ref": "#/$defs/Value" } } diff --git a/i18n/translations/en.reactodia-translation.json b/i18n/translations/en.reactodia-translation.json index 62047439..76630c1a 100644 --- a/i18n/translations/en.reactodia-translation.json +++ b/i18n/translations/en.reactodia-translation.json @@ -180,18 +180,60 @@ "show_only_creatable": "Show only constructible" }, "search_entities": { + "add_criterion.label": "add", + "add_criterion.title": "Add search condition", "add_group.label": "Add as group", "add_group.title": "Place selected entities on the canvas as a group", "add_selected.label": "Add selected", "add_selected.title": "Place selected entities on the canvas", + "apply_criteria.label": "apply", + "apply_criteria.title": "Apply search conditions", + "cancel_criteria.label": "cancel", + "cancel_criteria.title": "Cancel editing search conditions", + "clear_criteria.label": "clear", + "clear_criteria.title": "Clear search conditions", + "criteria_all_of_add.label": "and", + "criteria_all_of_add.title": "Add \"and\" search condition", + "criteria_all_of_joiner": "and", + "criteria_any_of_add.label": "or", + "criteria_any_of_add.title": "Add \"or\" search condition", + "criteria_any_of_joiner": "or", + "criteria_from_values": "One of {{values}}", + "criteria_from_values.any-entity": "any entity", + "criteria_from_values.many-entities": "one of {{count}} entities", + "criteria_from_values.header": "One of entities", + "criteria_from_values.joiner": " or ", + "criteria_from_values.label": "one of entities", + "criteria_has_property": "Has {{propertyType}} property values", + "criteria_has_property.any-property": "any", + "criteria_has_property.header": "Has property", + "criteria_has_property.label": "has property", "criteria_has_type": "Has type {{entityType}}", + "criteria_has_type.any_type": "any", + "criteria_has_type.header": "Has type", + "criteria_has_type.label": "has type", "criteria_connected": "Connected to {{entity}}", - "criteria_connected_via": "Connected to {{entity}} through {{relationType}}", - "criteria_connected_to_source": "Connected to {{entity}} through {{relationType}} as {{sourceIcon}}\u00A0source", - "criteria_connected_to_target": "Connected to {{entity}} through {{relationType}} as {{targetIcon}}\u00A0target", + "criteria_connected.any-link": "any link", + "criteria_connected.header": "Connected to {{entity}} by", + "criteria_connected.label": "connected to", + "criteria_connected.target-part": "entity", + "criteria_connected_via": "Connected by {{relationType}} to {{entity}}", + "criteria_connected_to_source": "Connected as {{sourceIcon}}\u00A0source by {{relationType}} to {{entity}}", + "criteria_connected_to_target": "Connected as {{targetIcon}}\u00A0target by {{relationType}} from {{entity}}", + "criteria_placeholder_any": "any entity", + "criteria_placeholder_unknown": "Unknown pattern type \"{patternType}\"", + "criteria_where_caption": "where\u00A0{{part}}:", + "edit_criteria.label": "edit", + "edit_criteria.title": "Edit search conditions", + "error.required_at_least_one_pattern": "Should have at least one sub-pattern", + "error.required_element_type": "Element type is required", + "error.required_property_type": "Property type is required", "input.placeholder": null, "place_elements.command": "Add selected elements", "query_progress.title": "Querying for entities", + "remove_criterion.title": "Remove condition", + "search_by.label": "search by", + "search_by.title": null, "show_more_results.label": "Show more", "show_more_results.title": "Load more results" }, diff --git a/package-lock.json b/package-lock.json index d3674c4a..57250eb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,21 @@ { "name": "@reactodia/workspace", - "version": "0.35.1", + "version": "0.35.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@reactodia/workspace", - "version": "0.35.1", + "version": "0.35.2", "license": "LGPL-2.1-or-later", "dependencies": { + "@floating-ui/react-dom": "^2.1.9", "@reactodia/hashmap": "^0.2.1", "@reactodia/worker-proxy": "^0.1.0", "clsx": "^2.1.1", "d3-color": "^3.1.0", "file-saver": "^2.0.5", - "n3": "^1.26.0" + "n3": "^2.13.10" }, "devDependencies": { "@comunica/query-sparql-rdfjs-lite": "^5.2.3", @@ -22,7 +23,7 @@ "@rdfjs/types": "^2.0.1", "@types/d3-color": "^3.1.3", "@types/file-saver": "^2.0.7", - "@types/n3": "^1.26.1", + "@types/n3": "^1.26.4", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", "@types/use-sync-external-store": "^1.5.0", @@ -3551,6 +3552,20 @@ "dev": true, "license": "ISC" }, + "node_modules/@comunica/actor-rdf-parse-n3/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/@comunica/actor-rdf-parse-rdfxml": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-rdfxml/-/actor-rdf-parse-rdfxml-4.5.0.tgz", @@ -5192,6 +5207,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -6190,9 +6243,9 @@ "license": "MIT" }, "node_modules/@types/n3": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.26.1.tgz", - "integrity": "sha512-TilYHzpU6ecXVJAbV+6o17Z8ZkWLWx6ZJD3IluaU4RiGHxqjU2or9fopxFHS6iXS6qcl5Mg1K3wSx9L8xxJaJQ==", + "version": "1.26.4", + "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.26.4.tgz", + "integrity": "sha512-PFEJWfs9mL7yLgjHpmVI/BfpZZPrsaDkUN8YiQXJoz8MkBsCYXys0DSo0r20y3+zqQupbUqxAYb8fFe8kbCbTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9414,7 +9467,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -9954,7 +10006,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -10121,9 +10172,9 @@ "license": "MIT" }, "node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "version": "2.13.10", + "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.10.tgz", + "integrity": "sha512-GunqYLhMwtrLbpSYpE+7sZyH8A7/7KCpgL4KAGD4RDn0rAiZGxLkyskdAlZGVyJcpwlsjeHJRlFFSUJKHpeOjA==", "license": "MIT", "dependencies": { "buffer": "^6.0.3", @@ -11181,7 +11232,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -11194,7 +11244,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -11550,7 +11599,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -11629,6 +11677,20 @@ "n3": "^1.16.3" } }, + "node_modules/shaclc-parse/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 87cb915c..b957d7f8 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,13 @@ "clean": "rimraf dist" }, "dependencies": { + "@floating-ui/react-dom": "^2.1.9", "@reactodia/hashmap": "^0.2.1", "@reactodia/worker-proxy": "^0.1.0", "clsx": "^2.1.1", "d3-color": "^3.1.0", "file-saver": "^2.0.5", - "n3": "^1.26.0" + "n3": "^2.13.10" }, "peerDependencies": { "react": "^17.0.2 || ^18 || ^19", @@ -56,7 +57,7 @@ "@rdfjs/types": "^2.0.1", "@types/d3-color": "^3.1.3", "@types/file-saver": "^2.0.7", - "@types/n3": "^1.26.1", + "@types/n3": "^1.26.4", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", "@types/use-sync-external-store": "^1.5.0", diff --git a/src/coreUtils/hooks.ts b/src/coreUtils/hooks.ts index 9ef64a3c..b37d6fa1 100644 --- a/src/coreUtils/hooks.ts +++ b/src/coreUtils/hooks.ts @@ -164,7 +164,22 @@ export interface UseAsyncResult { * @category Hooks */ export function useAsync(params: { + /** + * Input dependecies to load a value, compared with shallow array comparison. + */ input: I; + /** + * Callback to asynchronously load a value. + * + * Does not need to have stable identity, the latest provided callback + * will be used on load. To reload a value on a callback change, it can + * be added to the `input`. + * + * @param input provided input + * @param options load operation options + * @returns a promise with load result or `undefined` to cancel load operation + * while keeping the last result (`data` or `error`) + */ load: (input: I, options: { signal: AbortSignal }) => Promise | undefined; }): UseAsyncResult { const {input, load} = params; @@ -192,7 +207,10 @@ export function useAsync(params: { } ); } else { - setResult({data: undefined, status: 'completed', error: undefined}); + setResult(previous => ({ + ...previous, + status: previous.status === 'loading' ? 'completed' : previous.status, + })); } return () => controller.abort(); }, input); @@ -206,3 +224,26 @@ export function useLatest(value: T): { readonly current: T } { }); return ref; } + +export function useCombinedRef( + refA: React.Ref | undefined, + refB: React.Ref | undefined +): React.RefCallback { + return React.useCallback((value: T) => { + if (refA) { + if (typeof refA === 'function') { + refA(value); + } else { + (refA as React.MutableRefObject).current = value; + } + } + + if (refB) { + if (typeof refB === 'function') { + refB(value); + } else { + (refB as React.MutableRefObject).current = value; + } + } + }, [refA, refB]); +} diff --git a/src/data/composite/composite.ts b/src/data/composite/composite.ts index 0f4a4703..f1e8cd30 100644 --- a/src/data/composite/composite.ts +++ b/src/data/composite/composite.ts @@ -1,6 +1,7 @@ import * as Rdf from '../rdf/rdfModel'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, + DataProviderPatternSupport, LookupPattern, } from '../dataProvider'; import { ElementTypeModel, ElementTypeGraph, LinkTypeModel, ElementModel, LinkModel, PropertyTypeModel, @@ -8,15 +9,18 @@ import { } from '../model'; import { CompositeResponse, + mergeConnectedLinkStats, + mergeElementInfo, + mergeElementTypes, + mergeIndexedElementTypes, + mergeIndexedLinkTypes, + mergeIndexedPropertyTypes, mergeKnownElementTypes, mergeKnownLinkTypes, - mergeElementTypes, - mergePropertyTypes, - mergeLinkTypes, - mergeElementInfo, mergeLinksInfo, - mergeConnectedLinkStats, + mergeLinkTypes, mergeLookup, + mergePropertyTypes, } from './mergeUtils'; /** @@ -114,25 +118,49 @@ export class CompositeDataProvider implements DataProvider { return this.requestWithMerge(p => p.knownLinkTypes(params), mergeKnownLinkTypes); } + lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.requestWithMerge(p => p.lookupElementTypes(params), mergeElementTypes); + } + + lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.requestWithMerge(p => p.lookupPropertyTypes(params), mergePropertyTypes); + } + + lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.requestWithMerge(p => p.lookupLinkTypes(params), mergeLinkTypes); + } + elementTypes(params: { classIds: ReadonlyArray; signal?: AbortSignal; }): Promise> { - return this.requestWithMerge(p => p.elementTypes(params), mergeElementTypes); + return this.requestWithMerge(p => p.elementTypes(params), mergeIndexedElementTypes); } propertyTypes(params: { propertyIds: ReadonlyArray; signal?: AbortSignal; }): Promise> { - return this.requestWithMerge(p => p.propertyTypes(params), mergePropertyTypes); + return this.requestWithMerge(p => p.propertyTypes(params), mergeIndexedPropertyTypes); } linkTypes(params: { linkTypeIds: ReadonlyArray; signal?: AbortSignal; }): Promise> { - return this.requestWithMerge(p => p.linkTypes(params), mergeLinkTypes); + return this.requestWithMerge(p => p.linkTypes(params), mergeIndexedLinkTypes); } elements(params: { @@ -165,6 +193,23 @@ export class CompositeDataProvider implements DataProvider { return this.requestWithMerge(p => p.connectedLinkStats(params), mergeConnectedLinkStats); } + supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { + if (this.providers.length === 0) { + return {ok: true, errors: []}; + } + const errors: string[] = []; + for (const {provider} of this.providers) { + const result = provider.supportsPattern(pattern); + if (result.ok) { + return {ok: true, errors: []}; + } + for (const error of result.errors) { + errors.push(error); + } + } + return {ok: false, errors}; + } + lookup(params: DataProviderLookupParams): Promise { return this.requestWithMerge( p => p.lookup(params), diff --git a/src/data/composite/mergeUtils.ts b/src/data/composite/mergeUtils.ts index f7eefcdb..dc6e46be 100644 --- a/src/data/composite/mergeUtils.ts +++ b/src/data/composite/mergeUtils.ts @@ -42,13 +42,31 @@ export function mergeKnownLinkTypes(responses: CompositeResponse[] +): ElementTypeModel[] { + return mergeArrayResponses(responses, mergeClassModel); +} + +export function mergeIndexedElementTypes( + responses: CompositeResponse>[] +): Map { + return mergeMapResponses(responses, mergeClassModel); +} + export function mergePropertyTypes( + responses: CompositeResponse[] +): PropertyTypeModel[] { + return mergeArrayResponses(responses, mergePropertyType); +} + +export function mergeIndexedPropertyTypes( responses: CompositeResponse>[], ): Map { - return mergeMapResponses(responses, mergePropertyModel); + return mergeMapResponses(responses, mergePropertyType); } -function mergePropertyModel(a: PropertyTypeModel, b: PropertyTypeModel): PropertyTypeModel { +function mergePropertyType(a: PropertyTypeModel, b: PropertyTypeModel): PropertyTypeModel { return { ...a, ...b, @@ -56,13 +74,13 @@ function mergePropertyModel(a: PropertyTypeModel, b: PropertyTypeModel): Propert }; } -export function mergeElementTypes( - responses: CompositeResponse>[] -): Map { - return mergeMapResponses(responses, mergeClassModel); +export function mergeLinkTypes( + responses: CompositeResponse[] +): LinkTypeModel[] { + return mergeArrayResponses(responses, mergeLinkType); } -export function mergeLinkTypes( +export function mergeIndexedLinkTypes( responses: CompositeResponse>[] ): Map { return mergeMapResponses(responses, mergeLinkType); @@ -79,20 +97,6 @@ function mergeLinkType(a: LinkTypeModel, b: LinkTypeModel): LinkTypeModel { }; } -function mergeMapResponses( - responses: Iterable>>, - mergeItems: (a: V, b: V) => V -): Map { - const result = new Map(); - for (const [response] of responses) { - for (const [key, model] of response) { - const existing = result.get(key); - result.set(key, existing ? mergeItems(existing, model) : model); - } - } - return result; -} - export function mergeElementInfo( responses: CompositeResponse>[], originProperty: PropertyTypeIri @@ -297,3 +301,31 @@ function mergeCounts(a: number | undefined, b: number | undefined): number | und } return (a ?? 0) + (b ?? 0); } + +function mergeArrayResponses( + responses: Iterable>, + mergeItems: (a: T, b: T) => T +): T[] { + const result = new Map(); + for (const [response] of responses) { + for (const model of response) { + const existing = result.get(model.id); + result.set(model.id, existing ? mergeItems(existing, model) : model); + } + } + return Array.from(result.values()); +} + +function mergeMapResponses( + responses: Iterable>>, + mergeItems: (a: V, b: V) => V +): Map { + const result = new Map(); + for (const [response] of responses) { + for (const [key, model] of response) { + const existing = result.get(key); + result.set(key, existing ? mergeItems(existing, model) : model); + } + } + return result; +} diff --git a/src/data/dataProvider.ts b/src/data/dataProvider.ts index 2ac0a4e0..55ab812f 100644 --- a/src/data/dataProvider.ts +++ b/src/data/dataProvider.ts @@ -36,6 +36,66 @@ export interface DataProvider { signal?: AbortSignal; }): Promise; + /** + * Gets known element types filtered by a keyword. + */ + lookupElementTypes(params: { + /** + * Filter by a text lookup. + */ + text: string; + /** + * Limit number of element types returned. + * + * Default value depends on the provider implementation. + */ + limit?: number; + /** + * Cancellation signal. + */ + signal?: AbortSignal; + }): Promise; + + /** + * Gets known property types filtered by a keyword. + */ + lookupPropertyTypes(params: { + /** + * Filter by a text lookup. + */ + text: string; + /** + * Limit number of property types returned. + * + * Default value depends on the provider implementation. + */ + limit?: number; + /** + * Cancellation signal. + */ + signal?: AbortSignal; + }): Promise; + + /** + * Gets known link types filtered by a keyword. + */ + lookupLinkTypes(params: { + /** + * Filter by a text lookup. + */ + text: string; + /** + * Limit number of link types returned. + * + * Default value depends on the provider implementation. + */ + limit?: number; + /** + * Cancellation signal. + */ + signal?: AbortSignal; + }): Promise; + /** * Gets the data for the specified element types. */ @@ -138,6 +198,8 @@ export interface DataProvider { signal?: AbortSignal; }): Promise; + supportsPattern(pattern: LookupPattern): DataProviderPatternSupport; + /** * Looks up elements with different filters: * - by an element type via `elementTypeId`; @@ -176,6 +238,11 @@ export interface DataProviderLinkCount { readonly inexact?: boolean; } +export interface DataProviderPatternSupport { + readonly ok: boolean; + readonly errors: readonly string[]; +} + /** * Parameters for {@link DataProvider.lookup} operation. * @@ -212,12 +279,17 @@ export interface DataProviderLookupParams { */ linkDirection?: 'in' | 'out'; + /** + * Filter by a lookup pattern. + */ + pattern?: LookupPattern; + /** * Limit number of elements returned. * * Pass `null` to explicitly disable the limit. * - * Default depends on the provider implementation. + * Default value depends on the provider implementation. */ limit?: number | null; @@ -227,6 +299,53 @@ export interface DataProviderLookupParams { signal?: AbortSignal; } +export type LookupPattern = + | AllOfLookupPattern + | AnyOfLookupPattern + | WithTextLookupPattern + | HasTypeLookupPattern + | ConnectedToLookupPattern + | FromValuesLookupPattern + | HasPropertyLookupPattern; + +export interface AllOfLookupPattern { + readonly type: 'allOf'; + readonly patterns: readonly LookupPattern[]; +} + +export interface AnyOfLookupPattern { + readonly type: 'anyOf'; + readonly patterns: readonly LookupPattern[]; +} + +export interface HasTypeLookupPattern { + readonly type: 'hasType'; + readonly elementType: ElementTypeIri; +} + +export interface ConnectedToLookupPattern { + readonly type: 'connectedTo'; + readonly linkType?: LinkTypeIri; + readonly linkDirection?: 'in' | 'out'; + readonly target?: LookupPattern; +} + +export interface FromValuesLookupPattern { + readonly type: 'fromValues'; + readonly values: readonly ElementIri[]; +} + +export interface HasPropertyLookupPattern { + readonly type: 'hasProperty'; + readonly propertyType: PropertyTypeIri; + readonly values?: ReadonlyArray; +} + +export interface WithTextLookupPattern { + readonly type: 'withText'; + readonly text: string; +} + /** * Describes an element with information on which link types and directions * are used to connect it to other elements. @@ -256,3 +375,46 @@ export interface DataProviderLookupItem { */ readonly outLinks: ReadonlySet; } + +export function lookupPatternOf(params: Omit): LookupPattern { + const { + elementTypeId, refElementId, refElementLinkId, linkDirection, text, pattern, + } = params; + + const patterns: LookupPattern[] = []; + + if (elementTypeId) { + patterns.push({ + type: 'hasType', + elementType: elementTypeId, + }); + } + + if (refElementId || refElementLinkId || linkDirection) { + patterns.push({ + type: 'connectedTo', + linkType: refElementLinkId, + linkDirection: linkDirection, + target: refElementId ? { + type: 'fromValues', + values: [refElementId], + } : undefined, + }); + } + + if (text) { + patterns.push({ + type: 'withText', + text, + }); + } + + if (pattern) { + patterns.push(pattern); + } + + return patterns.length === 1 ? patterns[0] : { + type: 'allOf', + patterns, + }; +} diff --git a/src/data/decorated/decoratedDataProvider.ts b/src/data/decorated/decoratedDataProvider.ts index f7b3c5e8..302c12c2 100644 --- a/src/data/decorated/decoratedDataProvider.ts +++ b/src/data/decorated/decoratedDataProvider.ts @@ -3,6 +3,7 @@ import { delay } from '../../coreUtils/async'; import type * as Rdf from '../rdf/rdfModel'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, + DataProviderPatternSupport, LookupPattern, } from '../dataProvider'; import { ElementTypeModel, ElementTypeGraph, LinkTypeModel, ElementModel, LinkModel, @@ -33,6 +34,9 @@ export interface DecoratedDataProviderOptions { export type DecoratedMethodName = | 'knownElementTypes' | 'knownLinkTypes' + | 'lookupElementTypes' + | 'lookupPropertyTypes' + | 'lookupLinkTypes' | 'elementTypes' | 'propertyTypes' | 'linkTypes' @@ -93,6 +97,30 @@ export class DecoratedDataProvider implements DataProvider { return this.decorate('knownLinkTypes', [params]); } + lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.decorate('lookupElementTypes', [params]); + } + + lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.decorate('lookupPropertyTypes', [params]); + } + + lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.decorate('lookupLinkTypes', [params]); + } + elementTypes(params: { classIds: ReadonlyArray; signal?: AbortSignal; @@ -138,6 +166,10 @@ export class DecoratedDataProvider implements DataProvider { return this.decorate('connectedLinkStats', [params]); } + supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { + return this.baseProvider.supportsPattern(pattern); + } + lookup(params: DataProviderLookupParams): Promise { return this.decorate('lookup', [params]); } diff --git a/src/data/decorated/emptyDataProvider.ts b/src/data/decorated/emptyDataProvider.ts index dff8f346..f1db752b 100644 --- a/src/data/decorated/emptyDataProvider.ts +++ b/src/data/decorated/emptyDataProvider.ts @@ -3,7 +3,8 @@ import { LinkTypeIri, ElementIri, ElementModel, LinkModel, } from '../model'; import { - DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, + DataProvider, DataProviderLinkCount, DataProviderPatternSupport, + DataProviderLookupParams, DataProviderLookupItem, LookupPattern, } from '../dataProvider'; import { DataFactory, DefaultDataFactory } from '../rdf/rdfModel'; @@ -32,6 +33,30 @@ export class EmptyDataProvider implements DataProvider { return Promise.resolve([]); } + lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return Promise.resolve([]); + } + + lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return Promise.resolve([]); + } + + lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return Promise.resolve([]); + } + elementTypes(params: { classIds: readonly ElementTypeIri[]; signal?: AbortSignal | undefined; @@ -77,6 +102,10 @@ export class EmptyDataProvider implements DataProvider { return Promise.resolve([]); } + supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { + return {ok: false, errors: []}; + } + lookup(params: DataProviderLookupParams): Promise { return Promise.resolve([]); } diff --git a/src/data/indexedDb/indexedDbCachedProvider.ts b/src/data/indexedDb/indexedDbCachedProvider.ts index b5fc7c97..17a17642 100644 --- a/src/data/indexedDb/indexedDbCachedProvider.ts +++ b/src/data/indexedDb/indexedDbCachedProvider.ts @@ -1,12 +1,13 @@ import { AsyncLock } from '../../coreUtils/async'; import { multimapAdd } from '../../coreUtils/collections'; -import type * as Rdf from '../rdf/rdfModel'; +import * as Rdf from '../rdf/rdfModel'; import { ElementTypeGraph, LinkTypeModel, ElementTypeIri, ElementTypeModel, PropertyTypeIri, PropertyTypeModel, LinkTypeIri, ElementIri, ElementModel, LinkModel, } from '../model'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, + DataProviderPatternSupport, LookupPattern, lookupPatternOf, } from '../dataProvider'; import { AdjacencyRange, AdjacencyBlock, subtractAdjacencyBlocks, hashAdjacencyRange } from './adjacencyBlocks'; @@ -62,6 +63,9 @@ export interface IndexedDbCachedProviderOptions { const enum ObjectStore { knownElementTypes = 'knownElementTypes', knownLinkTypes = 'knownLinkTypes', + lookupElementTypes = 'lookupElementTypes', + lookupPropertyTypes = 'lookupPropertyTypes', + lookupLinkTypes = 'lookupLinkTypes', elementTypes = 'elementTypes', linkTypes = 'linkTypes', propertyTypes = 'propertyTypes', @@ -96,6 +100,15 @@ interface KnownLinkTypesRecord { readonly value: LinkTypeModel[]; } +type LookupTypesKey = [pattern: string, limit: string]; +const LOOKUP_TYPES_KEY_PROPERTIES: Array> = + ['pattern', 'limit']; +interface LookupTypesRecord { + readonly pattern: string; + readonly limit: string; + readonly result: T[]; +} + type LinkRecordKey = [ElementIri, ElementIri]; interface LinkRecord extends LinkModel { __id: number; @@ -119,22 +132,10 @@ interface ConnectedLinkStatsRecord { readonly stats: DataProviderLinkCount[]; } -type LookupLinkDirectionKey = 'in' | 'out' | ''; -type LookupKey = [ElementTypeIri, ElementIri, LinkTypeIri, LookupLinkDirectionKey, string, string]; -const LOOKUP_KEY_PROPERTIES: Array = [ - 'elementType', - 'element', - 'link', - 'direction', - 'text', - 'limit', -]; +type LookupKey = [pattern: string, limit: string]; +const LOOKUP_KEY_PROPERTIES: Array = ['pattern', 'limit']; interface LookupRecord { - readonly elementType: ElementTypeIri; - readonly element: ElementIri; - readonly link: LinkTypeIri; - readonly direction: LookupLinkDirectionKey; - readonly text: string; + readonly pattern: string; readonly limit: string; readonly result: DataProviderLookupItem[]; } @@ -146,7 +147,7 @@ interface LookupRecord { * @category Data */ export class IndexedDbCachedProvider implements DataProvider { - static readonly DB_VERSION = 4; + static readonly DB_VERSION = 5; private readonly hasher = new Sha256(); @@ -212,6 +213,21 @@ export class IndexedDbCachedProvider implements DataProvider { keyPath: 'id', }); } + if (!db.objectStoreNames.contains(ObjectStore.lookupElementTypes)) { + db.createObjectStore(ObjectStore.lookupElementTypes, { + keyPath: LOOKUP_TYPES_KEY_PROPERTIES, + }); + } + if (!db.objectStoreNames.contains(ObjectStore.lookupLinkTypes)) { + db.createObjectStore(ObjectStore.lookupLinkTypes, { + keyPath: LOOKUP_TYPES_KEY_PROPERTIES, + }); + } + if (!db.objectStoreNames.contains(ObjectStore.lookupPropertyTypes)) { + db.createObjectStore(ObjectStore.lookupPropertyTypes, { + keyPath: LOOKUP_TYPES_KEY_PROPERTIES, + }); + } if (!db.objectStoreNames.contains(ObjectStore.elementTypes)) { const keyPath: keyof ElementTypeModel = 'id'; db.createObjectStore(ObjectStore.elementTypes, {keyPath}); @@ -316,6 +332,77 @@ export class IndexedDbCachedProvider implements DataProvider { return result.value; } + async lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal | undefined; + }): Promise { + return this.lookupTypes( + params, + ObjectStore.lookupElementTypes, + () => this.baseProvider.lookupElementTypes(params) + ); + } + + async lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal | undefined; + }): Promise { + return this.lookupTypes( + params, + ObjectStore.lookupPropertyTypes, + () => this.baseProvider.lookupPropertyTypes(params) + ); + } + + async lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal | undefined; + }): Promise { + return this.lookupTypes( + params, + ObjectStore.lookupLinkTypes, + () => this.baseProvider.lookupLinkTypes(params) + ); + } + + private async lookupTypes( + params: { + text: string; + limit?: number; + signal?: AbortSignal | undefined; + }, + store: ObjectStore, + lookupItems: () => Promise + ): Promise { + if (!this.cacheTextLookups) { + return lookupItems(); + } + const key: LookupTypesKey = [ + JSON.stringify(serializeTextLookup(params.text)), + ( + params.limit === undefined ? '' : + params.limit === null ? 'null' : + String(params.limit) + ) + ]; + const db = await this.openDb(); + const record = await fetchSingleWithDbCache( + db, + store, + key, + async ([pattern, limit]): Promise> => ({ + pattern, + limit, + result: await lookupItems(), + }) + ); + rehydrateLabels(record.result, this.factory); + return record.result; + } + async elementTypes(params: { classIds: readonly ElementTypeIri[]; signal?: AbortSignal | undefined; @@ -746,16 +833,17 @@ export class IndexedDbCachedProvider implements DataProvider { return result.stats; } + supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { + return this.baseProvider.supportsPattern(pattern); + } + async lookup(params: DataProviderLookupParams): Promise { - if (!this.cacheTextLookups && params.text !== undefined) { + const fullPattern = lookupPatternOf(params); + if (!this.cacheTextLookups && hasTextPatternOrFilter(fullPattern)) { return this.baseProvider.lookup(params); } const key: LookupKey = [ - params.elementTypeId ?? '', - params.refElementId ?? '', - params.refElementLinkId ?? '', - params.linkDirection ?? '', - params.text ?? '', + JSON.stringify(serializePattern(fullPattern)), ( params.limit === undefined ? '' : params.limit === null ? 'null' : @@ -767,19 +855,8 @@ export class IndexedDbCachedProvider implements DataProvider { db, ObjectStore.lookup, key, - async ([ - elementType, - element, - link, - direction, - text, - limit, - ]): Promise => ({ - elementType, - element, - link, - direction, - text, + async ([pattern, limit]): Promise => ({ + pattern, limit, result: await this.baseProvider.lookup(params), }) @@ -1123,3 +1200,65 @@ function rehydrateTerm( return term; } } + +function hasTextPatternOrFilter(pattern: LookupPattern): boolean { + switch (pattern.type) { + case 'allOf': + case 'anyOf': { + return pattern.patterns.some(hasTextPatternOrFilter); + } + case 'withText': { + return true; + } + case 'hasProperty': { + // return pattern.filter !== undefined; + return false; + } + default: { + return false; + } + } +} + +type SExprLike = readonly (string | null | SExprLike)[]; + +function serializeTextLookup(text: string): SExprLike { + return ['withText', text]; +} + +function serializePattern(pattern: LookupPattern): SExprLike { + switch (pattern.type) { + case 'allOf': + case 'anyOf': { + return [pattern.type, pattern.patterns.map(serializePattern)]; + } + case 'hasType': { + return [pattern.type, pattern.elementType]; + } + case 'connectedTo': { + return [ + pattern.type, + pattern.linkType ?? null, + pattern.linkDirection ?? null, + pattern.target ? serializePattern(pattern.target) : null, + ]; + } + case 'withText': { + return [pattern.type, pattern.text]; + } + case 'fromValues': { + return [pattern.type, pattern.values]; + } + case 'hasProperty': { + return [ + pattern.type, + pattern.propertyType, + (pattern.values ?? []).map(Rdf.termToString), + /* serializeFilter(pattern.filter) */ null + ]; + } + default: { + return []; + } + } +} diff --git a/src/data/rdf/datasetLookup.ts b/src/data/rdf/datasetLookup.ts new file mode 100644 index 00000000..08b14782 --- /dev/null +++ b/src/data/rdf/datasetLookup.ts @@ -0,0 +1,277 @@ +import { HashMap } from '@reactodia/hashmap'; + +import { LookupPattern } from '../dataProvider'; +import { ElementIri, ElementTypeIri, LinkTypeIri, PropertyTypeIri } from '../model'; +import { makeCaseInsensitiveFilter } from '../utils'; + +import { MemoryDataset } from './memoryDataset'; +import * as Rdf from './rdfModel'; + +interface MatchItem { + readonly term: Rdf.NamedNode | Rdf.BlankNode; + outLinks?: Set; + inLinks?: Set; +} + +export interface MatchPatternContext { + readonly dataset: MemoryDataset; + readonly factory: Rdf.DataFactory; + readonly typePredicate: Rdf.NamedNode; + readonly labelPredicate: Rdf.NamedNode | null; +} + +export function matchPattern( + pattern: LookupPattern, + context: MatchPatternContext +): HashMap { + return joinWithPattern(undefined, pattern, context); +} + +function joinWithPattern( + previous: HashMap | undefined, + pattern: LookupPattern, + context: MatchPatternContext +): HashMap { + switch (pattern.type) { + case 'allOf': { + if (pattern.patterns.length === 0) { + return makeItemMap(); + } + let items = previous; + for (const p of pattern.patterns) { + items = joinWithPattern(items, p, context); + } + return items ?? makeItemMap(); + } + case 'anyOf': { + const items = makeItemMap(); + for (const p of pattern.patterns) { + const next = joinWithPattern(previous, p, context); + for (const m of next.values()) { + const item = items.get(m.term); + items.set(m.term, item ? mergeItem(item, m) : m); + } + } + return items; + } + case 'withText': { + const items = makeItemMap(); + if (context.labelPredicate) { + const textFilter = makeCaseInsensitiveFilter(pattern.text); + const iterable = previous + ? iterateWithAllSubjects(context.dataset, context.labelPredicate, previous.values()) + : context.dataset.iterateMatches(null, context.labelPredicate, null); + for (const t of iterable) { + if ( + isResourceTerm(t.subject) && + t.object.termType === 'Literal' && + textFilter(t.object.value) && + !items.has(t.subject) + ) { + items.set(t.subject, {term: t.subject}); + } + } + } + return items; + } + case 'hasType': { + const typeTerm = decodeTerm(pattern.elementType, context.factory); + const items = makeItemMap(); + if (previous) { + for (const item of previous.values()) { + if (context.dataset.hasMatches(item.term, context.typePredicate, typeTerm)) { + items.set(item.term, item); + } + } + } else { + for (const t of context.dataset.iterateMatches(null, context.typePredicate, typeTerm)) { + if (isResourceTerm(t.subject) && !items.has(t.subject)) { + items.set(t.subject, {term: t.subject}); + } + } + } + return items; + } + case 'connectedTo': { + const targets = pattern.target + ? Array.from(joinWithPattern(undefined, pattern.target, context).keys()) + : [null]; + const linkTerm = pattern.linkType ? decodeTerm(pattern.linkType, context.factory) : null; + const items = makeItemMap(); + if (!pattern.linkDirection || pattern.linkDirection === 'out') { + for (const valueTerm of targets) { + for (const t of context.dataset.iterateMatches(valueTerm, linkTerm, null)) { + if ( + t.predicate.termType === 'NamedNode' && + isResourceTerm(t.object) && + (!previous || previous.has(t.object)) + ) { + const term = t.object; + let item = items.get(term); + if (!item) { + item = {term}; + items.set(term, item); + } + const predicate: LinkTypeIri = encodeTerm(t.predicate); + if (!item.outLinks) { + item.outLinks = new Set(); + } + item.outLinks.add(predicate); + } + } + } + } + if (!pattern.linkDirection || pattern.linkDirection === 'in') { + for (const valueTerm of targets) { + for (const t of context.dataset.iterateMatches(null, linkTerm, valueTerm)) { + if ( + t.predicate.termType === 'NamedNode' && + isResourceTerm(t.subject) && + (!previous || previous.has(t.subject)) + ) { + const term = t.subject; + let item = items.get(term); + if (!item) { + item = {term}; + items.set(term, item); + } + const predicate: LinkTypeIri = encodeTerm(t.predicate); + if (!item.inLinks) { + item.inLinks = new Set(); + } + item.inLinks.add(predicate); + } + } + } + } + return items; + } + case 'fromValues': { + const items = makeItemMap(); + for (const value of pattern.values) { + const valueTerm = decodeTerm(value, context.factory); + if (!previous || previous.has(valueTerm)) { + items.set(valueTerm, {term: valueTerm}); + } + } + return items; + } + case 'hasProperty': { + const providedTerms = pattern.values ?? []; + const propertyTerm = decodeTerm(pattern.propertyType, context.factory); + const items = makeItemMap(); + if (propertyTerm.termType === 'BlankNode') { + return items; + } + const iterable = ( + previous ? iterateWithAllSubjects(context.dataset, propertyTerm, previous.values()) : + providedTerms.length === 0 ? context.dataset.iterateMatches(null, propertyTerm, null) : + iterateWithAllObjects(context.dataset, propertyTerm, providedTerms) + ); + for (const t of iterable) { + if ( + isResourceTerm(t.subject) && + (!previous || providedTerms.find(value => Rdf.equalTerms(t.object, value))) + ) { + items.set(t.subject, {term: t.subject}); + } + } + return items; + } + } +}; + +function makeItemMap() { + return new HashMap( + Rdf.hashTerm, Rdf.equalTerms + ); +} +function mergeItem(a: MatchItem, b: MatchItem): MatchItem { + if (!a.inLinks && !a.outLinks) { + return b; + } else if (!b.inLinks && !b.outLinks) { + return a; + } else { + let inLinks: Set | undefined; + if (a.inLinks && b.inLinks) { + inLinks = new Set(a.inLinks); + for (const link of b.inLinks) { + inLinks.add(link); + } + } else { + inLinks = a.inLinks ?? b.inLinks; + } + + let outLinks: Set | undefined; + if (a.outLinks && b.outLinks) { + outLinks = new Set(a.outLinks); + for (const link of b.outLinks) { + outLinks.add(link); + } + } else { + outLinks = a.outLinks ?? b.outLinks; + } + + return { + term: a.term, + inLinks, + outLinks, + }; + } +} + +function* iterateWithAllSubjects( + dataset: MemoryDataset, + predicate: Rdf.NamedNode, + items: Iterable +): IterableIterator { + for (const item of items) { + yield* dataset.iterateMatches(item.term, predicate, null); + } +} + +function* iterateWithAllObjects( + dataset: MemoryDataset, + predicate: Rdf.NamedNode, + objects: readonly Rdf.Quad['object'][] +): IterableIterator { + for (const object of objects) { + yield* dataset.iterateMatches(null, predicate, object); + } +} + +export function isResourceTerm(term: Rdf.Term): term is Rdf.NamedNode | Rdf.BlankNode { + switch (term.termType) { + case 'NamedNode': + case 'BlankNode': + return true; + default: + return false; + } +} + +const BLANK_PREFIX = 'urn:reactodia:blank:rdf:'; + +export function encodeTerm(term: Rdf.NamedNode | Rdf.BlankNode): string { + switch (term.termType) { + case 'NamedNode': + return term.value; + case 'BlankNode': + return BLANK_PREFIX + term.value; + default: + throw new Error( + `Unexpected term type to encode: ${(term as Rdf.Term).termType}` + ); + } +} + +export function decodeTerm( + iri: ElementIri | ElementTypeIri | LinkTypeIri | PropertyTypeIri, + factory: Rdf.DataFactory +): Rdf.NamedNode | Rdf.BlankNode { + if (iri.startsWith(BLANK_PREFIX)) { + return factory.blankNode(iri.substring(BLANK_PREFIX.length)); + } else { + return factory.namedNode(iri); + } +} diff --git a/src/data/rdf/rdfDataProvider.ts b/src/data/rdf/rdfDataProvider.ts index dee40117..7fb53fed 100644 --- a/src/data/rdf/rdfDataProvider.ts +++ b/src/data/rdf/rdfDataProvider.ts @@ -1,4 +1,4 @@ -import { HashMap, HashSet } from '@reactodia/hashmap'; +import { HashSet } from '@reactodia/hashmap'; import { ElementTypeGraph, ElementTypeModel, LinkTypeModel, ElementModel, LinkModel, PropertyTypeModel, @@ -7,9 +7,11 @@ import { } from '../model'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, + DataProviderPatternSupport, LookupPattern, lookupPatternOf, } from '../dataProvider'; import { makeCaseInsensitiveFilter } from '../utils'; +import { matchPattern, encodeTerm, decodeTerm, isResourceTerm } from './datasetLookup'; import { MemoryDataset, IndexQuadBy, indexedDataset } from './memoryDataset'; import * as Rdf from './rdfModel'; import { owl, rdf, rdfs, schema } from './vocabulary'; @@ -73,16 +75,31 @@ export interface RdfDataProviderOptions { * **Default**: * ```json * [ - * "http://www.w3.org/2002/07/owl#ObjectProperty", * "http://www.w3.org/1999/02/22-rdf-syntax-ns#Property" * ] * ``` */ + readonly propertyOrLinkBaseTypes?: ReadonlyArray; + /** + * **Default**: + * ```json + * [ + * "http://www.w3.org/2002/07/owl#DatatypeProperty" + * ] + * ``` + */ + readonly propertyTypeBaseTypes?: ReadonlyArray; + /** + * **Default**: + * ```json + * [ + * "http://www.w3.org/2002/07/owl#ObjectProperty" + * ] + * ``` + */ readonly linkTypeBaseTypes?: ReadonlyArray; } -const BLANK_PREFIX = 'urn:reactodia:blank:rdf:'; - /** * Provides graph data from in-memory [RDF/JS-compatible](https://rdf.js.org/data-model-spec/) * graph dataset. @@ -101,10 +118,16 @@ export class RdfDataProvider implements DataProvider { private readonly datatypePredicates: ReadonlySet; private readonly elementTypeBaseTypes: ReadonlyArray; private readonly elementSubtypePredicate: Rdf.NamedNode | null; + private readonly propertyOrLinkBaseTypes: ReadonlyArray; + private readonly propertyTypeBaseTypes: ReadonlyArray; private readonly linkTypeBaseTypes: ReadonlyArray; private readonly EMPTY_LINKS: ReadonlySet = new Set(); + private readonly elementCount = new Map(); + private readonly linkCount = new Map(); + private readonly propertyCount = new Map(); + constructor(options: RdfDataProviderOptions = {}) { this.factory = options.factory ?? Rdf.DefaultDataFactory; this.dataset = indexedDataset( @@ -124,7 +147,11 @@ export class RdfDataProvider implements DataProvider { .map(iri => this.factory.namedNode(iri)); this.elementSubtypePredicate = options.elementSubtypePredicate === null ? null : this.factory.namedNode(options.elementSubtypePredicate ?? rdfs.subClassOf); - this.linkTypeBaseTypes = (options.linkTypeBaseTypes ?? [owl.ObjectProperty, rdf.Property]) + this.propertyOrLinkBaseTypes = (options.propertyOrLinkBaseTypes ?? [rdf.Property]) + .map(iri => this.factory.namedNode(iri)); + this.propertyTypeBaseTypes = (options.propertyTypeBaseTypes ?? [owl.DatatypeProperty]) + .map(iri => this.factory.namedNode(iri)); + this.linkTypeBaseTypes = (options.linkTypeBaseTypes ?? [owl.ObjectProperty]) .map(iri => this.factory.namedNode(iri)); } @@ -133,16 +160,40 @@ export class RdfDataProvider implements DataProvider { * to be able to query it via this provider's methods. */ addGraph(quads: Iterable): void { - if (this.acceptBlankNodes) { - this.dataset.addAll(quads); - } else { - for (const q of quads) { - if (!( - q.subject.termType === 'BlankNode' || - q.object.termType === 'BlankNode' || - q.graph.termType === 'BlankNode' - )) { - this.dataset.add(q); + for (const q of quads) { + if (this.dataset.has(q)) { + continue; + } else if (this.acceptBlankNodes || !( + q.subject.termType === 'BlankNode' || + q.object.termType === 'BlankNode' || + q.graph.termType === 'BlankNode' + )) { + this.dataset.add(q); + + if (q.predicate.termType === 'NamedNode') { + if ( + q.predicate.value === this.typePredicate.value && + q.object.termType === 'NamedNode' + ) { + const count = this.elementCount.get(q.object.value) ?? 0; + this.elementCount.set(q.object.value, count + 1); + } else { + if ( + q.object.termType === 'NamedNode' && + !this.datatypePredicates.has(q.predicate.value) + ) { + const count = this.linkCount.get(q.predicate.value) ?? 0; + this.linkCount.set(q.predicate.value, count + 1); + } else if ( + q.object.termType === 'Literal' || ( + this.datatypePredicates.has(q.predicate.value) && + q.object.termType === 'NamedNode' + ) + ) { + const count = this.propertyCount.get(q.predicate.value) ?? 0; + this.propertyCount.set(q.predicate.value, count + 1); + } + } } } } @@ -153,6 +204,9 @@ export class RdfDataProvider implements DataProvider { */ clear(): void { this.dataset.clear(); + this.elementCount.clear(); + this.linkCount.clear(); + this.propertyCount.clear(); } /** @@ -182,7 +236,7 @@ export class RdfDataProvider implements DataProvider { knownElementTypes(params: { signal?: AbortSignal; }): Promise { - const typeCounts = this.computeTypeCounts(); + const typeCounts = new Map(this.elementCount); for (const baseType of this.elementTypeBaseTypes) { for (const t of this.dataset.iterateMatches(null, this.typePredicate, baseType)) { if (isResourceTerm(t.subject)) { @@ -239,13 +293,15 @@ export class RdfDataProvider implements DataProvider { knownLinkTypes(params: { signal?: AbortSignal; }): Promise { - const linkCounts = this.computeLinkCounts(); - for (const baseType of this.linkTypeBaseTypes) { - for (const t of this.dataset.iterateMatches(null, this.typePredicate, baseType)) { - if (isResourceTerm(t.subject)) { - const linkTypeId: LinkTypeIri = this.encodeTerm(t.subject); - if (!linkCounts.has(linkTypeId)) { - linkCounts.set(linkTypeId, 0); + const linkCounts = new Map(this.linkCount); + for (const baseTypes of [this.propertyOrLinkBaseTypes, this.linkTypeBaseTypes]) { + for (const baseType of baseTypes) { + for (const t of this.dataset.iterateMatches(null, this.typePredicate, baseType)) { + if (isResourceTerm(t.subject)) { + const linkTypeId: LinkTypeIri = this.encodeTerm(t.subject); + if (!linkCounts.has(linkTypeId)) { + linkCounts.set(linkTypeId, 0); + } } } } @@ -264,6 +320,146 @@ export class RdfDataProvider implements DataProvider { return Promise.resolve(Array.from(models.values())); } + lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {text, limit} = params; + return Promise.resolve(this.lookupSchemaItems({ + implicitValues: this.elementCount.keys(), + includeTypes: this.elementTypeBaseTypes, + excludeTypes: [], + allowBlanks: true, + createItem: (idTerm, label): ElementTypeModel => { + const id = this.encodeTerm(idTerm); + const count = this.elementCount.get(id); + return {id, label, count}; + }, + text, + limit, + })); + } + + lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {text, limit} = params; + return Promise.resolve(this.lookupSchemaItems({ + implicitValues: this.propertyCount.keys(), + includeTypes: [ + ...this.propertyOrLinkBaseTypes, + ...this.propertyTypeBaseTypes, + ], + excludeTypes: this.linkTypeBaseTypes, + createItem: (id, label): PropertyTypeModel => ({ + id: this.encodeTerm(id), + label, + }), + text, + limit, + })); + } + + lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {text, limit} = params; + return Promise.resolve(this.lookupSchemaItems({ + implicitValues: this.linkCount.keys(), + includeTypes: [ + ...this.propertyOrLinkBaseTypes, + ...this.linkTypeBaseTypes, + ], + excludeTypes: this.propertyTypeBaseTypes, + createItem: (idTerm, label): LinkTypeModel => { + const id = this.encodeTerm(idTerm); + const count = this.linkCount.get(id); + return {id, label, count}; + }, + text, + limit, + })); + } + + private lookupSchemaItems(params: { + implicitValues: Iterable; + includeTypes: readonly Rdf.NamedNode[], + excludeTypes: readonly Rdf.NamedNode[], + allowBlanks?: boolean, + createItem: (id: Rdf.NamedNode | Rdf.BlankNode, label: Rdf.Literal[]) => T, + text: string; + limit?: number; + }): T[] { + const {implicitValues, includeTypes, excludeTypes, allowBlanks, createItem, text} = params; + const limit = typeof params.limit === 'number' ? params.limit : Number.POSITIVE_INFINITY; + const seenIds = new HashSet(Rdf.hashTerm, Rdf.equalTerms); + const items: T[] = []; + const textFilter = makeCaseInsensitiveFilter(text); + + const tryAdd = (id: Rdf.NamedNode | Rdf.BlankNode) => { + let matches = false; + if (id.termType === 'NamedNode') { + const localName = Rdf.getLocalName(id.value); + if (localName !== undefined && textFilter(localName)) { + matches = true; + } + } + + const label = this.labelPredicate + ? findLiterals(this.dataset, id, this.labelPredicate) + : []; + if (label.some(v => textFilter(v.value))) { + matches = true; + } + + if (matches) { + items.push(createItem(id, label)); + } + }; + + for (const implicitValue of implicitValues) { + if (items.length >= limit) { + break; + } + + const id = this.decodeTerm(implicitValue); + if (!seenIds.has(id)) { + tryAdd(id); + } + } + + lookup: for (const baseType of includeTypes) { + const includeMatches = this.dataset.iterateMatches(null, this.typePredicate, baseType); + nextId: for (const {subject: id} of includeMatches) { + if (items.length >= limit) { + break lookup; + } + if ( + !(id.termType === 'NamedNode' || (allowBlanks && id.termType === 'BlankNode')) || + seenIds.has(id) + ) { + continue nextId; + } + + seenIds.add(id); + + for (const excludedType of excludeTypes) { + if (this.dataset.hasMatches(id, this.typePredicate, excludedType)) { + continue nextId; + } + } + + tryAdd(id); + } + } + return items; + } + elementTypes(params: { classIds: ReadonlyArray; signal?: AbortSignal; @@ -312,7 +508,6 @@ export class RdfDataProvider implements DataProvider { signal?: AbortSignal; }): Promise> { const {linkTypeIds} = params; - const linkCounts = this.computeLinkCounts(linkTypeIds); const models = new Map(); for (const linkTypeId of linkTypeIds) { const linkTypeIri = this.decodeTerm(linkTypeId); @@ -321,7 +516,7 @@ export class RdfDataProvider implements DataProvider { label: this.labelPredicate ? findLiterals(this.dataset, linkTypeIri, this.labelPredicate) : [], - count: linkCounts.get(linkTypeId) ?? 0, + count: this.linkCount.get(linkTypeId) ?? 0, }; models.set(linkTypeId, model); } @@ -437,85 +632,18 @@ export class RdfDataProvider implements DataProvider { return Promise.resolve(counts); } - lookup(params: DataProviderLookupParams): Promise { - interface ResultItem { - readonly term: Rdf.NamedNode | Rdf.BlankNode; - outLinks?: Set; - inLinks?: Set; - } + supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { + return {ok: true, errors: []}; + } - const items = new HashMap( - Rdf.hashTerm, Rdf.equalTerms - ); - let requiredTextFilter = params.text ? makeCaseInsensitiveFilter(params.text) : undefined; - - if (params.refElementId) { - const refElementIri = this.decodeTerm(params.refElementId); - const refLinkIri = params.refElementLinkId - ? this.decodeTerm(params.refElementLinkId) : null; - if (!params.linkDirection || params.linkDirection === 'out') { - for (const t of this.dataset.iterateMatches(refElementIri, refLinkIri, null)) { - if (t.predicate.termType === 'NamedNode' && isResourceTerm(t.object)) { - const term = t.object; - let item = items.get(term); - if (!item) { - item = {term}; - items.set(term, item); - } - const predicate: LinkTypeIri = this.encodeTerm(t.predicate); - if (!item.outLinks) { - item.outLinks = new Set(); - } - item.outLinks.add(predicate); - } - } - } - if (!params.linkDirection || params.linkDirection === 'in') { - for (const t of this.dataset.iterateMatches(null, refLinkIri, refElementIri)) { - if (t.predicate.termType === 'NamedNode' && isResourceTerm(t.subject)) { - const term = t.subject; - let item = items.get(term); - if (!item) { - item = {term}; - items.set(term, item); - } - const predicate: LinkTypeIri = this.encodeTerm(t.predicate); - if (!item.inLinks) { - item.inLinks = new Set(); - } - item.inLinks.add(predicate); - } - } - } - // join with filtered by type - if (params.elementTypeId) { - const typeTerm = this.decodeTerm(params.elementTypeId); - for (const item of Array.from(items.values())) { - if (!this.dataset.hasMatches(item.term, this.typePredicate, typeTerm)) { - items.delete(item.term); - } - } - } - } else if (params.elementTypeId) { - const typeTerm = this.decodeTerm(params.elementTypeId); - for (const t of this.dataset.iterateMatches(null, this.typePredicate, typeTerm)) { - if (isResourceTerm(t.subject) && !items.has(t.subject)) { - items.set(t.subject, {term: t.subject}); - } - } - } else if (requiredTextFilter && this.labelPredicate) { - for (const t of this.dataset.iterateMatches(null, this.labelPredicate, null)) { - if ( - isResourceTerm(t.subject) && - t.object.termType === 'Literal' && - requiredTextFilter(t.object.value) && - !items.has(t.subject) - ) { - items.set(t.subject, {term: t.subject}); - } - } - requiredTextFilter = undefined; - } + lookup(params: DataProviderLookupParams): Promise { + const fullPattern = lookupPatternOf(params); + const items = matchPattern(fullPattern, { + factory: this.factory, + dataset: this.dataset, + typePredicate: this.typePredicate, + labelPredicate: this.labelPredicate, + }); const linkedElements: DataProviderLookupItem[] = []; const limit = typeof params.limit === 'number' ? params.limit : Number.POSITIVE_INFINITY; @@ -528,18 +656,6 @@ export class RdfDataProvider implements DataProvider { if (this.labelPredicate) { const labels = findLiterals(this.dataset, item.term, this.labelPredicate); - if (requiredTextFilter) { - let foundMatch = false; - for (const label of labels) { - if (requiredTextFilter(label.value)) { - foundMatch = true; - break; - } - } - if (!foundMatch) { - continue; - } - } properties = { ...properties, [this.labelPredicate.value]: labels, @@ -555,7 +671,7 @@ export class RdfDataProvider implements DataProvider { }; } } - + const model: ElementModel = { id: this.encodeTerm(item.term), types: findTypes(this.dataset, item.term, this.typePredicate), @@ -570,43 +686,6 @@ export class RdfDataProvider implements DataProvider { return Promise.resolve(linkedElements); } - - private computeTypeCounts(): Map { - const instanceCounts = new Map(); - for (const t of this.dataset.iterateMatches(null, this.typePredicate, null)) { - if (isResourceTerm(t.object)) { - const elementTypeId: ElementTypeIri = this.encodeTerm(t.object); - instanceCounts.set(elementTypeId, (instanceCounts.get(elementTypeId) ?? 0) + 1); - } - } - return instanceCounts; - } - - private computeLinkCounts( - onlyLinkTypes?: ReadonlyArray - ): Map { - const linkTypeSet = onlyLinkTypes ? new Set(onlyLinkTypes) : undefined; - const linkStats = new Map(); - for (const t of this.dataset) { - if (t.predicate.termType === 'NamedNode') { - const linkTypeId: LinkTypeIri = this.encodeTerm(t.predicate); - if (!linkTypeSet || linkTypeSet.has(linkTypeId)) { - linkStats.set(linkTypeId, (linkStats.get(linkTypeId) ?? 0) + 1); - } - } - } - return linkStats; - } -} - -function isResourceTerm(term: Rdf.Term): term is Rdf.NamedNode | Rdf.BlankNode { - switch (term.termType) { - case 'NamedNode': - case 'BlankNode': - return true; - default: - return false; - } } function findFirstIriOrLiteral( @@ -682,26 +761,3 @@ function findProperties( return properties; } -function encodeTerm(term: Rdf.NamedNode | Rdf.BlankNode): string { - switch (term.termType) { - case 'NamedNode': - return term.value; - case 'BlankNode': - return BLANK_PREFIX + term.value; - default: - throw new Error( - `Unexpected term type to encode: ${(term as Rdf.Term).termType}` - ); - } -} - -function decodeTerm( - iri: ElementIri | ElementTypeIri | LinkTypeIri | PropertyTypeIri, - factory: Rdf.DataFactory -): Rdf.NamedNode | Rdf.BlankNode { - if (iri.startsWith(BLANK_PREFIX)) { - return factory.blankNode(iri.substring(BLANK_PREFIX.length)); - } else { - return factory.namedNode(iri); - } -} diff --git a/src/data/rdf/rdfModel.ts b/src/data/rdf/rdfModel.ts index 4b9b38c6..35d21c4a 100644 --- a/src/data/rdf/rdfModel.ts +++ b/src/data/rdf/rdfModel.ts @@ -12,7 +12,7 @@ export type DefaultGraph = RdfJs.DefaultGraph; export type BaseQuad = RdfJs.BaseQuad; export type Quad = RdfJs.Quad; -export type Term = NamedNode | BlankNode | Literal | Variable | DefaultGraph | BaseQuad; +export type Term = RdfJs.Term; export type DataFactory = RdfJs.DataFactory; export const DefaultDataFactory: RdfJs.DataFactory = N3.DataFactory; diff --git a/src/data/sparql/responseHandler.ts b/src/data/sparql/responseHandler.ts index d50fe151..82692fb6 100644 --- a/src/data/sparql/responseHandler.ts +++ b/src/data/sparql/responseHandler.ts @@ -10,6 +10,7 @@ import { } from '../model'; import type { DataProviderLinkCount, DataProviderLookupItem } from '../dataProvider'; import { LinkConfiguration, PropertyConfiguration } from './sparqlDataProviderSettings'; +import { isDirectLink } from './sparqlGenerator'; import { ClassBinding, ElementBinding, LinkBinding, ElementImageBinding, LinkCountBinding, LinkTypeBinding, ConnectedLinkTypeBinding, PropertyBinding, ElementTypeBinding, FilterBinding, @@ -542,18 +543,6 @@ function* translateLinkPredicates( } } -export function isDirectLink(link: LinkConfiguration) { - // link configuration is path-based if includes any variables - const pathBased = /[?$][a-zA-Z]+\b/.test(link.path); - return !pathBased; -} - -export function isDirectProperty(property: PropertyConfiguration) { - // property configuration is path-based if includes any variables - const pathBased = /[?$][a-zA-Z]+\b/.test(property.path); - return !pathBased; -} - function typeMatchesDomain( config: { readonly domain?: ReadonlyArray }, types: ReadonlySet | undefined diff --git a/src/data/sparql/sparqlDataProvider.ts b/src/data/sparql/sparqlDataProvider.ts index 7ee9b73b..fe479c9f 100644 --- a/src/data/sparql/sparqlDataProvider.ts +++ b/src/data/sparql/sparqlDataProvider.ts @@ -1,4 +1,3 @@ -import { multimapArrayAdd } from '../../coreUtils/collections'; import * as Rdf from '../rdf/rdfModel'; import { rdfs, schema } from '../rdf/vocabulary'; import { @@ -7,6 +6,7 @@ import { } from '../model'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, + DataProviderPatternSupport, LookupPattern, } from '../dataProvider'; import { chunkArray, chunkUndirectedCrossProduct } from './requestChunking'; @@ -29,16 +29,17 @@ import { getLinkStatistics, getPropertyTypes, triplesToElementBinding, - isDirectLink, - isDirectProperty, } from './responseHandler'; import { ClassBinding, ElementBinding, ElementTypeBinding, LinkBinding, PropertyBinding, FilterBinding, LinkCountBinding, LinkTypeBinding, ConnectedLinkTypeBinding, ElementImageBinding, } from './sparqlModels'; import { - SparqlDataProviderSettings, OwlStatsSettings, LinkConfiguration, PropertyConfiguration, + SparqlDataProviderSettings, OwlStatsSettings, FullTextSearchSettings, } from './sparqlDataProviderSettings'; +import { + SparqlGenerator, escapeIri, formatValueClauseContent, resolveTemplate, +} from './sparqlGenerator'; import { SparqlQuerySource, HttpSparqlQuerySource, HttpSparqlQuerySourceOptions, } from './sparqlQuerySource'; @@ -183,13 +184,7 @@ export class SparqlDataProvider implements DataProvider { private readonly acceptBlankNodes = false; private readonly chunkMeasure: ChunkMeasure; - private linkByPredicate = new Map(); - private linkById = new Map(); - private openWorldLinks: boolean; - - private propertyByPredicate = new Map(); - private openWorldProperties: boolean; - + private readonly generator: SparqlGenerator; private readonly labelPredicate: PropertyTypeIri; private readonly imagePredicate: PropertyTypeIri; @@ -246,21 +241,9 @@ export class SparqlDataProvider implements DataProvider { }; } - for (const link of settings.linkConfigurations) { - this.linkById.set(link.id, link); - const predicate = isDirectLink(link) ? link.path : link.id; - multimapArrayAdd(this.linkByPredicate, predicate, link); - } - this.openWorldLinks = settings.linkConfigurations.length === 0 || - Boolean(settings.openWorldLinks); - - for (const property of settings.propertyConfigurations) { - const predicate = isDirectProperty(property) ? property.path : property.id; - multimapArrayAdd(this.propertyByPredicate, predicate, property); - } - this.openWorldProperties = settings.propertyConfigurations.length === 0 || - Boolean(settings.openWorldProperties); - + this.generator = new SparqlGenerator(factory, settings, { + acceptBlankNodes: this.acceptBlankNodes, + }); this.labelPredicate = prepareLabelPredicate ?? rdfs.label; this.imagePredicate = prepareImagePredicate ?? schema.thumbnailUrl; } @@ -281,14 +264,14 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise { const {signal} = params; - const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, classTreeQuery} = this.settings; + const {defaultPrefix, schemaLabelProperty, classTreeQuery} = this.settings; if (!classTreeQuery) { return {elementTypes: [], subtypeOf: []}; } const query = defaultPrefix + resolveTemplate(classTreeQuery, { schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), }); const response = await this.executeSparqlSelect(query, {signal}); const classTree = getClassTree(response.results.bindings); @@ -300,21 +283,131 @@ export class SparqlDataProvider implements DataProvider { return classTree; } + async knownLinkTypes(params: { + signal?: AbortSignal; + }): Promise { + const {signal} = params; + const { + defaultPrefix, schemaLabelProperty, linkTypesQuery, linkTypesPattern, + } = this.settings; + if (!linkTypesQuery) { + return []; + } + + const query = defaultPrefix + resolveTemplate(linkTypesQuery, { + linkTypesPattern, + schemaLabelProperty, + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), + }); + const response = await this.executeSparqlSelect(query, {signal}); + const linkTypes = getLinkTypes(response.results.bindings); + + if (this.options.prepareLabels) { + await attachLabels(Array.from(linkTypes.values()), this.options.prepareLabels, signal); + } + + return Array.from(linkTypes.values()); + } + + async lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {classLookupQuery, classLookupSettings} = this.settings; + return this.lookupSchemaItems( + classLookupQuery, + classLookupSettings, + this.factory.variable!('class'), + (bindings: ClassBinding[]) => getClassTree(bindings).elementTypes, + params + ); + } + + async lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {propertyTypesLookupQuery, propertyTypesLookupSettings} = this.settings; + return this.lookupSchemaItems( + propertyTypesLookupQuery, + propertyTypesLookupSettings, + this.factory.variable!('property'), + (bindings: PropertyBinding[]) => Array.from( + getPropertyTypes(bindings).values() + ), + params + ); + } + + async lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {linkTypesLookupQuery, linkTypesLookupSettings} = this.settings; + return this.lookupSchemaItems( + linkTypesLookupQuery, + linkTypesLookupSettings, + this.factory.variable!('link'), + (bindings: LinkTypeBinding[]) => Array.from( + getLinkTypes(bindings).values() + ), + params + ); + } + + private async lookupSchemaItems( + lookupQuery: string | undefined, + lookupSettings: FullTextSearchSettings | undefined, + itemTerm: Rdf.Variable, + itemsFromResponse: (bindings: Binding[]) => Item[], + params: { + text: string; + limit?: number; + signal?: AbortSignal; + } + ): Promise { + const {text, limit, signal} = params; + const {schemaTextSearch} = this.settings; + + const textSearchSettings = lookupSettings ?? schemaTextSearch; + if (!(lookupQuery && textSearchSettings)) { + return []; + } + + const query = this.generator.generateLookupSchemaQuery({ + lookupQuery, + textSearchSettings, + itemTerm, + text, + limit, + }); + const response = await this.executeSparqlSelect(query, {signal}); + const items = Array.from(itemsFromResponse(response.results.bindings).values()); + + if (this.options.prepareLabels) { + await attachLabels(items, this.options.prepareLabels, signal); + } + + return items; + } + async propertyTypes(params: { propertyIds: ReadonlyArray; signal?: AbortSignal; }): Promise> { const {propertyIds, signal} = params; - const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, propertyInfoQuery} = this.settings; + const {defaultPrefix, schemaLabelProperty, propertyInfoQuery} = this.settings; const properties = new Map(); if (propertyInfoQuery) { await this.queryChunked(propertyIds, async batch => { - const ids = batch.map(escapeIri).map(id => ` ( ${id} )`).join(' '); const query = defaultPrefix + resolveTemplate(propertyInfoQuery, { - ids, + ids: formatValueClauseContent(batch), schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), }); const response = await this.executeSparqlSelect(query, {signal}); collectPropertyTypes(response.results.bindings, properties); @@ -337,16 +430,15 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise> { const {classIds, signal} = params; - const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, classInfoQuery} = this.settings; + const {defaultPrefix, schemaLabelProperty, classInfoQuery} = this.settings; const classes = new Map(); if (classInfoQuery) { await this.queryChunked(classIds, async batch => { - const ids = batch.map(escapeIri).map(id => ` ( ${id} )`).join(' '); const query = defaultPrefix + resolveTemplate(classInfoQuery, { - ids, + ids: formatValueClauseContent(batch), schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), }); const response = await this.executeSparqlSelect(query, {signal}); collectClassInfo(response.results.bindings, classes); @@ -369,7 +461,7 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise> { const {linkTypeIds, signal} = params; - const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, linkTypesInfoQuery} = this.settings; + const {defaultPrefix, schemaLabelProperty, linkTypesInfoQuery} = this.settings; const linkTypes = new Map(); if (linkTypesInfoQuery) { @@ -378,7 +470,7 @@ export class SparqlDataProvider implements DataProvider { const query = defaultPrefix + resolveTemplate(linkTypesInfoQuery, { ids, schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), }); const response = await this.executeSparqlSelect(query, {signal}); collectLinkTypes(response.results.bindings, linkTypes); @@ -396,32 +488,6 @@ export class SparqlDataProvider implements DataProvider { return linkTypes; } - async knownLinkTypes(params: { - signal?: AbortSignal; - }): Promise { - const {signal} = params; - const { - defaultPrefix, schemaLabelProperty, filterOnlyLanguages, linkTypesQuery, linkTypesPattern, - } = this.settings; - if (!linkTypesQuery) { - return []; - } - - const query = defaultPrefix + resolveTemplate(linkTypesQuery, { - linkTypesPattern, - schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), - }); - const response = await this.executeSparqlSelect(query, {signal}); - const linkTypes = getLinkTypes(response.results.bindings); - - if (this.options.prepareLabels) { - await attachLabels(Array.from(linkTypes.values()), this.options.prepareLabels, signal); - } - - return Array.from(linkTypes.values()); - } - async elements(params: { elementIds: ReadonlyArray; signal?: AbortSignal; @@ -436,9 +502,14 @@ export class SparqlDataProvider implements DataProvider { const query = defaultPrefix + resolveTemplate(elementInfoQuery, { ids, dataLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), - valueLanguageFilter: formatLanguageFilter('?propValue', filterOnlyLanguages), - propertyConfigurations: this.formatPropertyInfo(), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), + valueLanguageFilter: this.generator.formatLanguageFilter('?propValue'), + propertyConfigurations: this.generator.formatPropertyUnion({ + entityTerm: this.factory.variable!('inst'), + valueTerm: this.factory.variable!('propValue'), + predicateTerm: this.factory.variable!('propType'), + withProjection: true, + }), }); const response = await this.executeSparqlConstruct(query); for (const triple of response) { @@ -456,9 +527,9 @@ export class SparqlDataProvider implements DataProvider { const elementModels = getElementsInfo( bindings, await types, - this.propertyByPredicate, + this.generator.propertyByPredicate, this.labelPredicate, - this.openWorldProperties + this.generator.openWorldProperties ); if (this.options.prepareLabels) { @@ -518,10 +589,10 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise { const {primary, secondary, linkTypeIds, signal} = params; - const {filterOnlyLanguages, linksInfoQuery} = this.settings; + const {linksInfoQuery} = this.settings; - const propLanguageFilter = formatLanguageFilter('?propValue', filterOnlyLanguages); - const linkConfigurations = this.formatLinkLinks(); + const propLanguageFilter = this.generator.formatLanguageFilter('?propValue'); + const linkConfigurations = this.generator.formatLinkLinks(); let bindings: Promise>; if (primary.length > 0 && secondary.length > 0) { @@ -545,7 +616,7 @@ export class SparqlDataProvider implements DataProvider { } let elementsForTypes: Set | undefined; - if (this.linkByPredicate.size > 0) { + if (this.generator.linkByPredicate.size > 0) { // Optimization for common case without link configurations elementsForTypes = new Set(primary); for (const iri of secondary) { @@ -561,8 +632,8 @@ export class SparqlDataProvider implements DataProvider { let linksInfo = getLinksInfo( await bindings, await types, - this.linkByPredicate, - this.openWorldLinks + this.generator.linkByPredicate, + this.generator.openWorldLinks ); if (linkTypeIds) { const allowedLinkTypes = new Set(linkTypeIds); @@ -641,70 +712,34 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise { const {elementId, inexactCount, signal} = params; - const {defaultPrefix, linkTypesOfQuery, linkTypesStatisticsQuery, filterTypePattern} = this.settings; - - const bindDirection = /\?direction\b/.test(linkTypesOfQuery); - - const elementIri = escapeIri(elementId); - const forAll = this.formatLinkUnion( - elementId, undefined, undefined, '?outObject', '?inObject', bindDirection - ); - if (forAll.usePredicatePart) { - forAll.unionParts.push( - `{ ${elementIri} ?link ?outObject ${bindDirection ? 'BIND("out" AS ?direction)' : ''} }` - ); - forAll.unionParts.push( - `{ ?inObject ?link ${elementIri} ${bindDirection ? 'BIND("in" AS ?direction)' : ''} }` - ); - } - - const query = defaultPrefix + resolveTemplate(linkTypesOfQuery, { - elementIri, - linkConfigurations: forAll.unionParts.join('\nUNION\n'), + + const elementTerm = this.factory.namedNode(elementId); + const targetOut = this.factory.variable!('outObject'); + const targetIn = this.factory.variable!('inObject'); + const {query, bindDirection} = this.generator.generateLinkTypesOfQuery({ + elementTerm, + targetIn, + targetOut, }); - const linkTypeResponse = await this.executeSparqlSelect(query, {signal}); + const linkTypeResponse = await this.executeSparqlSelect( + query, + {signal} + ); const connectedLinkTypes = getConnectedLinkTypes( linkTypeResponse.results.bindings, - this.linkByPredicate, - this.openWorldLinks + this.generator.linkByPredicate, + this.generator.openWorldLinks ); - const navigateElementFilterOut = this.acceptBlankNodes - ? 'FILTER (IsIri(?outObject) || IsBlank(?outObject))' - : 'FILTER IsIri(?outObject)'; - // Specifically use isBlank() as opposed to isIri() for filtering to avoid - // a full scan on some endpoints (e.g. Virtuoso) - const navigateElementFilterIn = this.acceptBlankNodes - ? '' - : 'FILTER(!isBlank(?inObject))'; + const { + navigateElementFilterIn, + navigateElementFilterOut, + } = this.generator.formatLinkStatsNavigateFilters(); const foundLinkStats: DataProviderLinkCount[] = []; await Promise.all(connectedLinkTypes.map(async ({linkType, hasInLink, hasOutLink}) => { - const linkConfig = this.linkById.get(linkType); - let linkConfigurationOut: string; - let linkConfigurationIn: string; - - if (!linkConfig || isDirectLink(linkConfig)) { - const predicate = escapeIri( - linkConfig && isDirectLink(linkConfig) ? linkConfig.path : linkType - ); - linkConfigurationOut = `${elementIri} ${predicate} ?outObject`; - linkConfigurationIn = `?inObject ${predicate} ${elementIri}`; - } else { - linkConfigurationOut = this.formatLinkPath(linkConfig.path, elementIri, '?outObject'); - linkConfigurationIn = this.formatLinkPath(linkConfig.path, '?inObject', elementIri); - } - - if (linkConfig && linkConfig.domain && linkConfig.domain.length > 0) { - const commaSeparatedDomains = linkConfig.domain.map(escapeIri).join(', '); - const restrictionOut = filterTypePattern.replace(/[?$]inst\b/g, elementIri); - const restrictionIn = filterTypePattern.replace(/[?$]inst\b/g, '?inObject'); - linkConfigurationOut += ` { ${restrictionOut} FILTER(?class IN (${commaSeparatedDomains})) }`; - linkConfigurationIn += ` { ${restrictionIn} FILTER(?class IN (${commaSeparatedDomains})) }`; - } - - if (!linkTypesStatisticsQuery || (inexactCount && bindDirection)) { + if (!this.settings.linkTypesStatisticsQuery || (inexactCount && bindDirection)) { foundLinkStats.push({ id: linkType, inCount: hasInLink ? 1 : 0, @@ -712,15 +747,18 @@ export class SparqlDataProvider implements DataProvider { inexact: true, }); } else { - const statsQuery = defaultPrefix + resolveTemplate(linkTypesStatisticsQuery, { - linkId: escapeIri(linkType), - elementIri, - linkConfigurationOut, - linkConfigurationIn, - navigateElementFilterOut, + const statsQuery = this.generator.generateLinkStatsQuery({ + elementTerm, + targetIn, + targetOut, + linkType, navigateElementFilterIn, + navigateElementFilterOut, }); - const statsResponse = await this.executeSparqlSelect(statsQuery, {signal}); + const statsResponse = await this.executeSparqlSelect( + statsQuery, + {signal} + ); const linkStats = getLinkStatistics(statsResponse.results.bindings); if (linkStats) { foundLinkStats.push(linkStats); @@ -730,6 +768,10 @@ export class SparqlDataProvider implements DataProvider { return foundLinkStats; } + supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { + return {ok: false, errors: []}; + } + async lookup(baseParams: DataProviderLookupParams): Promise { const {signal} = baseParams; const params: DataProviderLookupParams = { @@ -744,15 +786,18 @@ export class SparqlDataProvider implements DataProvider { signal ); - const filterQuery = this.createFilterQuery(params); - const response = await this.executeSparqlSelect(filterQuery, {signal}); + const filterQuery = this.generator.generateFilterQuery(params); + const response = await this.executeSparqlSelect( + filterQuery, + {signal} + ); const linkedElements = getFilteredData( response.results.bindings, await types, - this.linkByPredicate, + this.generator.linkByPredicate, this.labelPredicate, - this.openWorldLinks + this.generator.openWorldLinks ); if (this.options.prepareLabels) { @@ -768,81 +813,6 @@ export class SparqlDataProvider implements DataProvider { return linkedElements; } - private createFilterQuery(params: DataProviderLookupParams): string { - if (!params.refElementId && params.refElementLinkId) { - throw new Error('Cannot execute refElementLink filter without refElement'); - } - - let outerProjection = '?inst ?class ?label'; - let innerProjection = '?inst'; - - let filterByRefElementLink = ''; - let queryTypes = ''; - if (params.refElementId) { - outerProjection += ' ?link ?direction'; - innerProjection += ' ?link ?direction'; - filterByRefElementLink = this.createRefQueryPart({ - elementId: params.refElementId, - linkId: params.refElementLinkId, - direction: params.linkDirection, - }); - - if (this.settings.linkConfigurations.length > 0) { - outerProjection += ' ?classAll'; - queryTypes = this.settings.filterTypePattern.replace(/[?$]class\b/g, '?classAll'); - } - } - - let filterByType = ''; - if (params.elementTypeId) { - const elementTypeIri = escapeIri(params.elementTypeId); - filterByType = this.settings.filterTypePattern.replace(/[?$]class\b/g, elementTypeIri); - } - - const { - defaultPrefix, dataLabelProperty, lookupQuery, filterOnlyLanguages, - filterElementInfoPattern, fullTextSearch, - } = this.settings; - - const queryElementInfo = resolveTemplate(filterElementInfoPattern, { - dataLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), - }); - - let filterByText = ''; - let orderBy = ''; - if (params.text) { - innerProjection += ' ?score'; - if (this.settings.fullTextSearch.extractLabel) { - filterByText += sparqlExtractLabel('?inst', '?extractedLabel'); - } - filterByText = resolveTemplate(fullTextSearch.queryPattern, {text: params.text, dataLabelProperty}); - if (filterByText) { - orderBy = 'ORDER BY DESC(?score)'; - } - } - - let limitPart = ''; - if (typeof params.limit === 'number') { - limitPart = `LIMIT ${params.limit}`; - } - - const completePrefix = defaultPrefix + '\n' + fullTextSearch.prefix + '\n'; - return completePrefix + resolveTemplate(lookupQuery, { - innerProjection, - outerProjection, - filterInnerPrelude: this.settings.filterInnerPrelude, - filterByType, - filterByRefElementLink, - filterByText, - filterAdditionalRestriction: this.settings.filterAdditionalRestriction, - orderBy, - limit: limitPart, - queryTypes, - queryElementInfo, - }); - } - /** * Executes arbitrary SPARQL SELECT query and returns the result tuples. */ @@ -876,148 +846,6 @@ export class SparqlDataProvider implements DataProvider { }); } - protected createRefQueryPart(params: { elementId: ElementIri; linkId?: LinkTypeIri; direction?: 'in' | 'out' }) { - const {elementId, linkId, direction} = params; - - const {unionParts, usePredicatePart} = this.formatLinkUnion( - elementId, linkId, direction, '?inst', '?inst', true - ); - - if (usePredicatePart) { - const refElementIRI = escapeIri(params.elementId); - let refLinkType: string | undefined; - if (linkId) { - const link = this.linkById.get(linkId); - refLinkType = link && isDirectLink(link) ? escapeIri(link.path) : escapeIri(linkId); - } - - const linkPattern = refLinkType || '?link'; - const bindType = refLinkType ? `BIND(${refLinkType} as ?link)` : ''; - // Specifically use isBlank() as opposed to isIri() when possible for filtering - // to avoid a full scan on some endpoints (e.g. Virtuoso) - const outFilter = this.acceptBlankNodes - ? 'FILTER(isIri(?inst) || isBlank(?inst))' - : 'FILTER(isIri(?inst))'; - const inFilter = this.acceptBlankNodes - ? '' - : 'FILTER(!isBlank(?inst))'; - - if (!direction || direction === 'out') { - unionParts.push(`{ ${refElementIRI} ${linkPattern} ?inst BIND("out" as ?direction) ${bindType} ${outFilter} }`); - } - if (!direction || direction === 'in') { - unionParts.push(`{ ?inst ${linkPattern} ${refElementIRI} BIND("in" as ?direction) ${bindType} ${inFilter} }`); - } - } - - let resultPattern = unionParts.length === 0 ? 'FILTER(false)' : unionParts.join('\nUNION\n'); - - const useAllLinksPattern = !linkId && this.settings.filterRefElementLinkPattern.length > 0; - if (useAllLinksPattern) { - resultPattern += `\n${this.settings.filterRefElementLinkPattern}`; - } - - return resultPattern; - } - - private formatLinkUnion( - refElementIri: ElementIri, - linkIri: LinkTypeIri | undefined, - direction: 'in' | 'out' | undefined, - outElementVar: string, - inElementVar: string, - bindDirection: boolean - ) { - const {linkConfigurations} = this.settings; - const fixedIri = escapeIri(refElementIri); - - const unionParts: string[] = []; - let hasDirectLink = false; - - for (const link of linkConfigurations) { - if (linkIri && link.id !== linkIri) { continue; } - if (isDirectLink(link)) { - hasDirectLink = true; - } else { - const linkType = escapeIri(link.id); - if (!direction || direction === 'out') { - const path = this.formatLinkPath(link.path, fixedIri, outElementVar); - const boundedDirection = bindDirection ? 'BIND("out" as ?direction) ' : ''; - unionParts.push( - `{ ${path} BIND(${linkType} as ?link) ${boundedDirection}}` - ); - } - if (!direction || direction === 'in') { - const path = this.formatLinkPath(link.path, inElementVar, fixedIri); - const boundedDirection = bindDirection ? 'BIND("in" as ?direction) ' : ''; - unionParts.push( - `{ ${path} BIND(${linkType} as ?link) ${boundedDirection}}` - ); - } - } - } - - const usePredicatePart = this.openWorldLinks || hasDirectLink; - return {unionParts, usePredicatePart}; - } - - private formatLinkLinks(): string { - const unionParts: string[] = []; - let hasDirectLink = false; - for (const link of this.settings.linkConfigurations) { - if (isDirectLink(link)) { - hasDirectLink = true; - } else { - const linkType = escapeIri(link.id); - unionParts.push( - `${this.formatLinkPath(link.path, '?source', '?target')} BIND(${linkType} as ?type)` - ); - } - } - - const usePredicatePart = this.openWorldLinks || hasDirectLink; - if (usePredicatePart) { - unionParts.push('?source ?type ?target'); - } - - return ( - unionParts.length === 0 ? '' : - unionParts.length === 1 ? unionParts[0] : - '{ ' + unionParts.join(' }\nUNION\n{ ') + ' }' - ); - } - - private formatLinkPath(path: string, source: string, target: string): string { - return path.replace(/[?$]source\b/g, source).replace(/[?$]target\b/g, target); - } - - private formatPropertyInfo(): string { - const unionParts: string[] = []; - let hasDirectProperty = false; - for (const property of this.settings.propertyConfigurations) { - if (isDirectProperty(property)) { - hasDirectProperty = true; - } else { - const propType = escapeIri(property.id); - const formatted = this.formatPropertyPath(property.path, '?inst', '?propValue'); - unionParts.push( - `{ ${formatted} BIND(${propType} as ?propType) }` - ); - } - } - - const usePredicatePart = this.openWorldProperties || hasDirectProperty; - if (usePredicatePart) { - unionParts.push('{ ?inst ?propType ?propValue }'); - } - - return unionParts.join('\nUNION\n'); - } - - private formatPropertyPath(path: string, subject: string, value: string): string { - return path.replace(/[?$]inst\b/g, subject).replace(/[?$]value\b/g, value); - } - private async querySingleElementTypes( element: ElementIri | undefined, signal: AbortSignal | undefined @@ -1125,52 +953,3 @@ function prepareElementImages( } }); } - -function resolveTemplate(template: string, values: { [key: string]: string | undefined }) { - let result = template; - for (const replaceKey in values) { - if (!Object.prototype.hasOwnProperty.call(values, replaceKey)) { continue; } - const replaceValue = values[replaceKey] || ''; - result = result.replace(new RegExp('\\${' + replaceKey + '}', 'g'), replaceValue); - } - return result; -} - -function formatLanguageFilter( - variable: string, - languages: ReadonlyArray | undefined -): string { - if (!languages) { - return ''; - } - const parts = [`FILTER(!isLiteral(${variable}) || lang(${variable}) = ""`]; - for (const language of languages) { - parts.push(` || lang(${variable}) = "${language}"`); - } - parts.push(')'); - return parts.join(''); -} - -function sparqlExtractLabel(subject: string, label: string): string { - return ` - BIND ( str( ${subject} ) as ?uriStr) - BIND ( strafter(?uriStr, "#") as ?label3) - BIND ( strafter(strafter(?uriStr, "//"), "/") as ?label6) - BIND ( strafter(?label6, "/") as ?label5) - BIND ( strafter(?label5, "/") as ?label4) - BIND (if (?label3 != "", ?label3, - if (?label4 != "", ?label4, - if (?label5 != "", ?label5, ?label6))) as ${label}) - `; -} - -function formatValueClauseContent(iris: Iterable): string { - return Array.from(iris, iri => `(${escapeIri(iri)})`).join(' '); -} - -function escapeIri(iri: string) { - if (typeof iri !== 'string') { - throw new Error(`Cannot escape IRI of type "${typeof iri}"`); - } - return `<${iri}>`; -} diff --git a/src/data/sparql/sparqlDataProviderSettings.ts b/src/data/sparql/sparqlDataProviderSettings.ts index c4be0a66..9ea503ce 100644 --- a/src/data/sparql/sparqlDataProviderSettings.ts +++ b/src/data/sparql/sparqlDataProviderSettings.ts @@ -25,10 +25,11 @@ export interface SparqlDataProviderSettings { filterOnlyLanguages?: ReadonlyArray; /** - * SELECT query to retrieve class tree. + * SELECT query to get full element type (class) tree. * * Parametrized variables: - * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings + * - `${schemaLabelProperty}` {@link schemaLabelProperty} property from the settings + * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` * * Expected output bindings: * - `?class` @@ -38,6 +39,30 @@ export interface SparqlDataProviderSettings { */ classTreeQuery?: string; + /** + * SELECT query to lookup element types (classes) filtered by a keyword. + * + * Parametrized variables: + * - `${schemaLabelProperty}` {@link schemaLabelProperty} property from the settings + * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` + * - `${filterByText}` from {@link classTreeLookupSettings} + * - `${limit}` (when `limit` is provided) + * + * Expected output bindings: + * - `?class` + * - `?label` (optional) + * - `?score` element lookup score (higher ordered first) + * (for order only; when `text` is provided) + */ + classLookupQuery?: string; + + /** + * Settings for element types lookup by a text keyword. + * + * If not specified, {@link schemaLookupSettings} is used by default. + */ + classLookupSettings?: FullTextSearchSettings; + /** * SELECT query to retrieve data for each class in a set. * @@ -53,6 +78,42 @@ export interface SparqlDataProviderSettings { */ classInfoQuery?: string; + /** + * SELECT query to lookup property types filtered by a keyword. + * + * Parametrized variables: + * - `${schemaLabelProperty}` {@link schemaLabelProperty} property from the settings + * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` + * - `${filterByText}` from {@link propertyTypesLookupSettings} + * - `${limit}` (when `limit` is provided) + * + * Expected output bindings: + * - `?property` + * - `?label` (optional) + */ + propertyTypesLookupQuery?: string; + + /** + * Settings for property types lookup by a text keyword. + * + * If not specified, {@link schemaLookupSettings} is used by default. + */ + propertyTypesLookupSettings?: FullTextSearchSettings; + + /** + * SELECT query to retrieve data for each datatype property in a set. + * + * Parametrized variables: + * - `${ids}` VALUES clause content with datatype property IRIs + * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings + * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` + * + * Expected output bindings: + * - `?property` + * - `?label` (optional) + */ + propertyInfoQuery?: string; + /** * SELECT query to retrieve initial link types. * @@ -76,33 +137,41 @@ export interface SparqlDataProviderSettings { linkTypesPattern?: string; /** - * SELECT query to retrieve data for each link type in a set. + * SELECT query to lookup link types filtered by a keyword. * * Parametrized variables: - * - `${ids}` VALUES clause content with link type IRIs - * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings + * - `${schemaLabelProperty}` {@link schemaLabelProperty} property from the settings * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` + * - `${filterByText}` from {@link linkTypesLookupSettings} + * - `${limit}` (when `limit` is provided) * * Expected output bindings: * - `?link` * - `?label` (optional) - * - `?instcount` (optional) */ - linkTypesInfoQuery?: string; + linkTypesLookupQuery?: string; /** - * SELECT query to retrieve data for each datatype property in a set. + * Settings for link types lookup by a text keyword. + * + * If not specified, {@link schemaLookupSettings} is used by default. + */ + linkTypesLookupSettings?: FullTextSearchSettings; + + /** + * SELECT query to retrieve data for each link type in a set. * * Parametrized variables: - * - `${ids}` VALUES clause content with datatype property IRIs + * - `${ids}` VALUES clause content with link type IRIs * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` * * Expected output bindings: - * - `?property` + * - `?link` * - `?label` (optional) + * - `?instcount` (optional) */ - propertyInfoQuery?: string; + linkTypesInfoQuery?: string; /** * CONSTRUCT query to retrieve data for each element (types, labels, properties). @@ -196,12 +265,7 @@ export interface SparqlDataProviderSettings { * - `${outerProjection}` * - `${innerProjection}` * - `${filterInnerPrelude}` from {@link filterInnerPrelude} - * - `${filterByType}` from {@link filterTypePattern} - * (when {@link DataProviderLookupParams.elementTypeId} is provided) - * - `${filterByRefElementLink}` from {@link filterRefElementLinkPattern} - * (when {@link DataProviderLookupParams.refElementId} is provided) - * - `${filterByText}` from {@link fullTextSearch} - * (when {@link DataProviderLookupParams.text} is provided) + * - `${filterByPattern}` * - `${filterAdditionalRestriction}` from {@link filterAdditionalRestriction} * - `${orderBy}` (when {@link DataProviderLookupParams.text} is provided) * - `${limit}` (when {@link DataProviderLookupParams.limit} is provided) @@ -210,16 +274,25 @@ export interface SparqlDataProviderSettings { * {@link linkConfigurations} is non-empty) * - `${queryElementInfo}` from {@link filterElementInfoPattern} * + * For compatibility, the following deprecated variables are parametrized + * in case `${filterByPattern}` is not found in the query: + * - `${filterByType}` from {@link filterTypePattern} + * (when {@link DataProviderLookupParams.elementTypeId} is provided) + * - `${filterByRefElementLink}` from {@link filterRefElementLinkPattern} + * (when {@link DataProviderLookupParams.refElementId} is provided) + * - `${filterByText}` from {@link fullTextSearch} + * (when {@link DataProviderLookupParams.text} is provided) + * * Expected bindings: * - `?inst` element IRI * - `?class` element type * - `?label` element label * - `?link` connected via link type - * (when {@link DataProviderLookupParams.refElementId} is provided) + * (when {@link ConnectedToLookupPattern} is provided at top level) * - `?direction` connected via link direction (`"in"` or `"out"`) - * (when {@link DataProviderLookupParams.refElementId} is provided) + * (when {@link ConnectedToLookupPattern} is provided at top level) * - `?score` element lookup score (higher ordered first) - * (for order only; when {@link DataProviderLookupParams.text} is provided) + * (for order only; when {@link WithTextLookupPattern} is provided at top level) */ lookupQuery: string; @@ -269,10 +342,15 @@ export interface SparqlDataProviderSettings { filterAdditionalRestriction: string; /** - * Lookup by text settings. + * Lookup entities by text settings. */ fullTextSearch: FullTextSearchSettings; + /** + * Default settings for schema items (element/property/link types) lookup by text. + */ + schemaTextSearch?: FullTextSearchSettings; + /** * "Virtual" links configurations to translate a SPARQL pattern as a link. */ @@ -317,9 +395,10 @@ export interface FullTextSearchSettings { * Parametrized variables: * - `${text}` text token * - `${dataLabelProperty}` `dataLabelProperty` property from the settings + * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings * * Expected bindings: - * - `?inst` link type + * - `?inst` found item IRI * - `?score` numerical score for ordering search results by relevance * - `?extractedLabel` (optional; if `extractLabel` is enabled) */ @@ -442,6 +521,8 @@ export const RdfSettings: SparqlDataProviderSettings = { classTreeQuery: '', + classLookupQuery: '', + classInfoQuery: `SELECT ?class ?label ?instcount WHERE { VALUES(?class) {\${ids}} @@ -452,6 +533,17 @@ export const RdfSettings: SparqlDataProviderSettings = { BIND("" as ?instcount) }`, + propertyTypesLookupQuery: '', + + propertyInfoQuery: +`SELECT ?property ?label WHERE { + VALUES(?property) {\${ids}} + OPTIONAL { + ?property \${schemaLabelProperty} ?label + \${labelLanguageFilter} + } +}`, + linkTypesQuery: `SELECT DISTINCT ?link ?instcount ?label WHERE { \${linkTypesPattern} @@ -463,6 +555,8 @@ export const RdfSettings: SparqlDataProviderSettings = { linkTypesPattern: '', + linkTypesLookupQuery: '', + linkTypesInfoQuery: `SELECT ?link ?label WHERE { VALUES(?link) {\${ids}} @@ -472,15 +566,6 @@ export const RdfSettings: SparqlDataProviderSettings = { } }`, - propertyInfoQuery: -`SELECT ?property ?label WHERE { - VALUES(?property) {\${ids}} - OPTIONAL { - ?property \${schemaLabelProperty} ?label - \${labelLanguageFilter} - } -}`, - elementInfoQuery: '', imageQueryPattern: '', @@ -495,6 +580,7 @@ export const RdfSettings: SparqlDataProviderSettings = { \${filterByType} \${filterByRefElementLink} \${filterByText} + \${filterByPattern} \${filterAdditionalRestriction} } \${orderBy} \${limit} } @@ -508,6 +594,45 @@ export const RdfSettings: SparqlDataProviderSettings = { filterElementInfoPattern: '', }; +// Wikidata does not distinguish property/link types +const WikidataPropertyLookup: FullTextSearchSettings = { + prefix: 'PREFIX bds: \n', + queryPattern: ` + SERVICE wikibase:mwapi { + bd:serviceParam wikibase:endpoint "www.wikidata.org"; + wikibase:api "EntitySearch"; + mwapi:search "\${text}"; + mwapi:language "en"; + mwapi:type "property". + ?inst wikibase:apiOutputItem mwapi:item. + ?num wikibase:apiOrdinal true. + } + BIND(IF( + STRLEN(STR(?inst)) > 33, + 0-(SUBSTR(STR(?inst), 33)), + -10000 + ) as ?score) + `, +}; +const WikidataEntityLookup: FullTextSearchSettings = { + prefix: 'PREFIX bds: \n', + queryPattern: ` + SERVICE wikibase:mwapi { + bd:serviceParam wikibase:endpoint "www.wikidata.org"; + wikibase:api "EntitySearch"; + mwapi:search "\${text}"; + mwapi:language "en". + ?inst wikibase:apiOutputItem mwapi:item. + ?num wikibase:apiOrdinal true. + } + BIND(IF( + STRLEN(STR(?inst)) > 33, + 0-(SUBSTR(STR(?inst), 33)), + -10000 + ) as ?score) + `, +}; + const WikidataSettingsOverride: Partial = { defaultPrefix: `PREFIX rdfs: @@ -534,11 +659,39 @@ const WikidataSettingsOverride: Partial = { } `, + classLookupQuery: ` + SELECT ?class ?label WHERE { + \${filterByText} + ?class (wdt:P31 / wdt:P31) wd:Q137429456 . + ?class \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, + classLookupSettings: WikidataEntityLookup, + + propertyTypesLookupQuery: ` + SELECT ?property ?label WHERE { + \${filterByText} + ?property \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, + propertyTypesLookupSettings: WikidataPropertyLookup, + linkTypesQuery: '', linkTypesPattern: ` ?link wdt:P279* wd:Q18616576. BIND(0 as ?instcount) `, + + linkTypesLookupQuery: ` + SELECT ?link ?label WHERE { + \${filterByText} + ?link \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, + linkTypesLookupSettings: WikidataPropertyLookup, linkTypesInfoQuery: `SELECT ?link ?label WHERE { @@ -629,24 +782,7 @@ const WikidataSettingsOverride: Partial = { \${labelLanguageFilter} } `, - fullTextSearch: { - prefix: 'PREFIX bds: \n', - queryPattern: ` - SERVICE wikibase:mwapi { - bd:serviceParam wikibase:endpoint "www.wikidata.org"; - wikibase:api "EntitySearch"; - mwapi:search "\${text}"; - mwapi:language "en". - ?inst wikibase:apiOutputItem mwapi:item. - ?num wikibase:apiOrdinal true. - } - BIND(IF( - STRLEN(STR(?inst)) > 33, - 0-(SUBSTR(STR(?inst), 33)), - -10000 - ) as ?score) - `, - }, + fullTextSearch: WikidataEntityLookup, }; /** @@ -671,17 +807,21 @@ const OwlRdfsSettingsOverride: Partial = { `?inst \${dataLabelProperty} ?search1 FILTER regex(COALESCE(str(?search1)), "\${text}", "i") BIND(if(bound(?search1), 0, 1) as ?score) -`, - extractLabel: true, + `, + }, + schemaTextSearch: { + prefix: '', + queryPattern: + `?inst \${schemaLabelProperty} ?search1 + FILTER regex(COALESCE(str(?search1)), "\${text}", "i") + BIND(if(bound(?search1), 0, 1) as ?score) + `, }, classTreeQuery: ` - SELECT ?class ?label ?parent - WHERE { - { - ?class a rdfs:Class - } UNION { - ?class a owl:Class - } + SELECT ?class ?label ?parent WHERE { + { ?class a rdfs:Class } + UNION + { ?class a owl:Class } FILTER ISIRI(?class) OPTIONAL { ?class rdfs:label ?label @@ -690,14 +830,45 @@ const OwlRdfsSettingsOverride: Partial = { OPTIONAL {?class rdfs:subClassOf ?parent. FILTER ISIRI(?parent)} } `, - - // todo: think more, maybe add a limit here? + classLookupQuery: ` + SELECT ?class ?label WHERE { + { ?class a rdfs:Class } + UNION + { ?class a owl:Class } + FILTER ISIRI(?class) + \${filterByText} + ?class \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, + propertyTypesLookupQuery: ` + SELECT ?property ?label WHERE { + { ?property a rdf:Property } + UNION + { ?property a owl:DatatypeProperty } + FILTER NOT EXISTS { ?property a owl:ObjectProperty } + \${filterByText} + ?property \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, linkTypesPattern: ` { ?link a rdf:Property } UNION { ?link a owl:ObjectProperty } BIND('' as ?instcount) `, + linkTypesLookupQuery: ` + SELECT ?link ?label WHERE { + { ?link a rdf:Property } + UNION + { ?link a owl:ObjectProperty } + FILTER NOT EXISTS { ?link a owl:DatatypeProperty } + \${filterByText} + ?link \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, elementInfoQuery: ` CONSTRUCT { ?inst ?class . diff --git a/src/data/sparql/sparqlGenerator.ts b/src/data/sparql/sparqlGenerator.ts new file mode 100644 index 00000000..75fc4e06 --- /dev/null +++ b/src/data/sparql/sparqlGenerator.ts @@ -0,0 +1,822 @@ +import { HashSet } from '@reactodia/hashmap'; + +import { multimapArrayAdd } from '../../coreUtils/collections'; + +import * as Rdf from '../rdf/rdfModel'; + +import { LinkTypeIri, PropertyTypeIri } from '../model'; +import { + DataProviderLookupParams, LookupPattern, HasTypeLookupPattern, ConnectedToLookupPattern, + HasPropertyLookupPattern, WithTextLookupPattern, lookupPatternOf, +} from '../dataProvider'; + +import { + SparqlDataProviderSettings, FullTextSearchSettings, LinkConfiguration, PropertyConfiguration, +} from './sparqlDataProviderSettings'; + +interface SparqlGeneratorOptions { + readonly acceptBlankNodes: boolean; +} + +export class SparqlGenerator { + private readonly acceptBlankNodes: boolean; + + readonly linkByPredicate = new Map(); + private linkById = new Map(); + readonly openWorldLinks: boolean; + + readonly propertyByPredicate = new Map(); + readonly openWorldProperties: boolean; + + constructor( + private readonly factory: Rdf.DataFactory, + private readonly settings: SparqlDataProviderSettings, + options: SparqlGeneratorOptions + ) { + const {acceptBlankNodes} = options; + this.acceptBlankNodes = acceptBlankNodes; + + for (const link of settings.linkConfigurations) { + this.linkById.set(link.id, link); + const predicate = isDirectLink(link) ? link.path : link.id; + multimapArrayAdd(this.linkByPredicate, predicate, link); + } + this.openWorldLinks = settings.linkConfigurations.length === 0 || + Boolean(settings.openWorldLinks); + + for (const property of settings.propertyConfigurations) { + const predicate = isDirectProperty(property) ? property.path : property.id; + multimapArrayAdd(this.propertyByPredicate, predicate, property); + } + this.openWorldProperties = settings.propertyConfigurations.length === 0 || + Boolean(settings.openWorldProperties); + } + + formatLanguageFilter(variable: string): string { + return formatLanguageFilter(variable, this.settings.filterOnlyLanguages); + } + + generateLookupSchemaQuery(params: { + lookupQuery: string; + textSearchSettings: FullTextSearchSettings; + itemTerm: Rdf.Variable; + text: string; + limit?: number; + }): string { + const {lookupQuery, textSearchSettings, itemTerm, text, limit} = params; + const {defaultPrefix, dataLabelProperty, schemaLabelProperty} = this.settings; + + let filterByText = ''; + if (textSearchSettings.extractLabel) { + filterByText += sparqlExtractLabel(escapeTerm(itemTerm), '?extractedLabel'); + } + filterByText += resolveTemplate(textSearchSettings.queryPattern, { + text: text, + dataLabelProperty, + schemaLabelProperty, + }); + const prefix = defaultPrefix + textSearchSettings.prefix; + const query = prefix + resolveTemplate(lookupQuery, { + schemaLabelProperty, + labelLanguageFilter: this.formatLanguageFilter('?label'), + filterByText: filterByText.replace(/[?$]inst\b/g, escapeTerm(itemTerm)), + limit: formatLimit(limit), + }); + return query; + } + + generateLinkTypesOfQuery(params: { + elementTerm: Rdf.NamedNode; + targetIn: Rdf.Variable; + targetOut: Rdf.Variable; + }): { query: string; bindDirection: boolean } { + const {elementTerm, targetIn, targetOut} = params; + const {defaultPrefix, linkTypesOfQuery} = this.settings; + + const bindDirection = /\?direction\b/.test(linkTypesOfQuery); + + const forAll = this.formatLinkUnion({ + sourceTerm: elementTerm, + targetIn: targetOut, + targetOut: targetIn, + withProjection: true, + }); + if (forAll.usePredicatePart) { + const bindOut = bindDirection ? 'BIND("out" AS ?direction)' : ''; + const bindIn = bindDirection ? 'BIND("in" AS ?direction)' : ''; + forAll.unionParts.push( + `{ ${escapeTerm(elementTerm)} ?link ${escapeTerm(targetOut)} ${bindOut} }` + ); + forAll.unionParts.push( + `{ ${escapeTerm(targetIn)} ?link ${escapeTerm(elementTerm)} ${bindIn} }` + ); + } + + const query = defaultPrefix + resolveTemplate(linkTypesOfQuery, { + elementIri: escapeTerm(elementTerm), + linkConfigurations: forAll.unionParts.join('\nUNION\n'), + }); + return {query, bindDirection}; + } + + generateLinkStatsQuery(params: { + elementTerm: Rdf.NamedNode; + targetIn: Rdf.Variable; + targetOut: Rdf.Variable; + linkType: LinkTypeIri; + navigateElementFilterOut: string; + navigateElementFilterIn: string; + }): string { + const { + elementTerm, targetIn, targetOut, linkType, + navigateElementFilterOut, navigateElementFilterIn, + } = params; + const {defaultPrefix, filterTypePattern, linkTypesStatisticsQuery} = this.settings; + + const linkConfig = this.linkById.get(linkType); + let linkConfigurationOut: string; + let linkConfigurationIn: string; + + if (!linkConfig || isDirectLink(linkConfig)) { + const predicate = escapeIri( + linkConfig && isDirectLink(linkConfig) ? linkConfig.path : linkType + ); + linkConfigurationOut = `${escapeTerm(elementTerm)} ${predicate} ${escapeTerm(targetOut)}`; + linkConfigurationIn = `${escapeTerm(targetIn)} ${predicate} ${escapeTerm(elementTerm)}`; + } else { + linkConfigurationOut = this.formatLinkPath(linkConfig.path, elementTerm, targetOut); + linkConfigurationIn = this.formatLinkPath(linkConfig.path, targetIn, elementTerm); + } + + if (linkConfig && linkConfig.domain && linkConfig.domain.length > 0) { + const commaSeparatedDomains = linkConfig.domain.map(escapeIri).join(', '); + const restrictionOut = filterTypePattern.replace(/[?$]inst\b/g, escapeTerm(elementTerm)); + const restrictionIn = filterTypePattern.replace(/[?$]inst\b/g, escapeTerm(targetIn)); + linkConfigurationOut += ` { ${restrictionOut} FILTER(?class IN (${commaSeparatedDomains})) }`; + linkConfigurationIn += ` { ${restrictionIn} FILTER(?class IN (${commaSeparatedDomains})) }`; + } + + const query = defaultPrefix + resolveTemplate(linkTypesStatisticsQuery, { + linkId: escapeIri(linkType), + elementIri: escapeTerm(elementTerm), + linkConfigurationOut, + linkConfigurationIn, + navigateElementFilterOut, + navigateElementFilterIn, + }); + return query; + } + + formatLinkStatsNavigateFilters() { + const navigateElementFilterOut = this.acceptBlankNodes + ? 'FILTER (IsIri(?outObject) || IsBlank(?outObject))' + : 'FILTER IsIri(?outObject)'; + // Specifically use isBlank() as opposed to isIri() for filtering to avoid + // a full scan on some endpoints (e.g. Virtuoso) + const navigateElementFilterIn = this.acceptBlankNodes + ? '' + : 'FILTER(!isBlank(?inObject))'; + return {navigateElementFilterIn, navigateElementFilterOut}; + } + + generateFilterQuery(params: DataProviderLookupParams): string { + const { + defaultPrefix, dataLabelProperty, lookupQuery, + filterOnlyLanguages, filterElementInfoPattern, fullTextSearch, + } = this.settings; + + let outerProjection = '?inst ?class ?label'; + let innerProjection = '?inst'; + + const entityTerm = this.factory.variable!('inst'); + const pattern = lookupPatternOf(params); + + const valueSets = new Map>(); + const projections = new Set<'link' | 'direction' | 'score'>(); + + let filterByType = ''; + let filterByRefElementLink = ''; + let filterByText = ''; + let filterByPattern = ''; + + const generateParams = { + entityTerm, + path: '', + valueSets, + projections, + withProjection: true, + } as const; + + const supportFilterByPattern = /\${filterByPattern}/.test(lookupQuery); + if (supportFilterByPattern) { + filterByPattern = this.generatePattern(pattern, generateParams); + } else { + const hasType = findPattern(pattern, p => p.type === 'hasType'); + if (hasType) { + filterByType = this.generatePattern(hasType, generateParams); + } + + const connectedTo = findPattern( + pattern, + p => p.type === 'connectedTo' + ); + if (connectedTo) { + filterByRefElementLink = this.generatePattern(connectedTo, generateParams); + } + + const withText = findPattern(pattern, p => p.type === 'withText'); + if (withText) { + filterByText = this.generatePattern(withText, generateParams); + } + } + + let prependedValueSets = ''; + for (const [variableName, setTerms] of valueSets) { + const valueSetContent = formatValueClauseContent(setTerms); + prependedValueSets += `VALUES(?${variableName}) { ${valueSetContent} }\n`; + } + + let queryTypes = ''; + if (projections.has('link')) { + outerProjection += ' ?link'; + innerProjection += ' ?link'; + if (this.settings.linkConfigurations.length > 0) { + outerProjection += ' ?classAll'; + queryTypes = this.settings.filterTypePattern.replace(/[?$]class\b/g, '?classAll'); + } + } + + if (projections.has('direction')) { + outerProjection += ' ?direction'; + innerProjection += ' ?direction'; + } + + let orderBy = ''; + if (projections.has('score')) { + innerProjection += ' ?score'; + orderBy = 'ORDER BY DESC(?score)'; + } + + const queryElementInfo = resolveTemplate(filterElementInfoPattern, { + dataLabelProperty, + labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + }); + + const limitPart = formatLimit(params.limit); + + const completePrefix = defaultPrefix + '\n' + fullTextSearch.prefix + '\n'; + return completePrefix + resolveTemplate(lookupQuery, { + innerProjection, + outerProjection, + filterInnerPrelude: this.settings.filterInnerPrelude, + filterByType, + filterByRefElementLink, + filterByText, + filterByPattern: prependedValueSets + filterByPattern, + filterAdditionalRestriction: this.settings.filterAdditionalRestriction, + orderBy, + limit: limitPart, + queryTypes, + queryElementInfo, + }); + } + + private generatePattern( + pattern: LookupPattern, + params: { + path: string; + entityTerm: Rdf.Variable; + valueSets: Map>; + projections: Set<'link' | 'direction' | 'score'>; + withProjection?: boolean; + } + ): string { + const {path, entityTerm, valueSets, projections, withProjection} = params; + switch (pattern.type) { + case 'allOf': + case 'anyOf': { + if (pattern.patterns.length === 0) { + return pattern.type === 'allOf' ? 'FILTER(false)' : ''; + } + const allValues = new Map>(); + const parts = pattern.patterns.map((branch, index) => { + const branchValues = new Map>(); + const generatedBranch = this.generatePattern(branch, { + path: `${path}_${index}`, + entityTerm, + valueSets: branchValues, + projections, + withProjection, + }); + for (const [setName, setValues] of branchValues) { + const existing = allValues.get(setName); + if (existing) { + if (pattern.type === 'allOf') { + keepOnlyIntersection(existing, setValues); + + } else { + // Union with existing + for (const item of setValues) { + existing.add(item); + } + } + } else { + allValues.set(setName, setValues); + } + } + return generatedBranch; + }); + + for (const [setName, setValues] of allValues) { + if (valueSets.has(setName)) { + throw new Error('Reactodia: nested patterns cannot shadow value sets'); + } + valueSets.set(setName, setValues); + } + + return '{\n' + parts.join(pattern.type === 'allOf' ? '\n' : '}\nUNION\n{') + '\n}'; + } + case 'hasType': { + const part = replaceVariables(this.settings.filterTypePattern, varName => { + if (varName === 'inst') { + return entityTerm; + } else if (varName === 'class') { + return this.factory.namedNode(pattern.elementType); + } else { + return this.factory.variable!(`${varName}${path}`); + } + }); + return part; + } + case 'connectedTo': { + const parts: string[] = []; + const targetTerm = + pattern.target?.type === 'fromValues' && pattern.target.values.length === 1 + ? this.factory.namedNode(pattern.target.values[0]) + : this.factory.variable!(`connectedTo_target${path}`); + parts.push( + this.generateConnectedToPattern(pattern, { + sourceTerm: entityTerm, + targetTerm, + predicateTerm: withProjection + ? this.factory.variable!('link') + : this.factory.variable!(`connectedTo_link${path}`), + withProjection, + }) + ); + if (withProjection) { + projections.add('link'); + projections.add('direction'); + } + if (pattern.target && targetTerm.termType === 'Variable') { + parts.push( + this.generatePattern(pattern.target, { + path: `${path}_target`, + entityTerm: targetTerm, + valueSets, + projections, + }) + ); + } + return parts.join('\n'); + } + case 'fromValues': { + const valueSet = new HashSet( + Rdf.hashTerm, + Rdf.equalTerms, + pattern.values.map(v => this.factory.namedNode(v)) + ); + valueSets.set(entityTerm.value, valueSet); + return ''; + } + case 'hasProperty': { + const parts: string[] = []; + const valueTerm = this.factory.variable!(`hasProperty_value${path}`); + parts.push( + this.generateHasPropertyPattern(pattern, { + entityTerm, + valueTerm, + predicateTerm: this.factory.variable!(`connectedTo_propType${path}`), + }) + ); + if (pattern.values && pattern.values.length > 0) { + const valueSet = new HashSet(Rdf.hashTerm, Rdf.equalTerms, pattern.values); + valueSets.set(entityTerm.value, valueSet); + } + return parts.join('\n'); + } + case 'withText': { + const scoreTerm = withProjection + ? this.factory.variable!('score') + : this.factory.variable!(`withText_score${path}`); + if (withProjection) { + projections.add('score'); + } + return this.generateWithTextPattern(pattern, { + entityTerm, + labelTerm: this.factory.variable!(`withText_label${path}`), + scoreTerm, + }); + } + default: { + throw new Error( + `Reactodia: unknown lookup pattern type "${(pattern as LookupPattern).type}"` + ); + } + } + } + + private generateConnectedToPattern( + pattern: ConnectedToLookupPattern, + params: { + sourceTerm: Rdf.Variable; + targetTerm: Rdf.Variable | Rdf.NamedNode; + predicateTerm: Rdf.Variable; + withProjection?: boolean; + } + ): string { + const {sourceTerm, targetTerm, predicateTerm, withProjection} = params; + + const {linkType, linkDirection} = pattern; + const {unionParts, usePredicatePart} = this.formatLinkUnion({ + sourceTerm: targetTerm, + targetIn: sourceTerm, + targetOut: sourceTerm, + linkType, + linkDirection, + withProjection + }); + + if (usePredicatePart) { + const source = escapeTerm(sourceTerm); + const target = escapeTerm(targetTerm); + let refLinkType: string | undefined; + if (linkType) { + const link = this.linkById.get(linkType); + refLinkType = link && isDirectLink(link) + ? escapeIri(link.path) + : escapeIri(linkType); + } + + const linkPattern = refLinkType || escapeTerm(predicateTerm); + const bindType = refLinkType ? `BIND(${refLinkType} as ${escapeTerm(predicateTerm)})` : ''; + // Specifically use isBlank() as opposed to isIri() when possible for filtering + // to avoid a full scan on some endpoints (e.g. Virtuoso) + const outFilter = this.acceptBlankNodes + ? `FILTER(isIri(${source}) || isBlank(${source}))` + : `FILTER(isIri(${source}))`; + const inFilter = this.acceptBlankNodes + ? '' + : `FILTER(!isBlank(${source}))`; + + if (!linkDirection || linkDirection === 'out') { + const binds = withProjection ? `BIND("out" as ?direction) ${bindType}` : ''; + unionParts.push(`{ ${target} ${linkPattern} ${source} ${binds} ${outFilter} }`); + } + if (!linkDirection || linkDirection === 'in') { + const binds = withProjection ? `BIND("in" as ?direction) ${bindType}` : ''; + unionParts.push(`{ ${source} ${linkPattern} ${target} ${binds} ${inFilter} }`); + } + } + + let resultPattern = unionParts.length === 0 ? 'FILTER(false)' : unionParts.join('\nUNION\n'); + + const useAllLinksPattern = !linkType && this.settings.filterRefElementLinkPattern.length > 0; + if (useAllLinksPattern) { + resultPattern += `\n${this.settings.filterRefElementLinkPattern}`; + } + + return resultPattern; + } + + private formatLinkUnion(params: { + sourceTerm: Rdf.Variable | Rdf.NamedNode; + targetIn: Rdf.Variable; + targetOut: Rdf.Variable; + linkType?: LinkTypeIri; + linkDirection?: 'in' | 'out'; + withProjection?: boolean; + }) { + const {sourceTerm, targetIn, targetOut, linkType, linkDirection, withProjection} = params; + const {linkConfigurations} = this.settings; + + const unionParts: string[] = []; + let hasDirectLink = false; + + for (const link of linkConfigurations) { + if (linkType && link.id !== linkType) { + continue; + } + + if (isDirectLink(link)) { + hasDirectLink = true; + } else { + const linkType = escapeIri(link.id); + if (!linkDirection || linkDirection === 'out') { + const path = this.formatLinkPath(link.path, sourceTerm, targetOut); + const binds = withProjection + ? `BIND(${linkType} as ?link) BIND("out" as ?direction)` : ''; + unionParts.push(`{ ${path} ${binds} }`); + } + if (!linkDirection || linkDirection === 'in') { + const path = this.formatLinkPath(link.path, targetIn, sourceTerm); + const binds = withProjection + ? `BIND(${linkType} as ?link) BIND("in" as ?direction)` : ''; + unionParts.push(`{ ${path} ${binds} }`); + } + } + } + + const usePredicatePart = this.openWorldLinks || hasDirectLink; + return {unionParts, usePredicatePart}; + } + + formatLinkLinks(): string { + const sourceTerm = this.factory.variable!('source'); + const targetTerm = this.factory.variable!('target'); + + const unionParts: string[] = []; + let hasDirectLink = false; + for (const link of this.settings.linkConfigurations) { + if (isDirectLink(link)) { + hasDirectLink = true; + } else { + const linkType = escapeIri(link.id); + unionParts.push( + `${this.formatLinkPath(link.path, sourceTerm, targetTerm)} BIND(${linkType} as ?type)` + ); + } + } + + const usePredicatePart = this.openWorldLinks || hasDirectLink; + if (usePredicatePart) { + unionParts.push(`${escapeTerm(sourceTerm)} ?type ${escapeTerm(targetTerm)}`); + } + + return ( + unionParts.length === 0 ? '' : + unionParts.length === 1 ? unionParts[0] : + '{ ' + unionParts.join(' }\nUNION\n{ ') + ' }' + ); + } + + private formatLinkPath( + path: string, + source: Rdf.Variable | Rdf.NamedNode, + target: Rdf.Variable | Rdf.NamedNode + ): string { + return replaceVariables(path, varName => { + if (varName === 'source') { + return source; + } else if (varName === 'target') { + return target; + } + return undefined; + }); + } + + private generateHasPropertyPattern( + pattern: HasPropertyLookupPattern, + params: { + entityTerm: Rdf.Variable; + valueTerm: Rdf.Variable; + predicateTerm: Rdf.Variable; + withProjection?: boolean; + } + ): string { + const {entityTerm, valueTerm, predicateTerm, withProjection} = params; + return this.formatPropertyUnion({ + entityTerm, + valueTerm, + predicateTerm, + propertyType: pattern.propertyType, + filterByValueType: true, + withProjection, + }); + } + + formatPropertyUnion(params: { + entityTerm: Rdf.Variable; + valueTerm: Rdf.Variable; + predicateTerm: Rdf.Variable; + propertyType?: PropertyTypeIri; + filterByValueType?: boolean; + withProjection?: boolean; + }): string { + const { + entityTerm, valueTerm, predicateTerm, propertyType, filterByValueType, withProjection, + } = params; + + const unionParts: string[] = []; + let hasDirectProperty = false; + for (const property of this.settings.propertyConfigurations) { + if (propertyType && property.id !== propertyType) { + continue; + } + + if (isDirectProperty(property)) { + hasDirectProperty = true; + } else { + const propType = escapeIri(property.id); + const formatted = this.formatPropertyPath(property.path, entityTerm, valueTerm); + const binds = withProjection + ? `BIND(${propType} as ${escapeTerm(predicateTerm)})` + : ''; + unionParts.push(`{ ${formatted} ${binds} }`); + } + } + + const usePredicatePart = this.openWorldProperties || hasDirectProperty; + if (usePredicatePart) { + const entity = escapeTerm(entityTerm); + const predicate = escapeTerm(predicateTerm); + const value = escapeTerm(valueTerm); + + if (propertyType) { + const binds = withProjection + ? `BIND(${escapeIri(propertyType)} as ${predicate})` + : ''; + unionParts.push(`{ ${entity} ${escapeIri(propertyType)} ${value} ${binds} }`); + } else { + let filter = ''; + if (filterByValueType) { + const directPredicates = new Set(); + for (const property of this.settings.propertyConfigurations) { + if (isDirectProperty(property)) { + directPredicates.add(property.path); + } + } + const commaSeparatedPredicates = Array.from(directPredicates, escapeIri).join(', '); + filter = commaSeparatedPredicates + ? `FILTER(isLiteral(${value}) || ${predicate} IN (${commaSeparatedPredicates}))` + : `FILTER(isLiteral(${value}))`; + } + unionParts.push(`{ ${entity} ${predicate} ${value} ${filter} }`); + } + } + + return unionParts.join('\nUNION\n'); + } + + private formatPropertyPath(path: string, subject: Rdf.Variable, value: Rdf.Variable): string { + return replaceVariables(path, varName => { + if (varName === 'inst') { + return subject; + } else if (varName === 'value') { + return value; + } + return undefined; + }); + } + + private generateWithTextPattern( + pattern: WithTextLookupPattern, + params: { + entityTerm: Rdf.Variable; + labelTerm: Rdf.Variable; + scoreTerm: Rdf.Variable; + } + ) { + const {entityTerm, labelTerm, scoreTerm} = params; + const parts: string[] = []; + if (this.settings.fullTextSearch.extractLabel) { + parts.push(sparqlExtractLabel(escapeTerm(entityTerm), escapeTerm(labelTerm))); + } + + const template = replaceVariables(this.settings.fullTextSearch.queryPattern, varName => { + if (varName === 'extractedLabel') { + return labelTerm; + } else if (varName === 'score') { + return scoreTerm; + } + return undefined; + }); + parts.push( + resolveTemplate(template, { + text: pattern.text, + dataLabelProperty: this.settings.dataLabelProperty, + schemaLabelProperty: this.settings.schemaLabelProperty, + }) + ); + return parts.join('\n'); + } +} + +export function isDirectLink(link: LinkConfiguration) { + // link configuration is path-based if includes any variables + const pathBased = /[?$][a-zA-Z]+\b/.test(link.path); + return !pathBased; +} + +export function isDirectProperty(property: PropertyConfiguration) { + // property configuration is path-based if includes any variables + const pathBased = /[?$][a-zA-Z]+\b/.test(property.path); + return !pathBased; +} + +export function resolveTemplate(template: string, values: { [key: string]: string | undefined }) { + let result = template; + for (const replaceKey in values) { + if (!Object.prototype.hasOwnProperty.call(values, replaceKey)) { continue; } + const replaceValue = values[replaceKey] || ''; + result = result.replace(new RegExp('\\${' + replaceKey + '}', 'g'), replaceValue); + } + return result; +} + +export function replaceVariables( + pattern: string, + replacer: (varName: string) => Rdf.Variable | Rdf.NamedNode | undefined +): string { + return pattern.replace(/[?$](.+?)\b/g, (original: string, varName: string) => { + const replaceWith = replacer(varName); + return replaceWith ? escapeTerm(replaceWith) : original; + }); +} + +function formatLanguageFilter( + variable: string, + languages: ReadonlyArray | undefined +): string { + if (!languages) { + return ''; + } + const parts = [`FILTER(!isLiteral(${variable}) || lang(${variable}) = ""`]; + for (const language of languages) { + parts.push(` || lang(${variable}) = "${language}"`); + } + parts.push(')'); + return parts.join(''); +} + +function sparqlExtractLabel(subject: string, label: string): string { + return ` + BIND(str(${subject}) as ?uriStr) + BIND(strafter(?uriStr, "#") as ?label3) + BIND(strafter(strafter(?uriStr, "//"), "/") as ?label6) + BIND(strafter(?label6, "/") as ?label5) + BIND(strafter(?label5, "/") as ?label4) + BIND(if (?label3 != "", ?label3, + if (?label4 != "", ?label4, + if (?label5 != "", ?label5, ?label6))) as ${label}) + `; +} + +export function formatValueClauseContent( + terms: Iterable +): string { + return Array.from(terms, term => { + const escaped = typeof term === 'string' ? escapeIri(term) : escapeTerm(term); + return `(${escaped})`; + }).join(' '); +} + +function formatLimit(limit: number | null | undefined): string { + return typeof limit === 'number' ? `LIMIT ${limit}` : ''; +} + +export function escapeTerm(term: Rdf.Term) { + return Rdf.termToString(term); +} + +export function escapeIri(iri: string) { + if (typeof iri !== 'string') { + throw new Error(`Cannot escape IRI of type "${typeof iri}"`); + } + return escapeTerm(Rdf.DefaultDataFactory.namedNode(iri)); +} + +function findPattern( + root: LookupPattern, + matches: (p: LookupPattern) => p is T +): T | undefined { + if (matches(root)) { + return root; + } else if (root.type === 'allOf') { + for (const pattern of root.patterns) { + const found = findPattern(pattern, matches); + if (found) { + return found; + } + } + } + return undefined; +} + +interface BasicSet extends Iterable { + has(item: T): boolean; + add(item: T): this; + delete(item: T): boolean; +} + +function keepOnlyIntersection(baseSet: BasicSet, otherSet: BasicSet): void { + const toRemove: T[] = []; + for (const item of baseSet) { + if (!otherSet.has(item)) { + toRemove.push(item); + } + } + for (const removed of toRemove) { + baseSet.delete(removed); + } +} diff --git a/src/diagram/spinner.tsx b/src/diagram/spinner.tsx index 69a92d08..fe8ab82b 100644 --- a/src/diagram/spinner.tsx +++ b/src/diagram/spinner.tsx @@ -66,6 +66,10 @@ export function Spinner(props: SpinnerProps) { * @see {@link Spinner} */ export function HtmlSpinner(props: { + /** + * Additional CSS class for the component. + */ + className?: string; /** * SVG canvas width to render spinner on. */ @@ -82,10 +86,10 @@ export function HtmlSpinner(props: { */ errorOccurred?: boolean; }) { - const {width, height, errorOccurred} = props; + const {className, width, height, errorOccurred} = props; const size = Math.min(width, height); return ( - + { return useAsync({ input: [locale, assetIri], - load: ([locale, assetIri], {signal}) => { + load: async ([locale, assetIri], {signal}) => { return assetIri === undefined ? undefined : locale.resolveAssetUrl(assetIri, {signal}); }, }); diff --git a/src/editor/withFetchStatus.tsx b/src/editor/withFetchStatus.tsx index 06a2c4ba..e1a2d3b5 100644 --- a/src/editor/withFetchStatus.tsx +++ b/src/editor/withFetchStatus.tsx @@ -28,12 +28,6 @@ export interface WithFetchStatusProps { children: React.ReactElement<{ className?: string }>; } -enum Status { - None = 0, - Loading = 1, - Error = 2, -} - const CLASS_NAME = 'reactodia-fetch-status'; /** @@ -52,31 +46,59 @@ const CLASS_NAME = 'reactodia-fetch-status'; export function WithFetchStatus(props: WithFetchStatusProps) { const {type, target, children} = props; + const status = useFetchStatus({type, target}); + + if (status === undefined) { + return children; + } else { + return React.cloneElement(children, { + className: cx(children.props.className, ( + status === 'loading' ? `${CLASS_NAME}--loading` : + status === 'error' ? `${CLASS_NAME}--error` : + undefined + )), + }); + } +} + +export function useFetchStatus(params: { + /** + * Fetch operation type. + */ + type: T; + /** + * Fetch operation target. + */ + target: FetchOperationTypeToTarget[T] | undefined; +}): 'loading' | 'error' | undefined { + const {type, target} = params; const {model} = useWorkspace(); - const [status, setStatus] = React.useState( + const [status, setStatus] = React.useState<'loading' | 'error' | undefined>( // Initialize with error status if needed to avoid layout jumps - model.getOperationFailReason(type, target) ? Status.Error : Status.None + target !== undefined && model.getOperationFailReason(type, target) + ? 'error' : undefined ); React.useEffect(() => { const checkOperations = (fail?: FetchOperationFail) => { setStatus(() => { - if (fail && isOperationMatch(fail.operation, type, target)) { - return Status.Error; - } + if (target) { + if (fail && isOperationMatch(fail.operation, type, target)) { + return 'error'; + } - for (const operation of model.operations) { - if (isOperationMatch(operation, type, target)) { - return Status.Loading; + for (const operation of model.operations) { + if (isOperationMatch(operation, type, target)) { + return 'loading'; + } } - } - if (model.getOperationFailReason(type, target)) { - return Status.Error; + if (model.getOperationFailReason(type, target)) { + return 'error'; + } } - - return Status.None; + return undefined; }); }; @@ -90,17 +112,7 @@ export function WithFetchStatus(props: WithF return () => model.events.off('changeOperations', listener); }, [type, target]); - if (status === Status.None) { - return children; - } else { - return React.cloneElement(children, { - className: cx(children.props.className, ( - status === Status.Loading ? `${CLASS_NAME}--loading` : - status === Status.Error ? `${CLASS_NAME}--error` : - undefined - )), - }); - } + return status; } function isOperationMatch( diff --git a/src/widgets/classTree/classTreeResults.tsx b/src/widgets/classTree/classTreeResults.tsx index 08122b79..a31ba53a 100644 --- a/src/widgets/classTree/classTreeResults.tsx +++ b/src/widgets/classTree/classTreeResults.tsx @@ -4,13 +4,12 @@ import * as React from 'react'; import { useTranslation } from '../../coreUtils/i18n'; import { ElementTypeIri, ElementTypeModel } from '../../data/model'; -import { useWorkspace } from '../../workspace/workspaceContext'; import { AccessibleTree, type TreeModel, type TreeRenderItem, TreeState, type TreeUpPath, TreeFocusableProps, treePathToDown, } from '../utility/accessibleTree'; -import { highlightSubstring } from '../utility/listElementView'; +import { SchemaTypeView } from '../utility/schemaTypeView'; export interface ClassTreeResultsProps extends ClassTreeProvidedContext { nodes: ReadonlyArray; @@ -129,7 +128,7 @@ export function ClassTreeResults(props: ClassTreeResultsProps) { export interface TreeNode { readonly iri: ElementTypeIri - readonly data: ElementTypeModel | undefined; + readonly data: ElementTypeModel; readonly label: string; readonly derived: ReadonlyArray; } @@ -157,7 +156,6 @@ function Leaf(props: { searchText, creatableClasses, onClickCreate, onDragCreate, draggableItems, onExpand, onSelect, } = useClassTreeContext(); - const {getElementTypeStyle} = useWorkspace(); const t = useTranslation(); const toggleClass = ( @@ -166,20 +164,6 @@ function Leaf(props: { `${CLASS_NAME}__toggle-collapsed` ); - const typeStyle = getElementTypeStyle([node.iri]); - const providedStyle = { - '--reactodia-element-style-color': typeStyle.color, - } as React.CSSProperties; - - const bodyClass = cx( - `${CLASS_NAME}__body`, - selected ? `${CLASS_NAME}__body--selected` : undefined - ); - - const label = highlightSubstring( - node.label, searchText, {className: `${CLASS_NAME}__highlighted-term`} - ); - const onDragStart = (e: React.DragEvent) => { // sets the drag data to support drag-n-drop in Firefox // see https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations for more details @@ -189,15 +173,16 @@ function Leaf(props: { }; return ( -
+
onExpand(path)} /> - { e.preventDefault(); onSelect(node, path); @@ -209,29 +194,12 @@ function Leaf(props: { } }} draggable={draggableItems}> -
- {typeStyle.icon ? ( - - ) : ( -
- )} -
- {label} {node.data?.count ? ( {node.data.count} ) : null} -
+ {creatableClasses.get(node.iri) ? (
); diff --git a/src/widgets/editorForms/editRelationForm.tsx b/src/widgets/editorForms/editRelationForm.tsx index 64f0fe8c..a641765b 100644 --- a/src/widgets/editorForms/editRelationForm.tsx +++ b/src/widgets/editorForms/editRelationForm.tsx @@ -402,7 +402,7 @@ function useRelationMetadata( ): readonly [shape: RelationMetadata | undefined, error?: unknown] { const {data, status, error} = useAsync({ input: [metadataProvider, link.linkTypeId, linkSource, linkTarget], - load: ([provider, _typeId, source, target], {signal}) => { + load: async ([provider, _typeId, source, target], {signal}) => { if (provider) { return Promise.all([ provider.canModifyRelation(link, source, target, {signal}), diff --git a/src/widgets/editorForms/elementTypeSelector.tsx b/src/widgets/editorForms/elementTypeSelector.tsx index 64ea3f43..15a5676c 100644 --- a/src/widgets/editorForms/elementTypeSelector.tsx +++ b/src/widgets/editorForms/elementTypeSelector.tsx @@ -17,14 +17,11 @@ import type { MetadataCreatedEntity } from '../../data/metadataProvider'; import { HtmlSpinner } from '../../diagram/spinner'; import { type DataDiagramModel, getAllPresentEntities } from '../../editor/dataDiagramModel'; -import { EntityElement } from '../../editor/dataElements'; import { subscribeElementTypes } from '../../editor/observedElement'; -import { ListElementView } from '../utility/listElementView'; import { NoSearchResults } from '../utility/noSearchResults'; import { SearchInput, SearchInputStore, useSearchInputStore } from '../utility/searchInput'; import { SearchResults } from '../utility/searchResults'; -import { createRequest } from '../instancesSearch'; import { type WorkspaceContext, useWorkspace } from '../../workspace/workspaceContext'; @@ -159,11 +156,10 @@ export class ElementTypeSelectorInner extends React.Component - - + }} + slotInput={} + /> { searchStore.value.length > 0 ? (
readonly ElementModel[] + ) => void; +}) { + const {dataProvider, selectedStatus, selected, setSelected} = props; + + const suggest = React.useCallback(async (text: string, options: { signal: AbortSignal }) => { + const {signal} = options; + // await delay(2000); + // if (Math.random() > 0.5) { + // throw new Error('Random error'); + // } + const items = await dataProvider.lookup({text, signal}); + return items.map(item => item.element); + }, [dataProvider]); + + // const suggestDefault = React.useCallback(async (options: { signal: AbortSignal }) => { + // const {signal} = options; + // }); + + const selectedSet = React.useMemo(() => new Set(selected.map(v => v.id)), [selected]); + return ( + + {selected.map(item => + + + + + ) : ( + <> + + + + )} +
+ ) : null} + +
+ ); +} diff --git a/src/widgets/instancesSearch/instancesSearch.tsx b/src/widgets/instancesSearch/instancesSearch.tsx index 9cbdf759..1372a130 100644 --- a/src/widgets/instancesSearch/instancesSearch.tsx +++ b/src/widgets/instancesSearch/instancesSearch.tsx @@ -1,31 +1,35 @@ import cx from 'clsx'; import * as React from 'react'; -import { EventObserver } from '../coreUtils/events'; -import { Debouncer } from '../coreUtils/scheduler'; -import { useTranslation, type Translation, TranslatedText } from '../coreUtils/i18n'; +import { EventObserver } from '../../coreUtils/events'; +import { Debouncer } from '../../coreUtils/scheduler'; +import { useTranslation, type Translation, TranslatedText } from '../../coreUtils/i18n'; -import { ElementModel, ElementIri, ElementTypeIri, LinkTypeIri } from '../data/model'; -import { DataProviderLookupParams, DataProviderLookupItem } from '../data/dataProvider'; +import { ElementModel, ElementIri } from '../../data/model'; +import { DataProviderLookupParams, DataProviderLookupItem } from '../../data/dataProvider'; -import type { CanvasApi } from '../diagram/canvasApi'; -import { placeElementsAroundTarget } from '../diagram/commands'; -import { Element, VoidElement } from '../diagram/elements'; -import { Vector, boundsOf } from '../diagram/geometry'; +import type { CanvasApi } from '../../diagram/canvasApi'; +import { placeElementsAroundTarget } from '../../diagram/commands'; +import { Element, VoidElement } from '../../diagram/elements'; +import { Vector, boundsOf } from '../../diagram/geometry'; -import { - DataGraphStructure, requestElementData, restoreLinksBetweenElements, -} from '../editor/dataDiagramModel'; -import { EntityElement, EntityGroup, iterateEntitiesOf } from '../editor/dataElements'; +import { requestElementData, restoreLinksBetweenElements } from '../../editor/dataDiagramModel'; +import { EntityElement, EntityGroup } from '../../editor/dataElements'; + +import { WorkspaceContext, WorkspaceEventKey, useWorkspace } from '../../workspace/workspaceContext'; +import { InstancesSearchTopic } from '../../workspace/commandBusTopic'; -import { WorkspaceContext, WorkspaceEventKey, useWorkspace } from '../workspace/workspaceContext'; -import { InstancesSearchTopic } from '../workspace/commandBusTopic'; +import { ErrorMessage } from '../utility/errorMessage'; +import { NoSearchResults } from '../utility/noSearchResults'; +import { ProgressBar, ProgressState } from '../utility/progressBar'; +import { SearchInputStore, useSearchInputStore } from '../utility/searchInput'; +import { SearchResults } from '../utility/searchResults'; -import { InlineEntity } from './utility/inlineEntity'; -import { NoSearchResults } from './utility/noSearchResults'; -import { ProgressBar, ProgressState } from './utility/progressBar'; -import { SearchInput, SearchInputStore, useSearchInputStore } from './utility/searchInput'; -import { SearchResults } from './utility/searchResults'; +import { InstanceCriteria } from './instanceCriteria'; +import { + type SearchCriterionOption, DefaultSearchCriterionOptions, makePatternGenerator, +} from './searchCriterion'; +import { SearchCriteria } from './searchPattern'; /** * Props for {@link InstancesSearch} component. @@ -59,6 +63,8 @@ export interface InstancesSearchProps { * @default 3 */ minSearchTermLength?: number; + + criterionOptions?: readonly SearchCriterionOption[]; /** * Handler for the search criteria changes. */ @@ -99,38 +105,6 @@ export interface InstancesSearchCommands { }; } -/** - * A filter criteria for the entity lookup from a {@link DataProvider}. - * - * @see {@link DataProviderLookupParams} - */ -export interface SearchCriteria { - /** - * Filter by a text lookup. - */ - readonly text?: string; - /** - * Filter by an element type. - */ - readonly elementType?: ElementTypeIri; - /** - * Filter by having a connected element with specified IRI. - */ - readonly refElement?: ElementIri; - /** - * Filter by connection link type. - * - * Only applicable when {@link refElement} is set. - */ - readonly refElementLink?: LinkTypeIri; - /** - * Reference element link type direction ('in' | 'out'). - * - * Only when {@link refElementLink} is set. - */ - readonly linkDirection?: 'in' | 'out'; -} - /** * Component to search for entities by various filter criteria * to add them as elements to the diagram. @@ -174,7 +148,7 @@ interface State { readonly criteria: SearchCriteria; readonly querying?: boolean; readonly resultId: number; - readonly error?: any; + readonly error?: unknown; readonly items?: ReadonlyArray; readonly selection: ReadonlySet; readonly moreItemsAvailable?: boolean; @@ -310,6 +284,7 @@ class InstancesSearchInner extends React.Component -
- {this.renderCriteria()} - {isControlled ? null : ( - + this.setState( + {criteria}, + () => this.props.onChangeCriteria?.(this.state.criteria) )} -
+ searchStore={isControlled ? undefined : searchStore} + criterionOptions={criterionOptions} + /> - {queryError ? ( -
- {queryError} -
- ) : null} + {this.state.error ? : null} {/* specify resultId as key to reset scroll position when loaded new search results */}
{ - const {workspace: {model}, translation: t} = this.props; - const {criteria} = this.state; - const criterions: React.ReactElement[] = []; - - if (criteria.elementType) { - const elementTypeInfo = model.getElementType(criteria.elementType); - const elementTypeLabel = t.formatLabel( - elementTypeInfo?.data?.label, - criteria.elementType, - model.language - ); - criterions.push( -
- {this.renderRemoveCriterionButtons(() => this.setState( - { - criteria: {...criteria, elementType: undefined}, - }, - () => this.props.onChangeCriteria?.(this.state.criteria) - ))} - {t.template('search_entities.criteria_has_type', { - entityType: ( - - {elementTypeLabel} - - ) - })} -
- ); - } else if (criteria.refElement) { - const refElementData = findEntityData(model, criteria.refElement) - ?? EntityElement.placeholderData(criteria.refElement); - - let linkTypeLabel: string | undefined; - if (criteria.refElementLink) { - const linkTypeData = model.getLinkType(criteria.refElementLink); - linkTypeLabel = t.formatLabel( - linkTypeData?.data?.label, - criteria.refElementLink, - model.language - ); - } - - const entity = ; - const relationType = criteria.refElementLink ? ( - - {linkTypeLabel} - - ) : undefined; - const sourceIcon = ; - const targetIcon = ; - - criterions.push( -
- {this.renderRemoveCriterionButtons(() => this.setState( - { - criteria: {...criteria, refElement: undefined, refElementLink: undefined}, - }, - () => this.props.onChangeCriteria?.(this.state.criteria) - ))} - {!criteria.refElementLink ? ( - t.template('search_entities.criteria_connected', { - entity, relationType, sourceIcon, targetIcon, - }) - ) : criteria.linkDirection === 'in' ? ( - t.template('search_entities.criteria_connected_to_source', { - entity, relationType, sourceIcon, targetIcon, - }) - ) : criteria.linkDirection == 'out' ? ( - t.template('search_entities.criteria_connected_to_target', { - entity, relationType, sourceIcon, targetIcon, - }) - ) : ( - t.template('search_entities.criteria_connected_via', { - entity, relationType, sourceIcon, targetIcon, - }) - )} -
- ); - } - - return
{criterions}
; - } - - private renderRemoveCriterionButtons(onClick: () => void) { - return
- -
; - } - private submitCriteriaUpdate(term: string, options: { triggerChange: boolean }): void { const {onChangeCriteria} = this.props; this.setState( @@ -520,7 +389,10 @@ class InstancesSearchInner extends React.Component { + typeKind: Kind; + selected: SchemaType[Kind] | undefined; + setSelected: (item: SchemaType[Kind] | undefined) => void; +} + +export function SchemaTypeSelect( + props: SchemaTypeSelectProps +) { + const {typeKind, selected, setSelected} = props; + const t = useTranslation(); + const {model} = useWorkspace(); + const {dataProvider} = model; + + const language = useObservedProperty(model.events, 'changeLanguage', () => model.language); + + const typeStatus = useFetchStatus({type: typeKind, target: selected?.id}); + + const suggest = React.useCallback(async (text: string, options: { signal: AbortSignal }) => { + const {signal} = options; + // await delay(2000); + // if (Math.random() > 0.5) { + // throw new Error('Random error'); + // } + switch (typeKind) { + case 'elementType': { + return dataProvider.lookupElementTypes({text, signal}); + } + case 'propertyType': { + return dataProvider.lookupPropertyTypes({text, signal}); + } + case 'linkType': { + return dataProvider.lookupLinkTypes({text, signal}); + } + } + }, [typeKind, dataProvider]) as (text: string) => Promise; + + const suggestDefault = React.useCallback( + async ( + options: { signal: AbortSignal } + ): Promise => { + const {signal} = options; + // await delay(2000); + // if (Math.random() > 0.5) { + // throw new Error('Random error'); + // } + switch (typeKind) { + case 'elementType': { + const {elementTypes} = await dataProvider.knownElementTypes({signal}); + return elementTypes; + } + case 'propertyType': { + const propertyTypeIds = getPresentPropertyTypes(model); + const propertyTypes = await dataProvider.propertyTypes({ + propertyIds: Array.from(propertyTypeIds), + signal, + }); + return Array.from(propertyTypes.values()); + } + case 'linkType': { + const linkTypeIds = getPresentLinkTypes(model); + const linkTypes = await dataProvider.linkTypes({ + linkTypeIds: Array.from(linkTypeIds), + signal, + }); + return Array.from(linkTypes.values()); + } + } + }, + [typeKind, model, dataProvider] + ) as () => Promise; + + const comparator = React.useMemo( + () => makeSchemaTypesComparator(t, language), + [t, language] + ); + + return ( + +
+ ) : null; + const option = patternOptions.find(option => option.type === pattern.type) + ?? PlaceholderUnknownOption; + const optionProps: SearchCriterionProps> = { + pattern: pattern as SearchPattern, + updatePattern: setPattern, + replacePattern: setPattern, + editing, + parent, + controls, + }; + + return option.renderCriterion(optionProps); +} + +export function AddCriterion(props: { + onAdd: (pattern: SearchPattern) => void; + title?: string; + children: React.ReactNode; +}) { + const {onAdd, title, children} = props; + const t = useTranslation(); + const {patternOptions} = React.useContext(SearchCriterionContext); + return ( + + {children} + + }> + {patternOptions.map(option => option.label ? ( + { + const pattern = SearchPattern.create( + option.type, + option.createState() + ); + onAdd(pattern); + }}> + {typeof option.label === 'string' + ? option.label + : option.label.resolve(t)} + + ) : null)} + + ); +} + +function AddCriterionButton(props: { + title: string; + children: React.ReactNode; +}) { + const {title, children} = props; + const {expanded, setExpanded} = useDropdownMenu(); + return ( + + ); +} + +export function SearchCriterionBlock(props: { + target: SearchPattern; + header: React.ReactNode; + controls: React.ReactNode; + children?: React.ReactNode; +}) { + const {target, header, controls, children} = props; + const t = useTranslation(); + const {errors} = React.useContext(SearchCriterionContext); + const targetErrors = errors.get(target); + return ( +
+
+ {header} +
+
+ {controls} +
+
+ {children} +
+ {targetErrors ? ( +
    + {targetErrors.map((error, index) => ( +
  • + {typeof error.message === 'string' + ? error.message + : error.message.resolve(t)} +
  • + ))} +
+ ) : null} +
+ ); +} + +export function SearchCriterionWhereGroup(props: { children: React.ReactElement }) { + const {children} = props; + return ( +
+ {children} +
+ ); +} + +export function SearchCriterionWhere(props: { + part: string; + pattern: SearchPattern | undefined; + setPattern: (pattern: SearchPattern | undefined) => void; + parent: SearchPattern | undefined; +}) { + const {part, pattern, setPattern, parent} = props; + const t = useTranslation(); + return ( + <> +
+ {t.template('search_entities.criteria_where_caption', { + part: {part}, + })} +
+
+ +
+ + ); +} + +function PlaceholderAnyCriterion(props: { + setPattern: (pattern: SearchPattern) => void; + editing: boolean; + topLevel?: boolean; +}) { + const {setPattern, editing, topLevel} = props; + const t = useTranslation(); + + if (!editing) { + return topLevel ? null : ( +
+ {t.text('search_entities.criteria_placeholder_any')} +
+ ); + } + + return ( + + {t.text('search_entities.add_criterion.label')} + + ); +} + +const PlaceholderUnknownOption: SearchCriterionOption = { + type: SearchPattern.type('urn:reactodia:unknownPattern').of(), + createState: () => ({}), + renderCriterion: props => , + generatePattern: () => { + throw new Error('Unknown pattern type'); + }, +}; + +function PlaceholderUnknownCriterion( + props: SearchCriterionProps +) { + const {pattern, controls} = props; + const t = useTranslation(); + return ( + + {t.text('search_entities.criteria_placeholder_unknown', { + patternType: pattern.type, + })} + + } + /> + ); +} + +export const SearchCriterionAllOf: SearchCriterionOption = { + type: SearchPatternAllOf, + createState: () => ({patterns: []}), + renderCriterion: props => , + generatePattern: (state, target, context) => { + if (state.patterns.length === 0) { + return { + type: 'error', + errors: [{ + target, + message: TranslatedText.text( + 'search_entities.error.required_at_least_one_pattern' + ), + }], + }; + } + const inner = context.generateMany(state.patterns); + return Array.isArray(inner) ? {type: 'allOf', patterns: inner} : inner; + }, +}; + +export const SearchCriterionAnyOf: SearchCriterionOption = { + type: SearchPatternAnyOf, + createState: () => ({patterns: []}), + renderCriterion: props => , + generatePattern: (state, target, context) => { + if (state.patterns.length === 0) { + return { + type: 'error', + errors: [{ + target, + message: TranslatedText.text( + 'search_entities.error.required_at_least_one_pattern' + ), + }], + }; + } + const inner = context.generateMany(state.patterns); + return Array.isArray(inner) ? {type: 'anyOf', patterns: inner} : inner; + }, +}; + +function AnyAllOfCriterion

( + props: SearchCriterionProps

+) { + const {pattern, updatePattern, replacePattern, editing, parent} = props; + const t = useTranslation(); + const className = parent + ? `${CLASS_NAME}__logical-group` + : `${CLASS_NAME}__logical-group-root`; + return ( +

+ {pattern.state.patterns.map((child, index) => ( + + {index > 0 ? ( +
+ {( + pattern.type === SearchPatternAllOf ? t.text( + 'search_entities.criteria_all_of_joiner' + ) : pattern.type === SearchPatternAnyOf ? t.text( + 'search_entities.criteria_any_of_joiner' + ) : pattern.type + )} +
+ ) : null} + { + const nextPatterns = [...pattern.state.patterns]; + if (next) { + if (SearchPattern.hasType(next, pattern.type)) { + nextPatterns.splice(index, 1, ...next.state.patterns); + } else { + nextPatterns.splice(index, 1, next); + } + } else { + nextPatterns.splice(index, 1); + } + + replacePattern( + nextPatterns.length === 0 ? undefined : + nextPatterns.length === 1 ? nextPatterns[0] : + { + ...pattern, + state: { + patterns: nextPatterns, + } satisfies SearchPatternState< + typeof SearchPatternAllOf | typeof SearchCriterionAnyOf + >, + } + ); + }} + parent={pattern} + /> +
+ ))} + {editing && pattern.state.patterns.length === 0 ? ( + updatePattern({ + ...pattern, + state: { + patterns: [added] + } satisfies SearchPatternState< + typeof SearchPatternAllOf | typeof SearchCriterionAnyOf + > as SearchPatternState

, + })} + title={ + pattern.type === SearchPatternAllOf ? t.textOptional( + 'search_entities.criteria_all_of_add.title' + ) : pattern.type === SearchPatternAnyOf ? t.textOptional( + 'search_entities.criteria_any_of_add.title' + ) : undefined + }> + {( + pattern.type === SearchPatternAllOf ? t.text( + 'search_entities.criteria_all_of_add.label' + ) : pattern.type === SearchPatternAnyOf ? t.text( + 'search_entities.criteria_any_of_add.label' + ) : t.text('search_entities.add_criterion.label') + )} + + ) : null} +

+ ); +} + +export const SearchCriterionHasType: SearchCriterionOption = { + type: SearchPatternHasType, + label: TranslatedText.text('search_entities.criteria_has_type.label'), + createState: () => ({elementType: ''}), + renderCriterion: props => , + generatePattern: (state, target) => { + if (!state.elementType) { + return { + type: 'error', + errors: [{ + target, + message: TranslatedText.text( + 'search_entities.error.required_element_type' + ), + }], + }; + } + return { + type: 'hasType', + elementType: state.elementType, + }; + }, +}; + +function HasTypeCriterion( + props: SearchCriterionProps +) { + const {pattern, updatePattern, editing, controls} = props; + const t = useTranslation(); + + const elementType = useSchemaType('elementType', pattern.state.elementType); + + let header: React.ReactNode; + let where: React.ReactNode | null = null; + if (!editing) { + const typeView = elementType ? ( + + ) : ( +
+ {t.text('search_entities.criteria_has_type.any_type')} +
+ ); + + header = ( + + {t.template('search_entities.criteria_has_type', { + entityType: typeView, + })} + + ); + } else { + header =
{t.text('search_entities.criteria_has_type.header')}
; + where = ( + updatePattern({ + ...pattern, + state: {...pattern.state, elementType: type ? type.id : ''}, + })} + /> + ); + } + + return ( + + {where} + + ); +} + +export const SearchCriterionConnectedTo: SearchCriterionOption = { + type: SearchPatternConnectedTo, + label: TranslatedText.text('search_entities.criteria_connected.label'), + createState: () => ({}), + renderCriterion: props => , + generatePattern: (state, _target, context) => { + const target = state.target ? context.generate(state.target) : undefined; + if (target?.type === 'error') { + return target; + } + return { + type: 'connectedTo', + linkType: state.linkType, + linkDirection: state.linkDirection, + target, + }; + }, +}; + +function ConnectedToCriterion( + props: SearchCriterionProps +) { + const {pattern, updatePattern, editing, controls} = props; + const t = useTranslation(); + + const {target, linkDirection} = pattern.state; + + const linkType = useSchemaType('linkType', pattern.state.linkType); + + let inlineEntity = false; + let header: React.ReactNode; + let where: React.ReactNode = null; + + if (!editing) { + let entity: React.ReactElement; + if (!target || SearchPattern.hasType(target, SearchPatternFromValues)) { + inlineEntity = true; + entity = ; + } else { + entity = ( + + {t.text('search_entities.criteria_connected.target-part')} + + ); + } + + const relationType = linkType ? ( + + ) : ( +
+ {t.text('search_entities.criteria_connected.any-link')} +
+ ); + + const sourceIcon = ; + const targetIcon = ; + + header = ( + + {!linkType && !linkDirection ? ( + t.template('search_entities.criteria_connected', { + entity, relationType, sourceIcon, targetIcon, + }) + ) : linkDirection === 'in' ? ( + t.template('search_entities.criteria_connected_to_source', { + entity, relationType, sourceIcon, targetIcon, + }) + ) : linkDirection == 'out' ? ( + t.template('search_entities.criteria_connected_to_target', { + entity, relationType, sourceIcon, targetIcon, + }) + ) : ( + t.template('search_entities.criteria_connected_via', { + entity, relationType, sourceIcon, targetIcon, + }) + )} + + ); + } else { + header = ( +
+ {t.template('search_entities.criteria_connected.header', { + entity: ( + + {t.text('search_entities.criteria_connected.target-part')} + + ), + })} +
+ ); + where = ( +
+ updatePattern({ + ...pattern, + state: { + ...pattern.state, + linkType: next?.id, + }, + })} + /> + updatePattern({ + ...pattern, + state: { + ...pattern.state, + linkDirection: next, + }, + })} + /> +
+ ); + } + + return ( + + {where} + {inlineEntity ? null : ( + + updatePattern({ + ...pattern, + state: {...pattern.state, target}, + })} + parent={pattern} + /> + + )} + + ); +} + +export const SearchCriterionFromValues: SearchCriterionOption = { + type: SearchPatternFromValues, + label: TranslatedText.text('search_entities.criteria_from_values.label'), + createState: () => ({values: []}), + renderCriterion: props => , + generatePattern: state => ({ + type: 'fromValues', + values: state.values, + }), +}; + +function FromValuesCriterion( + props: SearchCriterionProps +) { + const {pattern, updatePattern, editing, controls} = props; + const t = useTranslation(); + + let header: React.ReactNode; + let where: React.ReactNode = null; + if (!editing) { + header = ( + + {t.template('search_entities.criteria_from_values', { + values: , + })} + + ); + } else { + header =
{t.text('search_entities.criteria_from_values.header')}
; + where = ; + } + + return ( + + {where} + + ); +} + +function FromValuesList(props: { + values: readonly ElementIri[] +}) { + const {values} = props; + const t = useTranslation(); + const {model} = useWorkspace(); + const providedEntities = useProvidedEntities(model.dataProvider, values); + if (values.length === 0) { + return {t.text('search_entities.criteria_from_values.any-entity')}; + } else if (values.length <= 10) { + return ( + + {values.map((iri, index) => { + const data = providedEntities.data.get(iri) + ?? EntityElement.placeholderData(iri); + return ( + + {index === 0 ? null : ( + + {t.text('search_entities.criteria_from_values.joiner')} + + )} + + + ); + })} + + ); + } else { + return ( + + {t.text('search_entities.criteria_from_values.many-entities', { + count: values.length, + })} + + ); + } +} + +function FromValuesSelector(props: Pick< + SearchCriterionProps, + 'pattern' | 'updatePattern' +>) { + const {pattern, updatePattern} = props; + const {model} = useWorkspace(); + const providedEntities = useProvidedEntities( + model.dataProvider, + pattern.state.values + ); + const selected = Array.from(providedEntities.data.values()); + return ( + { + const nextValues = update(selected); + updatePattern({ + ...pattern, + state: { + values: nextValues.map(v => v.id), + }, + }); + }} + /> + ); +} + +export const SearchCriterionHasProperty: SearchCriterionOption = { + type: SearchPatternHasProperty, + label: TranslatedText.text('search_entities.criteria_has_property.label'), + createState: () => ({propertyType: ''}), + renderCriterion: props => , + generatePattern: (state, target) => { + if (!state.propertyType) { + return { + type: 'error', + errors: [{ + target, + message: TranslatedText.text( + 'search_entities.error.required_property_type' + ), + }], + }; + } + return { + type: 'hasProperty', + propertyType: state.propertyType, + values: state.values, + }; + }, +}; + +function HasPropertyCriterion( + props: SearchCriterionProps +) { + const {pattern, updatePattern, editing, controls} = props; + const t = useTranslation(); + + const propertyType = useSchemaType('propertyType', pattern.state.propertyType); + + let header: React.ReactNode; + let where: React.ReactNode = null; + + if (!editing) { + const propertyView = propertyType ? ( + + ) : ( +
+ {t.text('search_entities.criteria_has_property.any-property')} +
+ ); + + header = ( + + {t.template('search_entities.criteria_has_property', { + propertyType: propertyView, + })} + + ); + } else { + header =
{t.text('search_entities.criteria_has_property.header')}
; + where = ( + { + updatePattern({ + ...pattern, + state: {...pattern.state, propertyType: next?.id ?? ''}, + }); + }} + /> + ); + } + + return ( + + {where} + + ); +} + +export const DefaultSearchCriterionOptions: readonly SearchCriterionOption[] = [ + SearchCriterionAllOf, + SearchCriterionAnyOf, + SearchCriterionHasType, + SearchCriterionConnectedTo, + SearchCriterionFromValues, + SearchCriterionHasProperty, +]; diff --git a/src/widgets/instancesSearch/searchPattern.ts b/src/widgets/instancesSearch/searchPattern.ts new file mode 100644 index 00000000..f1400981 --- /dev/null +++ b/src/widgets/instancesSearch/searchPattern.ts @@ -0,0 +1,134 @@ +import { ElementIri, ElementTypeIri, LinkTypeIri, PropertyTypeIri } from '../../data/model'; +import { DataProviderLookupParams } from '../../data/dataProvider'; +import type * as Rdf from '../../data/rdf/rdfModel'; + +/** + * A filter criteria for the entity lookup from a {@link DataProvider}. + * + * @see {@link DataProviderLookupParams} + */ +export interface SearchCriteria { + /** + * Filter by a text lookup. + */ + readonly text?: string; + /** + * Filter by an element type. + */ + readonly elementType?: ElementTypeIri; + /** + * Filter by having a connected element with specified IRI. + */ + readonly refElement?: ElementIri; + /** + * Filter by connection link type. + * + * Only applicable when {@link refElement} is set. + */ + readonly refElementLink?: LinkTypeIri; + /** + * Reference element link type direction ('in' | 'out'). + * + * Only when {@link refElementLink} is set. + */ + readonly linkDirection?: 'in' | 'out'; + /** + * Filter by a lookup pattern. + */ + readonly pattern?: SearchPattern; +} + +/** + * Typed search pattern type (string literal) for {@link InstancesSearch}. + * + * @see {@link SearchPattern} + */ +export type SearchPatternType = K & { __searchPattern: S }; + +export interface SearchPattern

{ + readonly type: P & string; + readonly state: SearchPatternState

; +} + +export type SearchPatternState

= P extends SearchPatternType ? S : never; + +export const SearchPattern = { + /** + * Utility function to define typed {@link SearchPatternType search pattern type}. + * + * **Example**: + * ```ts + * const FromNamedSet = SearchPattern + * .property('urn:my:fromNamedSet') + * .of<{ readonly setName: string }>(); + * ``` + */ + type: (key: K): { of(): SearchPatternType } => { + return { + of: () => key as SearchPatternType, + }; + }, + hasType:

( + pattern: SearchPattern, + type: P + ): pattern is SearchPattern

=> { + return pattern.type === type; + }, + create:

(type: P, state: SearchPatternState

): SearchPattern

=> { + return {type, state}; + }, +} as const; + +export const SearchPatternAllOf = SearchPattern.type('urn:reactodia:allOf').of<{ + readonly patterns: readonly SearchPattern[]; +}>(); +export const SearchPatternAnyOf = SearchPattern.type('urn:reactodia:anyOf').of<{ + readonly patterns: readonly SearchPattern[]; +}>(); +export const SearchPatternHasType = SearchPattern.type('urn:reactodia:hasType').of<{ + readonly elementType?: ElementTypeIri; +}>(); +export const SearchPatternConnectedTo = SearchPattern.type('urn:reactodia:connectedTo').of<{ + readonly linkType?: LinkTypeIri; + readonly linkDirection?: 'in' | 'out'; + readonly target?: SearchPattern; +}>(); +export const SearchPatternFromValues = SearchPattern.type('urn:reactodia:fromValues').of<{ + readonly values: readonly ElementIri[]; +}>(); +export const SearchPatternHasProperty = SearchPattern.type('urn:reactodia:hasProperty').of<{ + readonly propertyType?: PropertyTypeIri; + readonly values?: ReadonlyArray; +}>(); + +export function searchPatternFrom(criteria: SearchCriteria): SearchPattern | undefined { + const { + elementType, refElement, refElementLink, linkDirection, pattern, + } = criteria; + + const patterns: SearchPattern[] = []; + + if (elementType) { + patterns.push(SearchPattern.create(SearchPatternHasType, {elementType})); + } + + if (refElement || refElementLink) { + patterns.push(SearchPattern.create(SearchPatternConnectedTo, { + linkType: refElementLink, + linkDirection, + target: refElement + ? SearchPattern.create(SearchPatternFromValues, {values: [refElement]}) + : undefined, + })); + } + + if (pattern) { + patterns.push(pattern); + } + + return ( + patterns.length === 0 ? undefined : + patterns.length === 1 ? patterns[0] : + SearchPattern.create(SearchPatternAllOf, {patterns}) + ); +} diff --git a/src/widgets/unifiedSearch/unifiedSearch.tsx b/src/widgets/unifiedSearch/unifiedSearch.tsx index b2144d83..e75dd279 100644 --- a/src/widgets/unifiedSearch/unifiedSearch.tsx +++ b/src/widgets/unifiedSearch/unifiedSearch.tsx @@ -1,6 +1,7 @@ import cx from 'clsx'; import * as React from 'react'; +import { Events, EventSource, EventObserver } from '../../coreUtils/events'; import { useObservedProperty } from '../../coreUtils/hooks'; import type { HotkeyString } from '../../coreUtils/hotkey'; import { useTranslation } from '../../coreUtils/i18n'; @@ -273,12 +274,18 @@ export function UnifiedSearch(props: UnifiedSearchProps) { } }, [hasSearchQuery, setExpanded]); + const [innerSource] = React.useState(() => new EventSource()); + const onContentPositioned = React.useCallback(() => { + innerSource.trigger('positioned', {}); + }, [innerSource]); + return ( ); @@ -366,8 +374,12 @@ class ActiveSection { } } +interface InnerEvents { + positioned: Record; +} + function SearchToggle(props: { - inputRef: React.RefObject, + inputRef: React.RefObject; searchTerm: string; setSearchTerm: (term: string) => void; onSubmit: () => void; @@ -462,10 +474,11 @@ function SearchContent(props: { offsetForMaxSize: Vector; onResize: (size: Size) => void; setMaxSize: (size: Size) => void; + innerEvents: Events; }) { const { visible, sections, activeSectionKey, onActivateSection, - dropUp, size, minSize, offsetForMaxSize, onResize, setMaxSize, + dropUp, size, minSize, offsetForMaxSize, onResize, setMaxSize, innerEvents, } = props; const {canvas} = useCanvas(); @@ -476,52 +489,68 @@ function SearchContent(props: { readonly centerOffsetX: number; }>(undefined); - const updatePanelBounds = () => { - if (!panelRef.current) { - return; - } - const {x: rawX, y: rawY, width, height} = panelRef.current.getBoundingClientRect(); - const x = rawX + window.scrollX; - const y = rawY + window.scrollY; - const [alignmentX] = getParentDockAlignment(panelRef.current); - const viewport = canvas.metrics.getViewportPageRect(); - panelBounds.current = { - bounds: {x, y, width, height}, - // A workaround to have correct resizing behavior when the element - // is aligned to the center without sacrificing performance to avoid - // reading target bounds on every resize (causing a costly reflow) - centeredByX: alignmentX === 'center', - centerOffsetX: Math.max( - x * 2 + width - viewport.x * 2 - viewport.width, 0 - ), - }; - }; - - useViewportResizeHandler(() => { - if (!panelBounds.current) { - updatePanelBounds(); - } - - if (panelBounds.current) { - const {bounds, centeredByX, centerOffsetX} = panelBounds.current; + const handlers = { + updatePanelBounds: () => { + if (!(visible && panelRef.current)) { + return; + } + const {x: rawX, y: rawY, width, height} = panelRef.current.getBoundingClientRect(); + const x = rawX + window.scrollX; + const y = rawY + window.scrollY; + const [alignmentX] = getParentDockAlignment(panelRef.current); const viewport = canvas.metrics.getViewportPageRect(); - const viewportRight = viewport.x + viewport.width; - const viewportBottom = viewport.y + viewport.height; + panelBounds.current = { + bounds: {x, y, width, height}, + // A workaround to have correct resizing behavior when the element + // is aligned to the center without sacrificing performance to avoid + // reading target bounds on every resize (causing a costly reflow) + centeredByX: alignmentX === 'center', + centerOffsetX: Math.max( + x * 2 + width - viewport.x * 2 - viewport.width, 0 + ), + }; + }, + updateMaxSize: () => { + if (!panelBounds.current) { + handlers.updatePanelBounds(); + } + + if (panelBounds.current) { + const {bounds, centeredByX, centerOffsetX} = panelBounds.current; + const viewport = canvas.metrics.getViewportPageRect(); + const viewportRight = viewport.x + viewport.width; + const viewportBottom = viewport.y + viewport.height; - const proposedWidth = centeredByX - ? viewport.width - centerOffsetX - offsetForMaxSize.x - : viewportRight - bounds.x - offsetForMaxSize.x; + const proposedWidth = centeredByX + ? viewport.width - centerOffsetX - offsetForMaxSize.x + : viewportRight - bounds.x - offsetForMaxSize.x; - const proposedHeight = dropUp - ? bounds.y - viewport.y - offsetForMaxSize.y - : viewportBottom - bounds.y - offsetForMaxSize.y; + const proposedHeight = dropUp + ? bounds.y - viewport.y - offsetForMaxSize.y + : viewportBottom - bounds.y - offsetForMaxSize.y; - const maxWidth = Math.max(proposedWidth, minSize.width); - const maxHeight = Math.max(proposedHeight, minSize.height); + const maxWidth = Math.max(proposedWidth, minSize.width); + const maxHeight = Math.max(proposedHeight, minSize.height); - setMaxSize({width: maxWidth, height: maxHeight}); + setMaxSize({width: maxWidth, height: maxHeight}); + } } - }); + } as const; + + useViewportResizeHandler(handlers.updateMaxSize); + + const latestHandlers = React.useRef(handlers); + latestHandlers.current = handlers; + + React.useLayoutEffect(() => { + const listener = new EventObserver(); + listener.listen(innerEvents, 'positioned', () => { + const {updatePanelBounds, updateMaxSize} = latestHandlers.current; + updatePanelBounds(); + updateMaxSize(); + }); + return () => listener.stopListening(); + }, [latestHandlers, innerEvents]); const onDrag = (e: MouseEvent, dx: number, dy: number) => { if (panelBounds.current) { @@ -583,19 +612,19 @@ function SearchContent(props: { ))} diff --git a/src/widgets/utility/autocomplete.tsx b/src/widgets/utility/autocomplete.tsx new file mode 100644 index 00000000..ee45b9ca --- /dev/null +++ b/src/widgets/utility/autocomplete.tsx @@ -0,0 +1,319 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import { EventObserver } from '../../coreUtils/events'; +import { useAsync, UseAsyncResult, useObservedProperty } from '../../coreUtils/hooks'; +import { useTranslation } from '../../coreUtils/i18n'; +import { HtmlSpinner } from '../../diagram/spinner'; + +import { Dropdown } from './dropdown'; +import { ErrorMessage } from './errorMessage'; +import { + FocusGroup, FocusGroupController, useFocusGroup, +} from './focusGroup'; +import { NoSearchResults } from './noSearchResults'; +import { ProgressBar } from './progressBar'; +import { SearchInput, SearchInputStore, useSearchInputStore } from './searchInput'; + +const CLASS_NAME = 'reactodia-autocomplete'; + +export function Autocomplete(props: { + /** + * Additional CSS class for the component. + */ + className?: string; + inlineContent: React.ReactNode | undefined; + inlineStatus?: 'loading' | 'error'; + suggest: (text: string, options: { signal: AbortSignal }) => Promise; + suggestDefault?: (options: { signal: AbortSignal }) => Promise; + suggestDefaultComparator?: (a: T, b: T) => number; + /** + * Input dropdown expand direction. + * + * @default "down" + */ + direction?: 'down' | 'up'; + /** + * Debounce timeout in milliseconds after input to perform the text search. + * + * @default 200 + */ + searchTimeout?: number; + /** + * Minimum number of characters in the search term to initiate the search. + * + * @default 2 + */ + minSearchTermLength?: number; + /** + * Placeholder text for the input. + * + * @default "Search for..." + */ + placeholder?: string; + /** + * @default "m" + */ + sizeVariant?: 'm' | 's'; + children: (params: { + items: readonly T[]; + highlightText: string; + }) => React.ReactNode; +}) { + const { + className, + inlineContent, + inlineStatus, + suggest, + suggestDefault, + suggestDefaultComparator, + direction, + searchTimeout = 200, + minSearchTermLength = 2, + placeholder, + sizeVariant = 'm', + children, + } = props; + + const [expanded, setExpanded] = React.useState(false); + const searchStore = useSearchInputStore({ + initialValue: '', + submitTimeout: searchTimeout, + allowSubmit: text => text.length >= minSearchTermLength, + }); + const searchTerm = useObservedProperty( + searchStore.events, 'changeValue', () => searchStore.value + ); + + const cancellation = React.useRef(); + const [suggestions, setSuggestions] = React.useState>({ + data: undefined, + status: 'completed', + }); + + React.useEffect(() => { + const listener = new EventObserver(); + listener.listen(searchStore.events, 'executeSearch', ({value}) => { + cancellation.current?.abort(); + cancellation.current = new AbortController(); + const signal = cancellation.current.signal; + setSuggestions(previous => ({data: previous.data, status: 'loading'})); + suggest(value, {signal}).then( + items => { + setSuggestions({data: items, status: 'completed'}); + }, + error => { + setSuggestions(previous => ({data: previous.data, status: 'error'})); + console.error(error); + } + ); + }); + listener.listen(searchStore.events, 'clearSearch', () => { + cancellation.current?.abort(); + setSuggestions({data: undefined, status: 'completed'}); + }); + }, [searchStore, suggest, setSuggestions]); + + React.useEffect(() => { + return () => cancellation.current?.abort(); + }, []); + + const dropdownRef = React.useRef(null); + const toggleInputRef = React.useRef(null); + + const hasSearchText = searchTerm.length > 0; + + const showDefaultSuggestions = suggestDefault && expanded && !hasSearchText; + const defaultSuggestions = useAsync({ + input: [suggestDefault, showDefaultSuggestions], + load: ([loadDefault, shouldLoad], {signal}) => + shouldLoad ? loadDefault?.({signal}) : undefined, + }); + + const onClickOutside = React.useCallback(() => { + if (!hasSearchText) { + setExpanded(false); + } + }, [hasSearchText, setExpanded]); + + React.useLayoutEffect(() => { + const container = dropdownRef.current; + if (container) { + const onFocusout = (e: FocusEvent) => { + if (!( + e.relatedTarget instanceof Node && + container.contains(e.relatedTarget) + )) { + setExpanded(false); + } + }; + container.addEventListener('focusout', onFocusout); + return () => container.removeEventListener('focusout', onFocusout); + } + }, [dropdownRef]); + + const displayedResult = React.useMemo(() => { + if (showDefaultSuggestions) { + return { + ...defaultSuggestions, + data: defaultSuggestions.data + ? [...defaultSuggestions.data].sort(suggestDefaultComparator) + : undefined, + }; + } else { + return suggestions; + } + }, [showDefaultSuggestions, defaultSuggestions, suggestDefaultComparator, suggestions]); + + let content: React.ReactNode; + if (displayedResult.status === 'error') { + content = ; + } else if (displayedResult.data && displayedResult.data.length > 0) { + content = children({ items: displayedResult.data, highlightText: searchTerm }); + } else { + content = ( + + ); + } + + return ( + + {({ref}) => ( + + } + onClick={() => toggleInputRef.current?.focus()}> + + + {content} + + + )} + + ); +} + +function AutocompleteToggle(props: { + inputRef: React.RefObject, + searchStore: SearchInputStore; + hasSearchText: boolean; + inlineContent: React.ReactNode; + inlineStatus: 'loading' | 'error' | undefined; + expanded: boolean; + setExpanded: (expanded: boolean) => void; + placeholder?: string; + sizeVariant: 'm' | 's'; +}) { + const { + inputRef, searchStore, hasSearchText, inlineContent, inlineStatus, + placeholder, sizeVariant, expanded, setExpanded, + } = props; + const t = useTranslation(); + + return ( + , + className: `${CLASS_NAME}__toggle-input`, + placeholder, + onClick: () => setExpanded(true), + onFocus: () => setExpanded(true), + onKeyUp: e => { + switch (e.key) { + case 'Enter': { + if (e.currentTarget.value.length > 0) { + setExpanded(true); + } + break; + } + case 'Escape': { + setExpanded(false); + break; + } + default: { + setExpanded(true); + break; + } + } + } + }} + slotInput={ + <> + {inlineContent} + {inlineStatus ? ( + + ) : null} + + } + slotEnd={ + hasSearchText ? undefined : ( + + ) + } + /> + ); +} + +function AutocompleteItems(props: { + innerRef: (element: HTMLElement | null) => void; + visible: boolean; + dropUp: boolean; + setExpanded: (nextExpanded: boolean) => void; + children: React.ReactNode; +}) { + const {innerRef, visible, dropUp, setExpanded, children} = props; + const controller = useFocusGroup(); + + const onKeyDown = (e: React.KeyboardEvent, controller: FocusGroupController) => { + if (e.key === 'Escape') { + setExpanded(false); + } else { + controller.defaultKeyDown(e); + } + }; + + return ( +

onKeyDown(e, controller)}> + {children} +
+ ); +} diff --git a/src/widgets/utility/dropdown.tsx b/src/widgets/utility/dropdown.tsx index e7120b1c..9c05f2b0 100644 --- a/src/widgets/utility/dropdown.tsx +++ b/src/widgets/utility/dropdown.tsx @@ -1,6 +1,9 @@ +import { autoUpdate, size, useFloating } from '@floating-ui/react-dom'; import cx from 'clsx'; import * as React from 'react'; +import { useCombinedRef } from '../../coreUtils/hooks'; + import { FocusGroup, FocusGroupController, useFocusGroup, useFocusGroupItem, } from './focusGroup'; @@ -15,6 +18,10 @@ export interface DropdownProps extends React.HTMLAttributes { * Additional CSS class for the component. */ className?: string; + /** + * `ref` for the inner DOM element. + */ + innerRef?: React.Ref; /** * Dropdown expand direction. * @@ -39,6 +46,11 @@ export interface DropdownProps extends React.HTMLAttributes { * Handler for clicks outside the dropdown DOM element. */ onClickOutside?: () => void; + /** + * Handler for dropdown content panel position changes + * e.g. when the dropdown becomes expanded. + */ + onContentPositioned?: () => void; } const CLASS_NAME = 'reactodia-dropdown'; @@ -50,16 +62,41 @@ const CLASS_NAME = 'reactodia-dropdown'; */ export function Dropdown(props: DropdownProps) { const { - className, direction = 'down', expanded, toggle, onClickOutside, children, - ...otherProps + className, innerRef, direction = 'down', expanded, toggle, onClickOutside, + onContentPositioned, children, ...otherProps } = props; - const menuRef = React.useRef(null); + + const {refs, floatingStyles, isPositioned} = useFloating({ + open: expanded, + placement: direction === 'up' ? 'top-start' : 'bottom-start', + strategy: 'fixed', + whileElementsMounted: autoUpdate, + middleware: [ + size({ + apply({elements}) { + const referenceBounds = elements.reference.getBoundingClientRect(); + elements.floating.style.setProperty( + '--reactodia-dropdown-toggle-width', + `${referenceBounds.width}px`, + ); + }, + }), + ], + }); + + const passedRef = useCombinedRef(refs.setReference, innerRef); + + React.useEffect(() => { + if (isPositioned) { + onContentPositioned?.(); + } + }, [onContentPositioned, isPositioned]); React.useLayoutEffect(() => { if (onClickOutside && expanded) { const closeMenu = (e: PointerEvent) => { // Auto-close menu on clicks from outside - if (e.target instanceof Node && !menuRef.current?.contains(e.target)) { + if (e.target instanceof Node && !refs.reference.current?.contains(e.target)) { onClickOutside(); } }; @@ -70,7 +107,7 @@ export function Dropdown(props: DropdownProps) { return (
{direction === 'down' ? toggle : null} -
+
{children}
{direction === 'up' ? toggle : null} @@ -102,6 +142,10 @@ export interface DropdownMenuProps extends React.HTMLAttributes { * @default "down" */ direction?: 'down' | 'up'; + /** + * Custom toggle menu button component. + */ + toggle?: React.ReactElement; /** * Title for the toggle menu button. */ @@ -120,7 +164,7 @@ const MENU_CLASS_NAME = 'reactodia-dropdown-menu'; * @category Components */ export function DropdownMenu(props: DropdownMenuProps) { - const {className, direction, title, children, ...otherProps} = props; + const {className, direction, toggle, title, children, ...otherProps} = props; const [expanded, setExpanded] = React.useState(false); const providedContext = React.useMemo( (): DropdownMenuContext => ({expanded, setExpanded}), @@ -144,7 +188,7 @@ export function DropdownMenu(props: DropdownMenuProps) { direction={direction} expanded={expanded} toggle={ - + } onClickOutside={onClickOutside}>
{ @@ -185,7 +232,7 @@ function DropdownMenuToggleButton(props: { title?: string }) { controller.ensureFocusable({reset: true}); } }, [expanded]); - return ( + return component ?? (
+ ); +} + +function tryFormatError(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message || error.name; + } + return undefined; +} diff --git a/src/widgets/utility/inlineEntity.tsx b/src/widgets/utility/inlineEntity.tsx index dd1b4666..25d1b2c1 100644 --- a/src/widgets/utility/inlineEntity.tsx +++ b/src/widgets/utility/inlineEntity.tsx @@ -1,3 +1,4 @@ +import cx from 'clsx'; import * as React from 'react'; import { useTranslation } from '../../coreUtils/i18n'; @@ -11,9 +12,15 @@ import { formatEntityTitle } from '../utility/listElementView'; const CLASS_NAME = 'reactodia-inline-entity'; export function InlineEntity(props: { + className?: string; target: ElementModel; + /** + * @default false + */ + block?: boolean; + children?: React.ReactNode; }) { - const {target} = props; + const {className, target, block, children} = props; const {model, getElementTypeStyle} = useWorkspace(); const t = useTranslation(); const label = model.locale.formatEntityLabel(target, model.language); @@ -21,13 +28,15 @@ export function InlineEntity(props: { const style = { '--reactodia-inline-entity-color': color, } as React.CSSProperties; + const Tag = block ? 'div' : 'span'; return ( - - + {label} - - + + {children} + ); } diff --git a/src/widgets/utility/listElementView.tsx b/src/widgets/utility/listElementView.tsx index 9ec0012e..c3143705 100644 --- a/src/widgets/utility/listElementView.tsx +++ b/src/widgets/utility/listElementView.tsx @@ -93,7 +93,7 @@ export function ListElementView(props: ListElementViewProps) { ref={ref as React.Ref} className={combinedClass} role={otherProps.role ?? 'option'} - draggable={!disabled && Boolean(onDragStart)} + draggable={!disabled && Boolean(onDragStart) ? true : undefined} title={otherProps.title ?? formatEntityTitle(element, model, t)} style={{...otherProps.style, ...providedStyle}} onClick={onItemClick} diff --git a/src/widgets/utility/schemaTypeView.tsx b/src/widgets/utility/schemaTypeView.tsx new file mode 100644 index 00000000..0721bcc2 --- /dev/null +++ b/src/widgets/utility/schemaTypeView.tsx @@ -0,0 +1,99 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import { useEventStore, useSyncStore } from '../../coreUtils/hooks'; +import { useTranslation } from '../../coreUtils/i18n'; +import { + ElementTypeIri, ElementTypeModel, PropertyTypeModel, LinkTypeModel, +} from '../../data/model'; +import { useWorkspace } from '../../workspace/workspaceContext'; + +import { highlightSubstring } from './listElementView'; + +export type SchemaType = { + elementType: ElementTypeModel; + propertyType: PropertyTypeModel; + linkType: LinkTypeModel; +}; + +export interface SchemaTypeViewProps + extends React.HTMLProps +{ + typeKind: Kind; + typeData: SchemaType[Kind]; + label?: string; + highlightText?: string; + selected?: boolean; + sizeVariant?: 'm' | 's'; + inline?: boolean; +} + +const CLASS_NAME = 'reactodia-schema-type-view'; + +export function SchemaTypeView( + props: SchemaTypeViewProps +) { + const { + typeKind, typeData, label, highlightText, selected, + sizeVariant = 'm', + inline = false, + ...restProps + } = props; + const t = useTranslation(); + const {model, getElementTypeStyle} = useWorkspace(); + + const language = useSyncStore( + useEventStore(label === undefined ? undefined : model.events, 'changeLanguage'), + () => model.language + ); + const typeLabel = label ?? t.formatLabel(typeData.label, typeData.id, language); + const highlightedLabel = highlightSubstring( + typeLabel, highlightText, {className: `${CLASS_NAME}__highlighted-term`} + ); + + const typeStyle = getElementTypeStyle([typeData.id as ElementTypeIri]); + const providedStyle = { + '--reactodia-element-style-color': typeStyle.color, + } as React.CSSProperties; + + const typeKindClass = ( + typeKind === 'elementType' ? `${CLASS_NAME}--element-type` : + typeKind === 'propertyType' ? `${CLASS_NAME}--property-type` : + typeKind === 'linkType' ? `${CLASS_NAME}--link-type` : + undefined + ); + + return ( + e.preventDefault())} + style={{...providedStyle, ...restProps.style}} + href={typeData.id}> +
+ {typeStyle.icon ? ( + + ) : ( +
+ )} +
+
+ {highlightedLabel} +
+ + {restProps.children} +
+ ); +} diff --git a/src/widgets/utility/searchInput.tsx b/src/widgets/utility/searchInput.tsx index 0d75dcf7..dcafd953 100644 --- a/src/widgets/utility/searchInput.tsx +++ b/src/widgets/utility/searchInput.tsx @@ -10,6 +10,10 @@ export interface SearchInputProps { className?: string; inputProps?: React.HTMLProps & DataAttributes; store: SearchInputStore; + /** @default "m" */ + sizeVariant?: 'm' | 's'; + slotInput?: React.ReactNode; + slotEnd?: React.ReactNode; children?: React.ReactNode; } @@ -19,7 +23,7 @@ const CLASS_NAME = 'reactodia-search-input'; export function SearchInput(props: SearchInputProps) { const { - className, inputProps = {}, store, children, + className, inputProps = {}, store, sizeVariant = 'm', slotInput, slotEnd, children, } = props; const t = useTranslation(); @@ -30,36 +34,37 @@ export function SearchInput(props: SearchInputProps) {
- store.change({value: e.currentTarget.value, action: 'input'})} - onKeyUp={e => { - if (e.key === 'Enter') { - store.change({value: store.value, action: 'submit'}); - } - }} - /> - {store.value ? ( -
+
+ {slotInput} + store.change({value: e.currentTarget.value, action: 'input'})} + onKeyUp={e => { + if (e.key === 'Enter') { + store.change({value: store.value, action: 'submit'}); + } + inputProps.onKeyUp?.(e); + }} + /> +
+
+ {store.value ? ( -
- ) : null} + ) : slotEnd} +
{mode === 'explicit' ? (
); } + +function searchPatternFrom(criteria: SearchCriteria): SearchPattern | undefined { + const { + elementType, refElement, refElementLink, linkDirection, pattern, + } = criteria; + + const patterns: SearchPattern[] = []; + + if (elementType) { + patterns.push(new SearchPatternHasType({elementType})); + } + + if (refElement || refElementLink || linkDirection) { + patterns.push(new SearchPatternConnectedTo({ + linkType: refElementLink, + linkDirection, + target: refElement + ? new SearchPatternFromValues({values: [refElement]}) + : undefined, + })); + } + + if (pattern) { + patterns.push(pattern); + } + + return ( + patterns.length === 0 ? undefined : + patterns.length === 1 ? patterns[0] : + new SearchPatternAllOf({patterns}) + ); +} diff --git a/src/widgets/instancesSearch/instancesSearch.tsx b/src/widgets/instancesSearch/instancesSearch.tsx index 1372a130..2efb2834 100644 --- a/src/widgets/instancesSearch/instancesSearch.tsx +++ b/src/widgets/instancesSearch/instancesSearch.tsx @@ -25,11 +25,11 @@ import { ProgressBar, ProgressState } from '../utility/progressBar'; import { SearchInputStore, useSearchInputStore } from '../utility/searchInput'; import { SearchResults } from '../utility/searchResults'; -import { InstanceCriteria } from './instanceCriteria'; +import { SearchCriteria, InstanceCriteria } from './instanceCriteria'; import { - type SearchCriterionOption, DefaultSearchCriterionOptions, makePatternGenerator, + type SearchCriterionOption, DefaultSearchCriterionOptions, } from './searchCriterion'; -import { SearchCriteria } from './searchPattern'; +import { SearchPatternGenerateContext } from './searchPattern'; /** * Props for {@link InstancesSearch} component. @@ -389,10 +389,7 @@ class InstancesSearchInner extends React.Component( const suggest = React.useCallback(async (text: string, options: { signal: AbortSignal }) => { const {signal} = options; - // await delay(2000); - // if (Math.random() > 0.5) { - // throw new Error('Random error'); - // } switch (typeKind) { case 'elementType': { return dataProvider.lookupElementTypes({text, signal}); @@ -66,10 +61,6 @@ export function SchemaTypeSelect( options: { signal: AbortSignal } ): Promise => { const {signal} = options; - // await delay(2000); - // if (Math.random() > 0.5) { - // throw new Error('Random error'); - // } switch (typeKind) { case 'elementType': { const {elementTypes} = await dataProvider.knownElementTypes({signal}); diff --git a/src/widgets/instancesSearch/searchCriterion.tsx b/src/widgets/instancesSearch/searchCriterion.tsx index 8bf35e99..dea119a1 100644 --- a/src/widgets/instancesSearch/searchCriterion.tsx +++ b/src/widgets/instancesSearch/searchCriterion.tsx @@ -2,7 +2,6 @@ import cx from 'clsx'; import * as React from 'react'; import { TranslatedText, useTranslation } from '../../coreUtils/i18n'; -import { LookupPattern } from '../../data/dataProvider'; import { ElementIri } from '../../data/model'; import { EntityElement } from '../../editor/dataElements'; import { useProvidedEntities } from '../../editor/useProvidedEntities'; @@ -15,56 +14,32 @@ import { SchemaTypeView } from '../utility/schemaTypeView'; import { EntitySelect } from './entitySelect'; import { SchemaTypeSelect, LinkDirectionSelect, useSchemaType } from './schemaTypeSelect'; import { - SearchPattern, SearchPatternType, SearchPatternState, - SearchPatternAllOf, SearchPatternAnyOf, SearchPatternConnectedTo, SearchPatternFromValues, - SearchPatternHasProperty, SearchPatternHasType, + SearchPattern, SearchPatternType, SearchPatternErrorItem, SerializedSearchPattern, + SearchPatternError, SearchPatternAllOf, SearchPatternAnyOf, SearchPatternConnectedTo, + SearchPatternFromValues, SearchPatternHasProperty, SearchPatternHasType, } from './searchPattern'; +import { LookupPattern } from '../../workspace'; -export interface SearchCriterionOption { - readonly type: P; +export interface SearchCriterionOption { + readonly type: SearchPatternType

; readonly label?: string | TranslatedText; - readonly createState: () => SearchPatternState

; + readonly createPattern: () => P; readonly renderCriterion: ( props: SearchCriterionProps

) => React.ReactNode; - readonly generatePattern: ( - state: SearchPatternState

, - target: SearchPattern, - context: SearchCriterionGenerateContext - ) => LookupPattern | SearchCriterionErrorPattern; } -export interface SearchCriterionProps { - pattern: SearchPattern

; - updatePattern: (state: SearchPattern

| undefined) => void; - replacePattern: (state: SearchPattern | undefined) => void; +export interface SearchCriterionProps { + pattern: P; + setPattern: (state: SearchPattern | undefined) => void; editing: boolean; parent?: SearchPattern; controls?: React.ReactNode; } -export interface SearchCriterionGenerateContext { - readonly generate: ( - pattern: SearchPattern - ) => LookupPattern | SearchCriterionErrorPattern; - readonly generateMany: ( - patterns: readonly SearchPattern[] - ) => LookupPattern[] | SearchCriterionErrorPattern; -} - -export interface SearchCriterionErrorPattern { - readonly type: 'error'; - readonly errors: readonly SearchCriterionError[]; -} - -export interface SearchCriterionError { - readonly target: SearchPattern; - readonly message: string | TranslatedText; -} - export interface SearchCriterionContext { readonly editing: boolean; - readonly errors: ReadonlyMap; + readonly errors: ReadonlyMap; readonly patternOptions: readonly SearchCriterionOption[]; } export const SearchCriterionContext = React.createContext({ @@ -73,46 +48,6 @@ export const SearchCriterionContext = React.createContext { - const option = patternOptions.find(option => option.type === pattern.type); - if (!option) { - throw new Error( - `Found no option to generate lookup pattern for type: '${pattern.type}'` - ); - } - return option.generatePattern(pattern.state, pattern, context); - }; - const generateMany = ( - patterns: readonly SearchPattern[] - ): LookupPattern[] | SearchCriterionErrorPattern => { - const results: LookupPattern[] = []; - const errors: SearchCriterionError[] = []; - let hasError = false; - for (const pattern of patterns) { - const result = generateSingle(pattern); - if (result.type === 'error') { - hasError = true; - for (const error of result.errors) { - errors.push(error); - } - } else { - results.push(result); - } - } - return hasError ? {type: 'error', errors} : results; - }; - const context: SearchCriterionGenerateContext = { - generate: generateSingle, - generateMany, - }; - return context; -} - const CLASS_NAME = 'reactodia-search-criterion'; export function SearchCriterion(props: { @@ -134,27 +69,25 @@ export function SearchCriterion(props: { } const isLogicalPattern = ( - SearchPattern.hasType(pattern, SearchPatternAllOf) || - SearchPattern.hasType(pattern, SearchPatternAnyOf) + pattern instanceof SearchPatternAllOf || + pattern instanceof SearchPatternAnyOf ); const controls = editing ? (

{isLogicalPattern ? null : ( setPattern(SearchPattern.create( - SearchPatternAllOf, - {patterns: [pattern, added]} - ))} + onAdd={added => setPattern(new SearchPatternAllOf({ + patterns: [pattern, added], + }))} title={t.textOptional('search_entities.criteria_all_of_add.title')}> {t.text('search_entities.criteria_all_of_add.label')} )} {isLogicalPattern ? null : ( setPattern(SearchPattern.create( - SearchPatternAnyOf, - {patterns: [pattern, added]} - ))} + onAdd={added => setPattern(new SearchPatternAnyOf({ + patterns: [pattern, added] + }))} title={t.textOptional('search_entities.criteria_any_of_add.title')}> {t.text('search_entities.criteria_any_of_add.label')} @@ -169,12 +102,11 @@ export function SearchCriterion(props: { />
) : null; - const option = patternOptions.find(option => option.type === pattern.type) + const option = patternOptions.find(option => pattern instanceof option.type) ?? PlaceholderUnknownOption; - const optionProps: SearchCriterionProps> = { - pattern: pattern as SearchPattern, - updatePattern: setPattern, - replacePattern: setPattern, + const optionProps: SearchCriterionProps = { + pattern, + setPattern, editing, parent, controls, @@ -200,13 +132,9 @@ export function AddCriterion(props: { }> {patternOptions.map(option => option.label ? ( - { - const pattern = SearchPattern.create( - option.type, - option.createState() - ); - onAdd(pattern); + onAdd(option.createPattern()); }}> {typeof option.label === 'string' ? option.label @@ -285,11 +213,9 @@ export function SearchCriterionWhereGroup(props: { children: React.ReactElement export function SearchCriterionWhere(props: { part: string; - pattern: SearchPattern | undefined; - setPattern: (pattern: SearchPattern | undefined) => void; - parent: SearchPattern | undefined; + children: React.ReactNode; }) { - const {part, pattern, setPattern, parent} = props; + const {part, children} = props; const t = useTranslation(); return ( <> @@ -299,10 +225,7 @@ export function SearchCriterionWhere(props: { })}
- + {children}
); @@ -331,17 +254,32 @@ function PlaceholderAnyCriterion(props: { ); } -const PlaceholderUnknownOption: SearchCriterionOption = { - type: SearchPattern.type('urn:reactodia:unknownPattern').of(), - createState: () => ({}), - renderCriterion: props => , - generatePattern: () => { +class SearchPatternUnknown extends SearchPattern> { + generate(): LookupPattern | SearchPatternError { throw new Error('Unknown pattern type'); - }, + } + + static readonly fromJSONType = 'unknown'; + + static fromJSON(): SearchPatternUnknown { + return new SearchPatternUnknown({}); + } + + toJSON(): SerializedSearchPattern { + return {'@type': 'unknown'}; + } +} + +SearchPattern.define(SearchPatternUnknown); + +const PlaceholderUnknownOption: SearchCriterionOption = { + type: SearchPatternUnknown, + createPattern: () => new SearchPatternUnknown({}), + renderCriterion: props => , }; function PlaceholderUnknownCriterion( - props: SearchCriterionProps + props: SearchCriterionProps ) { const {pattern, controls} = props; const t = useTranslation(); @@ -350,61 +288,29 @@ function PlaceholderUnknownCriterion( controls={controls} header={
- {t.text('search_entities.criteria_placeholder_unknown', { - patternType: pattern.type, - })} + {t.text('search_entities.criteria_placeholder_unknown')}
} /> ); } -export const SearchCriterionAllOf: SearchCriterionOption = { +export const SearchCriterionAllOf: SearchCriterionOption = { type: SearchPatternAllOf, - createState: () => ({patterns: []}), + createPattern: () => new SearchPatternAllOf({patterns: []}), renderCriterion: props => , - generatePattern: (state, target, context) => { - if (state.patterns.length === 0) { - return { - type: 'error', - errors: [{ - target, - message: TranslatedText.text( - 'search_entities.error.required_at_least_one_pattern' - ), - }], - }; - } - const inner = context.generateMany(state.patterns); - return Array.isArray(inner) ? {type: 'allOf', patterns: inner} : inner; - }, }; -export const SearchCriterionAnyOf: SearchCriterionOption = { +export const SearchCriterionAnyOf: SearchCriterionOption = { type: SearchPatternAnyOf, - createState: () => ({patterns: []}), + createPattern: () => new SearchPatternAnyOf({patterns: []}), renderCriterion: props => , - generatePattern: (state, target, context) => { - if (state.patterns.length === 0) { - return { - type: 'error', - errors: [{ - target, - message: TranslatedText.text( - 'search_entities.error.required_at_least_one_pattern' - ), - }], - }; - } - const inner = context.generateMany(state.patterns); - return Array.isArray(inner) ? {type: 'anyOf', patterns: inner} : inner; - }, }; -function AnyAllOfCriterion

( +function AnyAllOfCriterion

( props: SearchCriterionProps

) { - const {pattern, updatePattern, replacePattern, editing, parent} = props; + const {pattern, setPattern, editing, parent} = props; const t = useTranslation(); const className = parent ? `${CLASS_NAME}__logical-group` @@ -416,20 +322,26 @@ function AnyAllOfCriterion

0 ? (

{( - pattern.type === SearchPatternAllOf ? t.text( + pattern instanceof SearchPatternAllOf ? t.text( 'search_entities.criteria_all_of_joiner' - ) : pattern.type === SearchPatternAnyOf ? t.text( + ) : pattern instanceof SearchPatternAnyOf ? t.text( 'search_entities.criteria_any_of_joiner' - ) : pattern.type + ) : '-' )}
) : null} - { const nextPatterns = [...pattern.state.patterns]; if (next) { - if (SearchPattern.hasType(next, pattern.type)) { + if (( + next instanceof SearchPatternAllOf && + pattern instanceof SearchPatternAllOf + ) || ( + next instanceof SearchPatternAnyOf && + pattern instanceof SearchPatternAnyOf + )) { nextPatterns.splice(index, 1, ...next.state.patterns); } else { nextPatterns.splice(index, 1, next); @@ -438,17 +350,16 @@ function AnyAllOfCriterion

, - } + pattern instanceof SearchPatternAllOf ? new SearchPatternAllOf({ + patterns: nextPatterns, + }) : + pattern instanceof SearchPatternAnyOf ? new SearchPatternAnyOf({ + patterns: nextPatterns, + }) : + undefined ); }} parent={pattern} @@ -457,25 +368,28 @@ function AnyAllOfCriterion

updatePattern({ - ...pattern, - state: { - patterns: [added] - } satisfies SearchPatternState< - typeof SearchPatternAllOf | typeof SearchCriterionAnyOf - > as SearchPatternState

, - })} + onAdd={added => { + setPattern( + pattern instanceof SearchPatternAllOf ? new SearchPatternAllOf({ + patterns: [added], + }) : + pattern instanceof SearchPatternAnyOf ? new SearchPatternAnyOf({ + patterns: [added], + }) : + undefined + ); + }} title={ - pattern.type === SearchPatternAllOf ? t.textOptional( + pattern instanceof SearchPatternAllOf ? t.textOptional( 'search_entities.criteria_all_of_add.title' - ) : pattern.type === SearchPatternAnyOf ? t.textOptional( + ) : pattern instanceof SearchPatternAnyOf ? t.textOptional( 'search_entities.criteria_any_of_add.title' ) : undefined }> {( - pattern.type === SearchPatternAllOf ? t.text( + pattern instanceof SearchPatternAllOf ? t.text( 'search_entities.criteria_all_of_add.label' - ) : pattern.type === SearchPatternAnyOf ? t.text( + ) : pattern instanceof SearchPatternAnyOf ? t.text( 'search_entities.criteria_any_of_add.label' ) : t.text('search_entities.add_criterion.label') )} @@ -485,34 +399,17 @@ function AnyAllOfCriterion

= { +export const SearchCriterionHasType: SearchCriterionOption = { type: SearchPatternHasType, label: TranslatedText.text('search_entities.criteria_has_type.label'), - createState: () => ({elementType: ''}), + createPattern: () => new SearchPatternHasType({elementType: ''}), renderCriterion: props => , - generatePattern: (state, target) => { - if (!state.elementType) { - return { - type: 'error', - errors: [{ - target, - message: TranslatedText.text( - 'search_entities.error.required_element_type' - ), - }], - }; - } - return { - type: 'hasType', - elementType: state.elementType, - }; - }, }; function HasTypeCriterion( - props: SearchCriterionProps + props: SearchCriterionProps ) { - const {pattern, updatePattern, editing, controls} = props; + const {pattern, setPattern, editing, controls} = props; const t = useTranslation(); const elementType = useSchemaType('elementType', pattern.state.elementType); @@ -544,10 +441,10 @@ function HasTypeCriterion( where = ( updatePattern({ - ...pattern, - state: {...pattern.state, elementType: type ? type.id : ''}, - })} + setSelected={type => setPattern(new SearchPatternHasType({ + ...pattern.state, + elementType: type ? type.id : '', + }))} /> ); } @@ -561,29 +458,17 @@ function HasTypeCriterion( ); } -export const SearchCriterionConnectedTo: SearchCriterionOption = { +export const SearchCriterionConnectedTo: SearchCriterionOption = { type: SearchPatternConnectedTo, label: TranslatedText.text('search_entities.criteria_connected.label'), - createState: () => ({}), + createPattern: () => new SearchPatternConnectedTo({}), renderCriterion: props => , - generatePattern: (state, _target, context) => { - const target = state.target ? context.generate(state.target) : undefined; - if (target?.type === 'error') { - return target; - } - return { - type: 'connectedTo', - linkType: state.linkType, - linkDirection: state.linkDirection, - target, - }; - }, }; function ConnectedToCriterion( - props: SearchCriterionProps + props: SearchCriterionProps ) { - const {pattern, updatePattern, editing, controls} = props; + const {pattern, setPattern, editing, controls} = props; const t = useTranslation(); const {target, linkDirection} = pattern.state; @@ -596,7 +481,7 @@ function ConnectedToCriterion( if (!editing) { let entity: React.ReactElement; - if (!target || SearchPattern.hasType(target, SearchPatternFromValues)) { + if (!target || target instanceof SearchPatternFromValues) { inlineEntity = true; entity = ; } else { @@ -659,22 +544,16 @@ function ConnectedToCriterion(

updatePattern({ - ...pattern, - state: { - ...pattern.state, - linkType: next?.id, - }, - })} + setSelected={next => setPattern(new SearchPatternConnectedTo({ + ...pattern.state, + linkType: next?.id, + }))} /> updatePattern({ - ...pattern, - state: { - ...pattern.state, - linkDirection: next, - }, - })} + setSelected={next => setPattern(new SearchPatternConnectedTo({ + ...pattern.state, + linkDirection: next, + }))} />
); @@ -688,35 +567,32 @@ function ConnectedToCriterion( {inlineEntity ? null : ( updatePattern({ - ...pattern, - state: {...pattern.state, target}, - })} - parent={pattern} - /> + part={t.text('search_entities.criteria_connected.target-part')}> + setPattern(new SearchPatternConnectedTo({ + ...pattern.state, + target, + }))} + parent={pattern} + /> + )} ); } -export const SearchCriterionFromValues: SearchCriterionOption = { +export const SearchCriterionFromValues: SearchCriterionOption = { type: SearchPatternFromValues, label: TranslatedText.text('search_entities.criteria_from_values.label'), - createState: () => ({values: []}), + createPattern: () => new SearchPatternFromValues({values: []}), renderCriterion: props => , - generatePattern: state => ({ - type: 'fromValues', - values: state.values, - }), }; function FromValuesCriterion( - props: SearchCriterionProps + props: SearchCriterionProps ) { - const {pattern, updatePattern, editing, controls} = props; + const {pattern, setPattern, editing, controls} = props; const t = useTranslation(); let header: React.ReactNode; @@ -731,7 +607,7 @@ function FromValuesCriterion( ); } else { header =
{t.text('search_entities.criteria_from_values.header')}
; - where = ; + where = ; } return ( @@ -783,10 +659,10 @@ function FromValuesList(props: { } function FromValuesSelector(props: Pick< - SearchCriterionProps, - 'pattern' | 'updatePattern' + SearchCriterionProps, + 'pattern' | 'setPattern' >) { - const {pattern, updatePattern} = props; + const {pattern, setPattern} = props; const {model} = useWorkspace(); const providedEntities = useProvidedEntities( model.dataProvider, @@ -803,46 +679,26 @@ function FromValuesSelector(props: Pick< selected={selected} setSelected={update => { const nextValues = update(selected); - updatePattern({ - ...pattern, - state: { - values: nextValues.map(v => v.id), - }, - }); + setPattern(new SearchPatternFromValues({ + ...pattern.state, + values: nextValues.map(v => v.id), + })); }} /> ); } -export const SearchCriterionHasProperty: SearchCriterionOption = { +export const SearchCriterionHasProperty: SearchCriterionOption = { type: SearchPatternHasProperty, label: TranslatedText.text('search_entities.criteria_has_property.label'), - createState: () => ({propertyType: ''}), + createPattern: () => new SearchPatternHasProperty({propertyType: ''}), renderCriterion: props => , - generatePattern: (state, target) => { - if (!state.propertyType) { - return { - type: 'error', - errors: [{ - target, - message: TranslatedText.text( - 'search_entities.error.required_property_type' - ), - }], - }; - } - return { - type: 'hasProperty', - propertyType: state.propertyType, - values: state.values, - }; - }, }; function HasPropertyCriterion( - props: SearchCriterionProps + props: SearchCriterionProps ) { - const {pattern, updatePattern, editing, controls} = props; + const {pattern, setPattern, editing, controls} = props; const t = useTranslation(); const propertyType = useSchemaType('propertyType', pattern.state.propertyType); @@ -876,10 +732,10 @@ function HasPropertyCriterion( { - updatePattern({ - ...pattern, - state: {...pattern.state, propertyType: next?.id ?? ''}, - }); + setPattern(new SearchPatternHasProperty({ + ...pattern.state, + propertyType: next?.id ?? '', + })); }} /> ); diff --git a/src/widgets/instancesSearch/searchPattern.ts b/src/widgets/instancesSearch/searchPattern.ts index f1400981..4bae4013 100644 --- a/src/widgets/instancesSearch/searchPattern.ts +++ b/src/widgets/instancesSearch/searchPattern.ts @@ -1,134 +1,497 @@ +import { TranslatedText } from '../../coreUtils/i18n'; import { ElementIri, ElementTypeIri, LinkTypeIri, PropertyTypeIri } from '../../data/model'; -import { DataProviderLookupParams } from '../../data/dataProvider'; +import { LookupPattern } from '../../data/dataProvider'; import type * as Rdf from '../../data/rdf/rdfModel'; /** - * A filter criteria for the entity lookup from a {@link DataProvider}. + * Abstract search pattern type for search criteria in + * {@link SearchSectionEntities} and {@link InstancesSearch}. * - * @see {@link DataProviderLookupParams} + * **Example**: + * ```ts + * class FromNamedSet extends SearchPattern<{ + * readonly setIri: Rdf.NamedNode; + * }> { + * generate( + * context: SearchPatternGenerateContext + * ): LookupPattern | SearchPatternError { + * // ... + * } + * + * static readonly fromJSONType = 'fromNamedSet'; + * static fromJSON( + * raw: SerializedFromNamedSet, + * context: + * ): FromNamedSet { + * const {setIri} = raw; + * return new FromNamedSet({ + * setIri: context.factory.namedNode(setIri), + * }); + * } + * + * toJSON( + * context: SearchPatternToJsonContext + * ): SerializedFromNamedSet { + * const {setIri} = this.state; + * return { + * '@type': 'fromNamedSet', + * setIri: setIri.value, + * }; + * } + * } + * + * SearchPattern.define(FromNamedSet); + * + * interface SerializedFromNamedSet { + * setIri: string; + * } + * ``` + * + * @see {@link SearchPattern.define} */ -export interface SearchCriteria { +export abstract class SearchPattern { + constructor( + /** + * Search pattern state. + */ + readonly state: S + ) {} + /** - * Filter by a text lookup. + * Generates a valid {@link LookupPattern} from the pattern or + * {@link SearchPatternError} if it's incomplete or contradictory. */ - readonly text?: string; + abstract generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError; + /** - * Filter by an element type. + * Serializes the pattern to a plain JSON-stringifiable value. */ - readonly elementType?: ElementTypeIri; + abstract toJSON( + context: SearchPatternToJsonContext + ): SerializedSearchPattern; + /** - * Filter by having a connected element with specified IRI. + * Utility function to define {@link SearchPattern search pattern} type + * and assert its contract on serialization. */ - readonly refElement?: ElementIri; + static define(type: SearchPatternType): void { + if (typeof type.fromJSONType !== 'string') { + throw new Error('Missing or invalid "fromJSONType" on a search pattern type'); + } else if (typeof type.fromJSON !== 'function') { + throw new Error('Missing or invalid "fromJSON" on a search pattern type'); + } + } + /** - * Filter by connection link type. - * - * Only applicable when {@link refElement} is set. + * Utility function to generate lookup patterns from an array of search patterns, + * returing all collected errors instead if any error is produced. */ - readonly refElementLink?: LinkTypeIri; + static generateMany( + patterns: readonly SearchPattern[], + context: SearchPatternGenerateContext + ): LookupPattern[] | SearchPatternError { + const results: LookupPattern[] = []; + const errors: SearchPatternErrorItem[] = []; + let hasError = false; + for (const pattern of patterns) { + const result = pattern.generate(context); + if (result.type === 'error') { + hasError = true; + for (const error of result.errors) { + errors.push(error); + } + } else { + results.push(result); + } + } + return hasError ? {type: 'error', errors} : results; + } +} + +/** + * Describes a serializable {@link SearchPattern search pattern type}. + */ +export interface SearchPatternType

{ + new (...args: any[]): P; /** - * Reference element link type direction ('in' | 'out'). - * - * Only when {@link refElementLink} is set. + * Unique identifier string to act as determine a search pattern type + * for a serialized pattern state. */ - readonly linkDirection?: 'in' | 'out'; + readonly fromJSONType: ReturnType['@type']; /** - * Filter by a lookup pattern. + * Deserializes the pattern from a plain JSON-stringifiable value. */ - readonly pattern?: SearchPattern; + fromJSON( + state: ReturnType, + context: SearchPatternFromJsonContext + ): P | undefined; } /** - * Typed search pattern type (string literal) for {@link InstancesSearch}. - * - * @see {@link SearchPattern} + * A combined error from search pattern {@link SearchPattern.generate generation}. */ -export type SearchPatternType = K & { __searchPattern: S }; +export interface SearchPatternError { + /** + * Error result type. + */ + readonly type: 'error'; + /** + * All collected generation errors. + */ + readonly errors: readonly SearchPatternErrorItem[]; +} -export interface SearchPattern

{ - readonly type: P & string; - readonly state: SearchPatternState

; +/** + * A sigle error related to search pattern {@link SearchPattern.generate generation} + * from a `target` pattern. + */ +export interface SearchPatternErrorItem { + /** + * Target search pattern from which the error originates. + */ + readonly target: SearchPattern; + /** + * Error message. + */ + readonly message: string | TranslatedText; } -export type SearchPatternState

= P extends SearchPatternType ? S : never; +/** + * Context for {@link SearchPattern.generate}. + */ +export type SearchPatternGenerateContext = Record; -export const SearchPattern = { +/** + * Context for {@link SearchPattern.toJSON}. + */ +export interface SearchPatternToJsonContext { /** - * Utility function to define typed {@link SearchPatternType search pattern type}. - * - * **Example**: - * ```ts - * const FromNamedSet = SearchPattern - * .property('urn:my:fromNamedSet') - * .of<{ readonly setName: string }>(); - * ``` + * Serializes a sub-pattern. */ - type: (key: K): { of(): SearchPatternType } => { + toJson: (pattern: SearchPattern) => SerializedSearchPattern; +} + +/** + * Context for {@link SearchPatternType.fromJSON}. + */ +export interface SearchPatternFromJsonContext { + /** + * An [RDF term factory](https://rdf.js.org/data-model-spec/#datafactory-interface) + * to create RDF terms for identifiers and property values. + */ + factory: Rdf.DataFactory; + /** + * Deserializes a sub-pattern. + */ + fromJson: (pattern: SerializedSearchPattern) => SearchPattern; +} + +/** + * Base type for serialized {@link SearchPattern search pattern}. + */ +export interface SerializedSearchPattern { + '@type': string; +} + +export class SearchPatternAllOf extends SearchPattern<{ + readonly patterns: readonly SearchPattern[]; +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {patterns} = this.state; + if (patterns.length === 0) { + return { + type: 'error', + errors: [{ + target: this, + message: TranslatedText.text( + 'search_entities.error.required_at_least_one_pattern' + ), + }], + }; + } + const inner = SearchPattern.generateMany(patterns, context); + return Array.isArray(inner) ? {type: 'allOf', patterns: inner} : inner; + } + + static readonly fromJSONType = 'allOf'; + + static fromJSON( + raw: SerializedAllOf, + context: SearchPatternFromJsonContext + ): SearchPatternAllOf { + const {patterns} = raw; + return new SearchPatternAllOf({ + patterns: patterns.map(p => context.fromJson(p)), + }); + } + + toJSON(context: SearchPatternToJsonContext): SerializedAllOf { + const {patterns} = this.state; return { - of: () => key as SearchPatternType, + '@type': 'allOf', + patterns: patterns.map(p => context.toJson(p)), }; - }, - hasType:

( - pattern: SearchPattern, - type: P - ): pattern is SearchPattern

=> { - return pattern.type === type; - }, - create:

(type: P, state: SearchPatternState

): SearchPattern

=> { - return {type, state}; - }, -} as const; - -export const SearchPatternAllOf = SearchPattern.type('urn:reactodia:allOf').of<{ - readonly patterns: readonly SearchPattern[]; -}>(); -export const SearchPatternAnyOf = SearchPattern.type('urn:reactodia:anyOf').of<{ + } +} + +SearchPattern.define(SearchPatternAllOf); + +interface SerializedAllOf { + '@type': 'allOf'; + patterns: SerializedSearchPattern[]; +} + +export class SearchPatternAnyOf extends SearchPattern<{ readonly patterns: readonly SearchPattern[]; -}>(); -export const SearchPatternHasType = SearchPattern.type('urn:reactodia:hasType').of<{ +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {patterns} = this.state; + if (patterns.length === 0) { + return { + type: 'error', + errors: [{ + target: this, + message: TranslatedText.text( + 'search_entities.error.required_at_least_one_pattern' + ), + }], + }; + } + const inner = SearchPattern.generateMany(patterns, context); + return Array.isArray(inner) ? {type: 'anyOf', patterns: inner} : inner; + } + + static readonly fromJSONType = 'anyOf'; + + static fromJSON( + raw: SerializedAnyOf, + context: SearchPatternFromJsonContext + ): SearchPatternAnyOf { + const {patterns} = raw; + return new SearchPatternAnyOf({ + patterns: patterns.map(p => context.fromJson(p)), + }); + } + + toJSON(context: SearchPatternToJsonContext): SerializedAnyOf { + const {patterns} = this.state; + return { + '@type': 'anyOf', + patterns: patterns.map(p => context.toJson(p)), + }; + } +} + +SearchPattern.define(SearchPatternAnyOf); + +interface SerializedAnyOf { + '@type': 'anyOf'; + patterns: SerializedSearchPattern[]; +} + +export class SearchPatternHasType extends SearchPattern<{ readonly elementType?: ElementTypeIri; -}>(); -export const SearchPatternConnectedTo = SearchPattern.type('urn:reactodia:connectedTo').of<{ +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {elementType} = this.state; + if (!elementType) { + return { + type: 'error', + errors: [{ + target: this, + message: TranslatedText.text( + 'search_entities.error.required_element_type' + ), + }], + }; + } + return { + type: 'hasType', + elementType, + }; + } + + static readonly fromJSONType = 'hasType'; + + static fromJSON(raw: SerializedHasType): SearchPatternHasType { + const {elementType} = raw; + return new SearchPatternHasType({elementType}); + } + + toJSON(context: SearchPatternToJsonContext): SerializedHasType { + const {elementType} = this.state; + return { + '@type': 'hasType', + elementType, + }; + } +} + +SearchPattern.define(SearchPatternHasType); + +interface SerializedHasType { + '@type': 'hasType'; + elementType?: ElementTypeIri; +} + +export class SearchPatternConnectedTo extends SearchPattern<{ readonly linkType?: LinkTypeIri; readonly linkDirection?: 'in' | 'out'; readonly target?: SearchPattern; -}>(); -export const SearchPatternFromValues = SearchPattern.type('urn:reactodia:fromValues').of<{ +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {linkType, linkDirection, target} = this.state; + const targetPattern = target?.generate(context); + if (targetPattern?.type === 'error') { + return targetPattern; + } + return { + type: 'connectedTo', + linkType, + linkDirection, + target: targetPattern, + }; + } + + static readonly fromJSONType = 'connectedTo'; + + static fromJSON( + raw: SerializedConnectedTo, + context: SearchPatternFromJsonContext + ): SearchPatternConnectedTo { + const {linkType, linkDirection, target} = raw; + return new SearchPatternConnectedTo({ + linkType, + linkDirection, + target: target ? context.fromJson(target) : undefined, + }); + } + + toJSON(context: SearchPatternToJsonContext): SerializedConnectedTo { + const {linkType, linkDirection, target} = this.state; + return { + '@type': 'connectedTo', + linkType, + linkDirection, + target: target ? context.toJson(target) : undefined, + }; + } +} + +SearchPattern.define(SearchPatternConnectedTo); + +interface SerializedConnectedTo { + '@type': 'connectedTo'; + linkType?: LinkTypeIri; + linkDirection?: 'in' | 'out'; + target?: SerializedSearchPattern; +} + +export class SearchPatternFromValues extends SearchPattern<{ readonly values: readonly ElementIri[]; -}>(); -export const SearchPatternHasProperty = SearchPattern.type('urn:reactodia:hasProperty').of<{ - readonly propertyType?: PropertyTypeIri; - readonly values?: ReadonlyArray; -}>(); +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {values} = this.state; + return { + type: 'fromValues', + values, + }; + } -export function searchPatternFrom(criteria: SearchCriteria): SearchPattern | undefined { - const { - elementType, refElement, refElementLink, linkDirection, pattern, - } = criteria; + static readonly fromJSONType = 'fromValues'; - const patterns: SearchPattern[] = []; + static fromJSON(raw: SerializedFromValues): SearchPatternFromValues { + const {values} = raw; + return new SearchPatternFromValues({values}); + } - if (elementType) { - patterns.push(SearchPattern.create(SearchPatternHasType, {elementType})); + toJSON(context: SearchPatternToJsonContext): SerializedFromValues { + const {values} = this.state; + return { + '@type': 'fromValues', + values, + }; } +} - if (refElement || refElementLink) { - patterns.push(SearchPattern.create(SearchPatternConnectedTo, { - linkType: refElementLink, - linkDirection, - target: refElement - ? SearchPattern.create(SearchPatternFromValues, {values: [refElement]}) - : undefined, - })); +SearchPattern.define(SearchPatternFromValues); + +interface SerializedFromValues { + '@type': 'fromValues'; + values: readonly ElementIri[]; +} + +export class SearchPatternHasProperty extends SearchPattern<{ + readonly propertyType?: PropertyTypeIri; + readonly values?: ReadonlyArray; +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {propertyType, values} = this.state; + if (!propertyType) { + return { + type: 'error', + errors: [{ + target: this, + message: TranslatedText.text( + 'search_entities.error.required_property_type' + ), + }], + }; + } + return { + type: 'hasProperty', + propertyType, + values, + }; } - if (pattern) { - patterns.push(pattern); + static readonly fromJSONType = 'hasProperty'; + + static fromJSON( + raw: SerializedHasProperty, + context: SearchPatternFromJsonContext + ): SearchPatternHasProperty { + const {propertyType, values} = raw; + return new SearchPatternHasProperty({ + propertyType, + values: values?.map(v => context.factory.fromTerm( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + v as any + ) as Rdf.NamedNode | Rdf.Literal), + }); } - return ( - patterns.length === 0 ? undefined : - patterns.length === 1 ? patterns[0] : - SearchPattern.create(SearchPatternAllOf, {patterns}) - ); + toJSON(context: SearchPatternToJsonContext): SerializedHasProperty { + const {propertyType, values} = this.state; + return { + '@type': 'hasProperty', + propertyType, + values, + }; + } +} + +SearchPattern.define(SearchPatternHasProperty); + +interface SerializedHasProperty { + '@type': 'hasProperty'; + propertyType?: PropertyTypeIri; + values?: readonly ( + | Pick + | Pick + )[]; } diff --git a/src/workspace.ts b/src/workspace.ts index d94d4ddd..dedab18c 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -266,15 +266,24 @@ export { Halo, type HaloProps } from './widgets/halo'; export { HaloLink, type HaloLinkProps } from './widgets/haloLink'; export { InstancesSearch, type InstancesSearchProps, type InstancesSearchCommands, - type SearchCriteria, SearchPattern, type SearchPatternType, type SearchPatternState, - SearchPatternAllOf, SearchPatternAnyOf, SearchPatternHasType, SearchPatternConnectedTo, - SearchPatternFromValues, SearchPatternHasProperty, + type SearchCriteria, SearchPattern, type SearchPatternType, type SerializedSearchPattern, + type SearchPatternError, type SearchPatternErrorItem, type SearchPatternGenerateContext, + type SearchPatternFromJsonContext, type SearchPatternToJsonContext, + SearchPatternAllOf, + SearchPatternAnyOf, + SearchPatternHasType, + SearchPatternConnectedTo, + SearchPatternFromValues, + SearchPatternHasProperty, type SearchCriterionOption, type SearchCriterionProps, - type SearchCriterionGenerateContext, type SearchCriterionErrorPattern, - type SearchCriterionError, SearchCriterionBlock, SearchCriterionWhereGroup, - SearchCriterionWhere, DefaultSearchCriterionOptions, - SearchCriterionAllOf, SearchCriterionAnyOf, SearchCriterionHasType, - SearchCriterionConnectedTo, SearchCriterionFromValues, SearchCriterionHasProperty, + SearchCriterionBlock, SearchCriterionWhereGroup, SearchCriterionWhere, + DefaultSearchCriterionOptions, + SearchCriterionAllOf, + SearchCriterionAnyOf, + SearchCriterionHasType, + SearchCriterionConnectedTo, + SearchCriterionFromValues, + SearchCriterionHasProperty, } from './widgets/instancesSearch'; export { type LinkActionContext, useLinkActionContext, From 81c92dba17b9d0e9d64c7fef966e1bf09d94907b Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Thu, 12 Nov 2026 01:20:47 +0300 Subject: [PATCH 4/9] WIP Add JSDoc for all exported types, components and functions --- src/data/dataProvider.ts | 30 +-- src/data/sparql/sparqlGenerator.ts | 16 +- src/widgets/instancesSearch/index.ts | 3 +- .../instancesSearch/instanceCriteria.tsx | 17 +- .../instancesSearch/instancesSearch.tsx | 77 +++++++- .../instancesSearch/searchCriterion.tsx | 184 ++++++++++++++---- src/widgets/instancesSearch/searchPattern.ts | 71 +++++-- .../unifiedSearch/builtinSearchSections.tsx | 41 +++- 8 files changed, 344 insertions(+), 95 deletions(-) diff --git a/src/data/dataProvider.ts b/src/data/dataProvider.ts index 55ab812f..9cd376db 100644 --- a/src/data/dataProvider.ts +++ b/src/data/dataProvider.ts @@ -300,48 +300,48 @@ export interface DataProviderLookupParams { } export type LookupPattern = - | AllOfLookupPattern - | AnyOfLookupPattern - | WithTextLookupPattern - | HasTypeLookupPattern - | ConnectedToLookupPattern - | FromValuesLookupPattern - | HasPropertyLookupPattern; - -export interface AllOfLookupPattern { + | LookupPatternAllOf + | LookupPatternAnyOf + | LookupPatternWithText + | LookupPatternHasType + | LookupPatternConnectedTo + | LookupPatternFromValues + | LookupPatternHasProperty; + +export interface LookupPatternAllOf { readonly type: 'allOf'; readonly patterns: readonly LookupPattern[]; } -export interface AnyOfLookupPattern { +export interface LookupPatternAnyOf { readonly type: 'anyOf'; readonly patterns: readonly LookupPattern[]; } -export interface HasTypeLookupPattern { +export interface LookupPatternHasType { readonly type: 'hasType'; readonly elementType: ElementTypeIri; } -export interface ConnectedToLookupPattern { +export interface LookupPatternConnectedTo { readonly type: 'connectedTo'; readonly linkType?: LinkTypeIri; readonly linkDirection?: 'in' | 'out'; readonly target?: LookupPattern; } -export interface FromValuesLookupPattern { +export interface LookupPatternFromValues { readonly type: 'fromValues'; readonly values: readonly ElementIri[]; } -export interface HasPropertyLookupPattern { +export interface LookupPatternHasProperty { readonly type: 'hasProperty'; readonly propertyType: PropertyTypeIri; readonly values?: ReadonlyArray; } -export interface WithTextLookupPattern { +export interface LookupPatternWithText { readonly type: 'withText'; readonly text: string; } diff --git a/src/data/sparql/sparqlGenerator.ts b/src/data/sparql/sparqlGenerator.ts index 75fc4e06..496c6cb3 100644 --- a/src/data/sparql/sparqlGenerator.ts +++ b/src/data/sparql/sparqlGenerator.ts @@ -6,8 +6,8 @@ import * as Rdf from '../rdf/rdfModel'; import { LinkTypeIri, PropertyTypeIri } from '../model'; import { - DataProviderLookupParams, LookupPattern, HasTypeLookupPattern, ConnectedToLookupPattern, - HasPropertyLookupPattern, WithTextLookupPattern, lookupPatternOf, + DataProviderLookupParams, LookupPattern, LookupPatternHasType, LookupPatternConnectedTo, + LookupPatternHasProperty, LookupPatternWithText, lookupPatternOf, } from '../dataProvider'; import { @@ -211,12 +211,12 @@ export class SparqlGenerator { if (supportFilterByPattern) { filterByPattern = this.generatePattern(pattern, generateParams); } else { - const hasType = findPattern(pattern, p => p.type === 'hasType'); + const hasType = findPattern(pattern, p => p.type === 'hasType'); if (hasType) { filterByType = this.generatePattern(hasType, generateParams); } - const connectedTo = findPattern( + const connectedTo = findPattern( pattern, p => p.type === 'connectedTo' ); @@ -224,7 +224,7 @@ export class SparqlGenerator { filterByRefElementLink = this.generatePattern(connectedTo, generateParams); } - const withText = findPattern(pattern, p => p.type === 'withText'); + const withText = findPattern(pattern, p => p.type === 'withText'); if (withText) { filterByText = this.generatePattern(withText, generateParams); } @@ -427,7 +427,7 @@ export class SparqlGenerator { } private generateConnectedToPattern( - pattern: ConnectedToLookupPattern, + pattern: LookupPatternConnectedTo, params: { sourceTerm: Rdf.Variable; targetTerm: Rdf.Variable | Rdf.NamedNode; @@ -576,7 +576,7 @@ export class SparqlGenerator { } private generateHasPropertyPattern( - pattern: HasPropertyLookupPattern, + pattern: LookupPatternHasProperty, params: { entityTerm: Rdf.Variable; valueTerm: Rdf.Variable; @@ -670,7 +670,7 @@ export class SparqlGenerator { } private generateWithTextPattern( - pattern: WithTextLookupPattern, + pattern: LookupPatternWithText, params: { entityTerm: Rdf.Variable; labelTerm: Rdf.Variable; diff --git a/src/widgets/instancesSearch/index.ts b/src/widgets/instancesSearch/index.ts index 354cbeea..eac7d2ea 100644 --- a/src/widgets/instancesSearch/index.ts +++ b/src/widgets/instancesSearch/index.ts @@ -3,9 +3,8 @@ export { InstancesSearch, type InstancesSearchProps, type InstancesSearchCommands, } from './instancesSearch'; export { - type SearchCriterionOption, type SearchCriterionProps, + type SearchCriteriaOption, type SearchCriterionProps, SearchCriterion, SearchCriterionBlock, SearchCriterionWhereGroup, SearchCriterionWhere, - DefaultSearchCriterionOptions, SearchCriterionAllOf, SearchCriterionAnyOf, SearchCriterionHasType, SearchCriterionConnectedTo, SearchCriterionFromValues, SearchCriterionHasProperty, } from './searchCriterion'; diff --git a/src/widgets/instancesSearch/instanceCriteria.tsx b/src/widgets/instancesSearch/instanceCriteria.tsx index aa865334..f29b784c 100644 --- a/src/widgets/instancesSearch/instanceCriteria.tsx +++ b/src/widgets/instancesSearch/instanceCriteria.tsx @@ -11,11 +11,12 @@ import { import { SearchInput, SearchInputStore } from '../utility/searchInput'; import { - SearchCriterion, SearchCriterionContext, SearchCriterionOption, AddCriterion, + SearchCriterion, SearchCriterionContext, SearchCriteriaOption, AddCriterion, } from './searchCriterion'; import { - SearchPattern, SearchPatternErrorItem, SearchPatternAllOf, SearchPatternHasType, - SearchPatternConnectedTo, SearchPatternFromValues, SearchPatternGenerateContext, + SearchPattern, SearchPatternType, SearchPatternErrorItem, + SearchPatternAllOf, SearchPatternHasType, SearchPatternConnectedTo, + SearchPatternFromValues, SearchPatternGenerateContext, } from './searchPattern'; /** @@ -56,9 +57,10 @@ export function InstanceCriteria(props: { criteria: SearchCriteria; setCriteria: (criteria: SearchCriteria) => void; searchStore: SearchInputStore | undefined; - criterionOptions: readonly SearchCriterionOption[]; + allCriterions: readonly SearchCriteriaOption[]; + creatableCriterions: readonly SearchCriteriaOption[]; }) { - const {criteria, setCriteria, searchStore, criterionOptions} = props; + const {criteria, setCriteria, searchStore, allCriterions, creatableCriterions} = props; const t = useTranslation(); const [outerCriteria, setOuterCriteria] = React.useState(criteria); @@ -71,9 +73,10 @@ export function InstanceCriteria(props: { const context = React.useMemo((): SearchCriterionContext => ({ editing: editState !== undefined, - patternOptions: criterionOptions, errors: editState?.errors ?? new Map(), - }), [editState, criterionOptions]); + allOptions: allCriterions, + creatableOptions: creatableCriterions, + }), [editState, allCriterions, creatableCriterions]); if (outerCriteria !== criteria) { setOuterCriteria(criteria); diff --git a/src/widgets/instancesSearch/instancesSearch.tsx b/src/widgets/instancesSearch/instancesSearch.tsx index 2efb2834..ba92e56c 100644 --- a/src/widgets/instancesSearch/instancesSearch.tsx +++ b/src/widgets/instancesSearch/instancesSearch.tsx @@ -27,9 +27,11 @@ import { SearchResults } from '../utility/searchResults'; import { SearchCriteria, InstanceCriteria } from './instanceCriteria'; import { - type SearchCriterionOption, DefaultSearchCriterionOptions, + type SearchCriteriaOption, SearchCriterionAllOf, SearchCriterionAnyOf, + SearchCriterionHasType, SearchCriterionConnectedTo, SearchCriterionFromValues, + SearchCriterionHasProperty, } from './searchCriterion'; -import { SearchPatternGenerateContext } from './searchPattern'; +import { SearchPatternGenerateContext, SearchPatternType } from './searchPattern'; /** * Props for {@link InstancesSearch} component. @@ -63,8 +65,34 @@ export interface InstancesSearchProps { * @default 3 */ minSearchTermLength?: number; - - criterionOptions?: readonly SearchCriterionOption[]; + /** + * Supported search criteria options to display and edit corresponding + * {@link SearchPattern search patterns}. + * + * Built-in criteria options are always included to display or edit their + * {@link SearchCriteriaOption.type pattern types}, however these options + * can be overriden by including options for these pattern types instead. + * + * **Built-in criteria options**: + * - {@link SearchCriterionAllOf} + * - {@link SearchCriterionAnyOf} + * - {@link SearchCriterionHasType} + * - {@link SearchCriterionConnectedTo} + * - {@link SearchCriterionFromValues} + * - {@link SearchCriterionHasProperty} + */ + criteriaOptions?: readonly SearchCriteriaOption[]; + /** + * The order in which to display the built-in and extra search criteria options + * when adding a new search pattern from the UI. + * + * If specified, only options with corresponding types will be available + * for creation from the menu. + * + * **Default** order is built-in options then additional ones provided + * with {@link creatableOptions}. + */ + addCriteriaOrder?: readonly SearchPatternType[]; /** * Handler for the search criteria changes. */ @@ -105,6 +133,15 @@ export interface InstancesSearchCommands { }; } +const BuiltInCriterionOptions: readonly SearchCriteriaOption[] = [ + SearchCriterionAllOf, + SearchCriterionAnyOf, + SearchCriterionHasType, + SearchCriterionConnectedTo, + SearchCriterionFromValues, + SearchCriterionHasProperty, +]; + /** * Component to search for entities by various filter criteria * to add them as elements to the diagram. @@ -116,6 +153,8 @@ export function InstancesSearch(props: InstancesSearchProps) { searchStore, searchTimeout = 'explicit', minSearchTermLength = 3, + criteriaOptions, + addCriteriaOrder, } = props; const uncontrolledSearch = useSearchInputStore({ initialValue: '', @@ -125,11 +164,32 @@ export function InstancesSearch(props: InstancesSearchProps) { const effectiveSearchStore = searchStore ?? uncontrolledSearch; const workspace = useWorkspace(); const t = useTranslation(); + + const [allCriterions, creatableCriterions] = React.useMemo(() => { + const all = new Map(); + for (const option of BuiltInCriterionOptions) { + all.set(option.type, option); + } + if (criteriaOptions) { + for (const option of criteriaOptions) { + all.set(option.type, option); + } + } + const allValues = Array.from(all.values()); + const creatable = addCriteriaOrder + ? addCriteriaOrder.map(type => all.get(type)) + .filter((option): option is SearchCriteriaOption => Boolean(option)) + : allValues; + return [allValues, creatable]; + }, [criteriaOptions, addCriteriaOrder]); + return ( @@ -140,6 +200,8 @@ interface InstancesSearchInnerProps extends InstancesSearchProps { isControlled: boolean; searchStore: SearchInputStore; minSearchTermLength: number; + allCriterions: readonly SearchCriteriaOption[]; + creatableCriterions: readonly SearchCriteriaOption[]; workspace: WorkspaceContext; translation: Translation; } @@ -283,8 +345,8 @@ class InstancesSearchInner extends React.Component this.props.onChangeCriteria?.(this.state.criteria) )} searchStore={isControlled ? undefined : searchStore} - criterionOptions={criterionOptions} + allCriterions={allCriterions} + creatableCriterions={creatableCriterions} /> { +/** + * Search criteria option to display and edit the corresponding + * search pattern {@link type}. + */ +export interface SearchCriteriaOption { + /** + * Correspoding {@link SearchPattern search pattern} for the option. + */ readonly type: SearchPatternType

; + /** + * Option label to display in the menu when creating a new pattern. + * + * If `undefined`, the option will be hidden from the menu. + */ readonly label?: string | TranslatedText; + /** + * Function to create a new instance of the corresponding search pattern. + */ readonly createPattern: () => P; + /** + * Renders the search patten in display or edit mode. + */ readonly renderCriterion: ( props: SearchCriterionProps

) => React.ReactNode; } +/** + * Props for rendering a component from {@link SearchCriteriaOption.renderCriterion}. + */ export interface SearchCriterionProps { + /** + * Displayed or edited search pattern. + */ pattern: P; + /** + * Handler to update or replace the currently edited pattern. + */ setPattern: (state: SearchPattern | undefined) => void; - editing: boolean; + /** + * Mode to render the pattern: + * - display mode: read-only compact represetnation of the pattern, + * - edit mode: UI to edit the pattern state, replace or remove it. + */ + inEditMode: boolean; + /** + * Search pattern parent if exists, or `undefined` if the pattern + * is a top-level one. + */ parent?: SearchPattern; + /** + * Provided controls to display with {@link SearchCriterionBlock} + * in the edit {@link mode}. + */ controls?: React.ReactNode; } export interface SearchCriterionContext { readonly editing: boolean; readonly errors: ReadonlyMap; - readonly patternOptions: readonly SearchCriterionOption[]; + readonly allOptions: readonly SearchCriteriaOption[]; + readonly creatableOptions: readonly SearchCriteriaOption[]; } export const SearchCriterionContext = React.createContext({ editing: false, errors: new Map(), - patternOptions: [], + allOptions: [], + creatableOptions: [], }); const CLASS_NAME = 'reactodia-search-criterion'; +/** + * Displays a search pattern with corresponding {@link SearchCriteriaOption criteria option}. + * + * If {@link pattern} is `undefined`, displays a placeholder option + * with ability to create a new search pattern instead. + * + * @category Components + */ export function SearchCriterion(props: { + /** + * Search pattern to display or edit. + */ pattern: SearchPattern | undefined; + /** + * Handler to process when the pattern is updated or replaced. + */ setPattern: (pattern: SearchPattern | undefined) => void; + /** + * Parent search pattern if exists, or `undefined` if the pattern + * is a top-level one. + */ parent: SearchPattern | undefined; }) { const {pattern, setPattern, parent} = props; const t = useTranslation(); - const {editing, patternOptions} = React.useContext(SearchCriterionContext); + const {editing, allOptions} = React.useContext(SearchCriterionContext); if (!pattern) { return ( @@ -102,12 +162,12 @@ export function SearchCriterion(props: { /> ) : null; - const option = patternOptions.find(option => pattern instanceof option.type) + const option = allOptions.find(option => pattern instanceof option.type) ?? PlaceholderUnknownOption; const optionProps: SearchCriterionProps = { pattern, setPattern, - editing, + inEditMode: editing, parent, controls, }; @@ -122,7 +182,7 @@ export function AddCriterion(props: { }) { const {onAdd, title, children} = props; const t = useTranslation(); - const {patternOptions} = React.useContext(SearchCriterionContext); + const {creatableOptions} = React.useContext(SearchCriterionContext); return ( }> - {patternOptions.map(option => option.label ? ( + {creatableOptions.map(option => option.label ? ( { onAdd(option.createPattern()); @@ -165,10 +225,28 @@ function AddCriterionButton(props: { ); } +/** + * Utility component to render criterion from {@link SearchCriteriaOption} + * in the edit mode with controls and pattern validation errors. + * + * @category Components + */ export function SearchCriterionBlock(props: { + /** + * Target pattern to display validation errors for. + */ target: SearchPattern; + /** + * Criterion header to describe the pattern type. + */ header: React.ReactNode; + /** + * Criterion controls provided with {@link SearchCriterionProps.controls}. + */ controls: React.ReactNode; + /** + * Criterion editing UI, e.g. inputs, {@link SearchCriterionWhereGroup}, etc. + */ children?: React.ReactNode; }) { const {target, header, controls, children} = props; @@ -202,7 +280,19 @@ export function SearchCriterionBlock(props: { ); } -export function SearchCriterionWhereGroup(props: { children: React.ReactElement }) { +/** + * Utility component to display a "where" definition-list + * when rendering a criterion from {@link SearchCriteriaOption}. + * + * @category Components + * @see {@link SearchCriterionWhere} + */ +export function SearchCriterionWhereGroup(props: { + /** + * Definition list content, e.g. {@link SearchCriterionWhere} items. + */ + children: React.ReactElement; +}) { const {children} = props; return (

@@ -211,8 +301,21 @@ export function SearchCriterionWhereGroup(props: { children: React.ReactElement ); } +/** + * Utility component to display a "where" definition-item + * when rendering a criterion from {@link SearchCriteriaOption}. + * + * @category Components + * @see {@link SearchCriterionWhereGroup} + */ export function SearchCriterionWhere(props: { + /** + * Definition label, e.g. "target" or "sub-pattern" to display. + */ part: string; + /** + * Definition content. + */ children: React.ReactNode; }) { const {part, children} = props; @@ -272,7 +375,7 @@ class SearchPatternUnknown extends SearchPattern> { SearchPattern.define(SearchPatternUnknown); -const PlaceholderUnknownOption: SearchCriterionOption = { +const PlaceholderUnknownOption: SearchCriteriaOption = { type: SearchPatternUnknown, createPattern: () => new SearchPatternUnknown({}), renderCriterion: props => , @@ -295,13 +398,19 @@ function PlaceholderUnknownCriterion( ); } -export const SearchCriterionAllOf: SearchCriterionOption = { +/** + * Search criteria option corresponding to {@link LookupPatternAllOf}. + */ +export const SearchCriterionAllOf: SearchCriteriaOption = { type: SearchPatternAllOf, createPattern: () => new SearchPatternAllOf({patterns: []}), renderCriterion: props => , }; -export const SearchCriterionAnyOf: SearchCriterionOption = { +/** + * Search criteria option corresponding to {@link LookupPatternAnyOf}. + */ +export const SearchCriterionAnyOf: SearchCriteriaOption = { type: SearchPatternAnyOf, createPattern: () => new SearchPatternAnyOf({patterns: []}), renderCriterion: props => , @@ -310,7 +419,7 @@ export const SearchCriterionAnyOf: SearchCriterionOption = { function AnyAllOfCriterion

( props: SearchCriterionProps

) { - const {pattern, setPattern, editing, parent} = props; + const {pattern, setPattern, inEditMode, parent} = props; const t = useTranslation(); const className = parent ? `${CLASS_NAME}__logical-group` @@ -366,7 +475,7 @@ function AnyAllOfCriterion

( /> ))} - {editing && pattern.state.patterns.length === 0 ? ( + {inEditMode && pattern.state.patterns.length === 0 ? ( { setPattern( @@ -399,7 +508,10 @@ function AnyAllOfCriterion

( ); } -export const SearchCriterionHasType: SearchCriterionOption = { +/** + * Search criteria option corresponding to {@link LookupPatternHasType}. + */ +export const SearchCriterionHasType: SearchCriteriaOption = { type: SearchPatternHasType, label: TranslatedText.text('search_entities.criteria_has_type.label'), createPattern: () => new SearchPatternHasType({elementType: ''}), @@ -409,14 +521,14 @@ export const SearchCriterionHasType: SearchCriterionOption function HasTypeCriterion( props: SearchCriterionProps ) { - const {pattern, setPattern, editing, controls} = props; + const {pattern, setPattern, inEditMode, controls} = props; const t = useTranslation(); const elementType = useSchemaType('elementType', pattern.state.elementType); let header: React.ReactNode; let where: React.ReactNode | null = null; - if (!editing) { + if (!inEditMode) { const typeView = elementType ? ( = { +/** + * Search criteria option corresponding to {@link LookupPatternConnectedTo}. + */ +export const SearchCriterionConnectedTo: SearchCriteriaOption = { type: SearchPatternConnectedTo, label: TranslatedText.text('search_entities.criteria_connected.label'), createPattern: () => new SearchPatternConnectedTo({}), @@ -468,7 +583,7 @@ export const SearchCriterionConnectedTo: SearchCriterionOption ) { - const {pattern, setPattern, editing, controls} = props; + const {pattern, setPattern, inEditMode, controls} = props; const t = useTranslation(); const {target, linkDirection} = pattern.state; @@ -479,7 +594,7 @@ function ConnectedToCriterion( let header: React.ReactNode; let where: React.ReactNode = null; - if (!editing) { + if (!inEditMode) { let entity: React.ReactElement; if (!target || target instanceof SearchPatternFromValues) { inlineEntity = true; @@ -582,7 +697,10 @@ function ConnectedToCriterion( ); } -export const SearchCriterionFromValues: SearchCriterionOption = { +/** + * Search criteria option corresponding to {@link LookupPatternFromValues}. + */ +export const SearchCriterionFromValues: SearchCriteriaOption = { type: SearchPatternFromValues, label: TranslatedText.text('search_entities.criteria_from_values.label'), createPattern: () => new SearchPatternFromValues({values: []}), @@ -592,12 +710,12 @@ export const SearchCriterionFromValues: SearchCriterionOption ) { - const {pattern, setPattern, editing, controls} = props; + const {pattern, setPattern, inEditMode, controls} = props; const t = useTranslation(); let header: React.ReactNode; let where: React.ReactNode = null; - if (!editing) { + if (!inEditMode) { header = ( {t.template('search_entities.criteria_from_values', { @@ -688,7 +806,10 @@ function FromValuesSelector(props: Pick< ); } -export const SearchCriterionHasProperty: SearchCriterionOption = { +/** + * Search criteria option corresponding to {@link LookupPatternHasProperty}. + */ +export const SearchCriterionHasProperty: SearchCriteriaOption = { type: SearchPatternHasProperty, label: TranslatedText.text('search_entities.criteria_has_property.label'), createPattern: () => new SearchPatternHasProperty({propertyType: ''}), @@ -698,7 +819,7 @@ export const SearchCriterionHasProperty: SearchCriterionOption ) { - const {pattern, setPattern, editing, controls} = props; + const {pattern, setPattern, inEditMode, controls} = props; const t = useTranslation(); const propertyType = useSchemaType('propertyType', pattern.state.propertyType); @@ -706,7 +827,7 @@ function HasPropertyCriterion( let header: React.ReactNode; let where: React.ReactNode = null; - if (!editing) { + if (!inEditMode) { const propertyView = propertyType ? ( ); } - -export const DefaultSearchCriterionOptions: readonly SearchCriterionOption[] = [ - SearchCriterionAllOf, - SearchCriterionAnyOf, - SearchCriterionHasType, - SearchCriterionConnectedTo, - SearchCriterionFromValues, - SearchCriterionHasProperty, -]; diff --git a/src/widgets/instancesSearch/searchPattern.ts b/src/widgets/instancesSearch/searchPattern.ts index 4bae4013..ca302d2b 100644 --- a/src/widgets/instancesSearch/searchPattern.ts +++ b/src/widgets/instancesSearch/searchPattern.ts @@ -7,6 +7,11 @@ import type * as Rdf from '../../data/rdf/rdfModel'; * Abstract search pattern type for search criteria in * {@link SearchSectionEntities} and {@link InstancesSearch}. * + * Compared to {@link LookupPattern}, a search pattern represents + * displayed or edited state for a {@link SearchCriterionOption}. + * This state can be converted into {@link LookupPattern} with + * validation, serialized to/deserialized from a plain JSON value. + * * **Example**: * ```ts * class FromNamedSet extends SearchPattern<{ @@ -195,6 +200,11 @@ export interface SerializedSearchPattern { '@type': string; } +/** + * Search pattern corresponding to {@link LookupPatternAllOf}. + * + * @see {@link SearchCriterionAllOf} + */ export class SearchPatternAllOf extends SearchPattern<{ readonly patterns: readonly SearchPattern[]; }> { @@ -217,7 +227,7 @@ export class SearchPatternAllOf extends SearchPattern<{ return Array.isArray(inner) ? {type: 'allOf', patterns: inner} : inner; } - static readonly fromJSONType = 'allOf'; + static readonly fromJSONType = 'AllOf'; static fromJSON( raw: SerializedAllOf, @@ -232,7 +242,7 @@ export class SearchPatternAllOf extends SearchPattern<{ toJSON(context: SearchPatternToJsonContext): SerializedAllOf { const {patterns} = this.state; return { - '@type': 'allOf', + '@type': 'AllOf', patterns: patterns.map(p => context.toJson(p)), }; } @@ -241,10 +251,15 @@ export class SearchPatternAllOf extends SearchPattern<{ SearchPattern.define(SearchPatternAllOf); interface SerializedAllOf { - '@type': 'allOf'; + '@type': 'AllOf'; patterns: SerializedSearchPattern[]; } +/** + * Search pattern corresponding to {@link LookupPatternAnyOf}. + * + * @see {@link SearchCriterionAnyOf} + */ export class SearchPatternAnyOf extends SearchPattern<{ readonly patterns: readonly SearchPattern[]; }> { @@ -267,7 +282,7 @@ export class SearchPatternAnyOf extends SearchPattern<{ return Array.isArray(inner) ? {type: 'anyOf', patterns: inner} : inner; } - static readonly fromJSONType = 'anyOf'; + static readonly fromJSONType = 'AnyOf'; static fromJSON( raw: SerializedAnyOf, @@ -282,7 +297,7 @@ export class SearchPatternAnyOf extends SearchPattern<{ toJSON(context: SearchPatternToJsonContext): SerializedAnyOf { const {patterns} = this.state; return { - '@type': 'anyOf', + '@type': 'AnyOf', patterns: patterns.map(p => context.toJson(p)), }; } @@ -291,10 +306,15 @@ export class SearchPatternAnyOf extends SearchPattern<{ SearchPattern.define(SearchPatternAnyOf); interface SerializedAnyOf { - '@type': 'anyOf'; + '@type': 'AnyOf'; patterns: SerializedSearchPattern[]; } +/** + * Search pattern corresponding to {@link LookupPatternHasType}. + * + * @see {@link SearchCriterionHasType} + */ export class SearchPatternHasType extends SearchPattern<{ readonly elementType?: ElementTypeIri; }> { @@ -319,7 +339,7 @@ export class SearchPatternHasType extends SearchPattern<{ }; } - static readonly fromJSONType = 'hasType'; + static readonly fromJSONType = 'HasType'; static fromJSON(raw: SerializedHasType): SearchPatternHasType { const {elementType} = raw; @@ -329,7 +349,7 @@ export class SearchPatternHasType extends SearchPattern<{ toJSON(context: SearchPatternToJsonContext): SerializedHasType { const {elementType} = this.state; return { - '@type': 'hasType', + '@type': 'HasType', elementType, }; } @@ -338,10 +358,15 @@ export class SearchPatternHasType extends SearchPattern<{ SearchPattern.define(SearchPatternHasType); interface SerializedHasType { - '@type': 'hasType'; + '@type': 'HasType'; elementType?: ElementTypeIri; } +/** + * Search pattern corresponding to {@link LookupPatternConnectedTo}. + * + * @see {@link SearchCriterionConnectedTo} + */ export class SearchPatternConnectedTo extends SearchPattern<{ readonly linkType?: LinkTypeIri; readonly linkDirection?: 'in' | 'out'; @@ -363,7 +388,7 @@ export class SearchPatternConnectedTo extends SearchPattern<{ }; } - static readonly fromJSONType = 'connectedTo'; + static readonly fromJSONType = 'ConnectedTo'; static fromJSON( raw: SerializedConnectedTo, @@ -380,7 +405,7 @@ export class SearchPatternConnectedTo extends SearchPattern<{ toJSON(context: SearchPatternToJsonContext): SerializedConnectedTo { const {linkType, linkDirection, target} = this.state; return { - '@type': 'connectedTo', + '@type': 'ConnectedTo', linkType, linkDirection, target: target ? context.toJson(target) : undefined, @@ -391,12 +416,17 @@ export class SearchPatternConnectedTo extends SearchPattern<{ SearchPattern.define(SearchPatternConnectedTo); interface SerializedConnectedTo { - '@type': 'connectedTo'; + '@type': 'ConnectedTo'; linkType?: LinkTypeIri; linkDirection?: 'in' | 'out'; target?: SerializedSearchPattern; } +/** + * Search pattern corresponding to {@link LookupPatternFromValues}. + * + * @see {@link SearchCriterionFromValues} + */ export class SearchPatternFromValues extends SearchPattern<{ readonly values: readonly ElementIri[]; }> { @@ -410,7 +440,7 @@ export class SearchPatternFromValues extends SearchPattern<{ }; } - static readonly fromJSONType = 'fromValues'; + static readonly fromJSONType = 'FromValues'; static fromJSON(raw: SerializedFromValues): SearchPatternFromValues { const {values} = raw; @@ -420,7 +450,7 @@ export class SearchPatternFromValues extends SearchPattern<{ toJSON(context: SearchPatternToJsonContext): SerializedFromValues { const {values} = this.state; return { - '@type': 'fromValues', + '@type': 'FromValues', values, }; } @@ -429,10 +459,15 @@ export class SearchPatternFromValues extends SearchPattern<{ SearchPattern.define(SearchPatternFromValues); interface SerializedFromValues { - '@type': 'fromValues'; + '@type': 'FromValues'; values: readonly ElementIri[]; } +/** + * Search pattern corresponding to {@link LookupPatternHasProperty}. + * + * @see {@link SearchCriterionHasProperty} + */ export class SearchPatternHasProperty extends SearchPattern<{ readonly propertyType?: PropertyTypeIri; readonly values?: ReadonlyArray; @@ -459,7 +494,7 @@ export class SearchPatternHasProperty extends SearchPattern<{ }; } - static readonly fromJSONType = 'hasProperty'; + static readonly fromJSONType = 'HasProperty'; static fromJSON( raw: SerializedHasProperty, @@ -478,7 +513,7 @@ export class SearchPatternHasProperty extends SearchPattern<{ toJSON(context: SearchPatternToJsonContext): SerializedHasProperty { const {propertyType, values} = this.state; return { - '@type': 'hasProperty', + '@type': 'HasProperty', propertyType, values, }; @@ -488,7 +523,7 @@ export class SearchPatternHasProperty extends SearchPattern<{ SearchPattern.define(SearchPatternHasProperty); interface SerializedHasProperty { - '@type': 'hasProperty'; + '@type': 'HasProperty'; propertyType?: PropertyTypeIri; values?: readonly ( | Pick diff --git a/src/widgets/unifiedSearch/builtinSearchSections.tsx b/src/widgets/unifiedSearch/builtinSearchSections.tsx index e38de8b5..2e26c172 100644 --- a/src/widgets/unifiedSearch/builtinSearchSections.tsx +++ b/src/widgets/unifiedSearch/builtinSearchSections.tsx @@ -6,7 +6,9 @@ import type { CanvasDropEvent } from '../../diagram/canvasApi'; import type { EntityElement } from '../../editor/dataElements'; import { ClassTree } from '../classTree'; -import { InstancesSearch, SearchCriteria } from '../instancesSearch'; +import { + InstancesSearch, SearchCriteria, SearchCriteriaOption, SearchPatternType, +} from '../instancesSearch'; import { LinkTypesToolbox } from '../linksToolbox'; import { InstancesSearchTopic } from '../../workspace/commandBusTopic'; @@ -88,8 +90,41 @@ export function SearchSectionEntities(props: { * @default 3 */ minSearchTermLength?: number; + /** + * Supported search criteria options to display and edit corresponding + * {@link SearchPattern search patterns}. + * + * Built-in criteria options are always included to display or edit their + * {@link SearchCriterionOption.type pattern types}, however these options + * can be overriden by including options for these pattern types instead. + * + * **Built-in criteria options**: + * - {@link SearchCriterionAllOf} + * - {@link SearchCriterionAnyOf} + * - {@link SearchCriterionHasType} + * - {@link SearchCriterionConnectedTo} + * - {@link SearchCriterionFromValues} + * - {@link SearchCriterionHasProperty} + */ + criteriaOptions?: readonly SearchCriteriaOption[]; + /** + * The order in which to display the built-in and extra search criteria options + * when adding a new search pattern from the UI. + * + * If specified, only options with corresponding types will be available + * for creation from the menu. + * + * **Default** order is built-in options then additional ones provided + * with {@link creatableOptions}. + */ + addCriteriaOrder?: readonly SearchPatternType[]; }) { - const {searchTimeout = 600, minSearchTermLength = 3} = props; + const { + searchTimeout = 600, + minSearchTermLength = 3, + criteriaOptions, + addCriteriaOrder, + } = props; const {getCommandBus} = useWorkspace(); const {shouldRender, setSectionActive, searchStore} = useUnifiedSearchSection({ searchTimeout, @@ -108,6 +143,8 @@ export function SearchSectionEntities(props: { return ( { if (shouldRender) { setSectionActive(true, criteriaAsSearchExtra(criteria)); From c31b8e0dde49da446832fc34186b7e585a6ceb90 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Fri, 13 Nov 2026 02:50:24 +0300 Subject: [PATCH 5/9] WIP Add JSDoc for `DataProvider` types --- src/data/dataProvider.ts | 165 ++++++++++++++++-- src/widgets/instancesSearch/index.ts | 2 +- .../instancesSearch/searchCriterion.tsx | 20 +-- src/workspace.ts | 5 +- 4 files changed, 163 insertions(+), 29 deletions(-) diff --git a/src/data/dataProvider.ts b/src/data/dataProvider.ts index 9cd376db..ab0fe5cc 100644 --- a/src/data/dataProvider.ts +++ b/src/data/dataProvider.ts @@ -198,15 +198,16 @@ export interface DataProvider { signal?: AbortSignal; }): Promise; + /** + * TODO + */ supportsPattern(pattern: LookupPattern): DataProviderPatternSupport; /** - * Looks up elements with different filters: - * - by an element type via `elementTypeId`; - * - by a connected element via `refElementId`, `refElementLinkId` and `linkDirection`; - * - by a text lookup via `text`; + * Finds entities in a data graph by specified lookup filters. * - * Filters can be combined to produce an intersection of the results. + * Support and validity for the provided lookup pattern can be checked + * with {@link DataProvider.supportsPattern}. */ lookup(params: DataProviderLookupParams): Promise; } @@ -246,36 +247,51 @@ export interface DataProviderPatternSupport { /** * Parameters for {@link DataProvider.lookup} operation. * + * Specified filters are be combined to produce an intersection of the results, + * same as nesting them in a {@link LookupPatternAllOf} pattern. + * + * All lookup filters can be combined into a single pattern + * with {@link lookupPatternOf}. + * * @category Data * @see {@link DataProvider.lookup} */ export interface DataProviderLookupParams { /** * Filter by an element type. + * + * Same as {@link LookupPatternHasType}. */ elementTypeId?: ElementTypeIri; /** * Filter by a text lookup. + * + * Same as {@link LookupPatternWithText}. */ text?: string; /** * Filter by having a connected element with specified IRI. + * + * Same as {@link LookupPatternConnectedTo} (together with + * {@link refElementLinkId} and {@link linkDirection}). */ refElementId?: ElementIri; /** * Filter by connection link type. * - * Only applicable when `refElementId` is set. + * Same as {@link LookupPatternConnectedTo} (together with + * {@link refElementId} and {@link linkDirection}). */ refElementLinkId?: LinkTypeIri; /** * Reference element link type direction ('in' | 'out'). - * - * Only when `refElementLinkId` is set. + * + * Same as {@link LookupPatternConnectedTo} (together with + * {@link refElementId} and {@link refElementLinkId}). */ linkDirection?: 'in' | 'out'; @@ -299,6 +315,11 @@ export interface DataProviderLookupParams { signal?: AbortSignal; } +/** + * Describes a lookup pattern to find entities in a data graph. + * + * @see {@link DataProvider.lookup} + */ export type LookupPattern = | LookupPatternAllOf | LookupPatternAnyOf @@ -308,41 +329,149 @@ export type LookupPattern = | LookupPatternFromValues | LookupPatternHasProperty; +/** + * Lookup pattern that filters entities by requiring all of the nested + * {@link patterns} to match. + */ export interface LookupPatternAllOf { + /** + * Lookup pattern type. + */ readonly type: 'allOf'; + /** + * Patterns list, all of which matching are required for this pattern to match. + * + * Empty list is considered to be an invalid pattern. + */ readonly patterns: readonly LookupPattern[]; } +/** + * Lookup pattern that filters entities by requiring any of the nested + * {@link patterns} to match. + */ export interface LookupPatternAnyOf { + /** + * Lookup pattern type. + */ readonly type: 'anyOf'; + /** + * Patterns list, any of which matching is enough for this pattern to match. + * + * Empty list is considered to be an invalid pattern. + */ readonly patterns: readonly LookupPattern[]; } +/** + * Lookup pattern that filters entities by an element type. + */ export interface LookupPatternHasType { + /** + * Lookup pattern type. + */ readonly type: 'hasType'; + /** + * Required element type for an entity. + */ readonly elementType: ElementTypeIri; } +/** + * Lookup pattern that filters entities by having another connected entity + * matching a {@link target} pattern by any or specific {@link linkType link type} + * and {@link linkDirection direction}. + * + * If provided as a top-level pattern (including as part of {@link LookupPatternAllOf} + * or {@link LookupPatternAnyOf}), provides {@link DataProviderLookupItem.inLinks} and + * {@link DataProviderLookupItem.outLinks} on the result items. + */ export interface LookupPatternConnectedTo { + /** + * Lookup pattern type. + */ readonly type: 'connectedTo'; + /** + * Required link type between {@link target} and the entity. + * + * If not specified, any link type is allowed. + */ readonly linkType?: LinkTypeIri; + /** + * Required link direction between {@link target} and the entity: + * - `out`: `target` → entity + * - `in`: `target` ← entity + * + * If not specified, both directions are allowed. + */ readonly linkDirection?: 'in' | 'out'; + /** + * Required link pattern to match connected entities. + * + * If not specified, any connected entity is allowed. + */ readonly target?: LookupPattern; } +/** + * Lookup pattern that filters entities being in the fixed set of + * the matching {@link values}. + */ export interface LookupPatternFromValues { + /** + * Lookup pattern type. + */ readonly type: 'fromValues'; + /** + * Set of allowed entity IRIs. + * + * If empty, the pattern should never match. + */ readonly values: readonly ElementIri[]; } +/** + * Lookup pattern that filters entities by having a property value + * with specified {@link propertyType property type}. + * + * The pattern can optionally match only when the property has at least + * one value from the provided set of values. + */ export interface LookupPatternHasProperty { + /** + * Lookup pattern type. + */ readonly type: 'hasProperty'; + /** + * Required property type with at least one value for the entity. + */ readonly propertyType: PropertyTypeIri; + /** + * Set of matching property values: if non-empty, the pattern should match + * only if the property has at least one values from this set. + */ readonly values?: ReadonlyArray; } +/** + * Lookup pattern that filters entities by having a matching text + * in its labels. + * + * If provided as a top-level pattern, depending on how close {@link text} matches + * entity label by provider-specific score may infuence the result item position + * in the {@link DataProvider.lookup} results. + */ export interface LookupPatternWithText { + /** + * Lookup pattern type. + */ readonly type: 'withText'; + /** + * Text token to match in an entity label. + * + * The exact text matching behavior is provider-specific + * (e.g. how partial word matches are handled, etc). + */ readonly text: string; } @@ -359,24 +488,30 @@ export interface DataProviderLookupItem { */ readonly element: ElementModel; /** - * Link types for connections in the form: `refElementId` ← `element` - * ({@link DataProviderLookupParams.refElementId refElementId}). + * Link types for connections in the form: `entity` → `target` + * ({@link LookupPatternConnectedTo.target}). * - * Only applicable if {@link DataProviderLookupParams.refElementId refElementId} + * Only applicable if {@link LookupPatternConnectedTo} or equivalent filter * is specified in {@link DataProvider.lookup}. */ readonly inLinks: ReadonlySet; /** - * Link types for connections in the form: `refElementId` → `element` - * ({@link DataProviderLookupParams.refElementId refElementId}). + * Link types for connections in the form: `entity` ← `target` + * ({@link LookupPatternConnectedTo.target}). * - * Only applicable if {@link DataProviderLookupParams.refElementId refElementId} + * Only applicable if {@link LookupPatternConnectedTo} or equivalent filter * is specified in {@link DataProvider.lookup}. */ readonly outLinks: ReadonlySet; } -export function lookupPatternOf(params: Omit): LookupPattern { +/** + * Composes a a single lookup pattern from filters specified in + * {@link DataProviderLookupParams params} for {@link DataProvider.lookup}. + */ +export function lookupPatternOf( + params: Omit +): LookupPattern { const { elementTypeId, refElementId, refElementLinkId, linkDirection, text, pattern, } = params; diff --git a/src/widgets/instancesSearch/index.ts b/src/widgets/instancesSearch/index.ts index eac7d2ea..668826a1 100644 --- a/src/widgets/instancesSearch/index.ts +++ b/src/widgets/instancesSearch/index.ts @@ -3,7 +3,7 @@ export { InstancesSearch, type InstancesSearchProps, type InstancesSearchCommands, } from './instancesSearch'; export { - type SearchCriteriaOption, type SearchCriterionProps, SearchCriterion, + type SearchCriteriaOption, type SearchCriteriaRenderProps, SearchCriterion, SearchCriterionBlock, SearchCriterionWhereGroup, SearchCriterionWhere, SearchCriterionAllOf, SearchCriterionAnyOf, SearchCriterionHasType, SearchCriterionConnectedTo, SearchCriterionFromValues, SearchCriterionHasProperty, diff --git a/src/widgets/instancesSearch/searchCriterion.tsx b/src/widgets/instancesSearch/searchCriterion.tsx index c60ba0e8..3fa69083 100644 --- a/src/widgets/instancesSearch/searchCriterion.tsx +++ b/src/widgets/instancesSearch/searchCriterion.tsx @@ -43,14 +43,14 @@ export interface SearchCriteriaOption + props: SearchCriteriaRenderProps

) => React.ReactNode; } /** * Props for rendering a component from {@link SearchCriteriaOption.renderCriterion}. */ -export interface SearchCriterionProps { +export interface SearchCriteriaRenderProps { /** * Displayed or edited search pattern. */ @@ -164,7 +164,7 @@ export function SearchCriterion(props: { ) : null; const option = allOptions.find(option => pattern instanceof option.type) ?? PlaceholderUnknownOption; - const optionProps: SearchCriterionProps = { + const optionProps: SearchCriteriaRenderProps = { pattern, setPattern, inEditMode: editing, @@ -382,7 +382,7 @@ const PlaceholderUnknownOption: SearchCriteriaOption = { }; function PlaceholderUnknownCriterion( - props: SearchCriterionProps + props: SearchCriteriaRenderProps ) { const {pattern, controls} = props; const t = useTranslation(); @@ -417,7 +417,7 @@ export const SearchCriterionAnyOf: SearchCriteriaOption = { }; function AnyAllOfCriterion

( - props: SearchCriterionProps

+ props: SearchCriteriaRenderProps

) { const {pattern, setPattern, inEditMode, parent} = props; const t = useTranslation(); @@ -519,7 +519,7 @@ export const SearchCriterionHasType: SearchCriteriaOption }; function HasTypeCriterion( - props: SearchCriterionProps + props: SearchCriteriaRenderProps ) { const {pattern, setPattern, inEditMode, controls} = props; const t = useTranslation(); @@ -581,7 +581,7 @@ export const SearchCriterionConnectedTo: SearchCriteriaOption + props: SearchCriteriaRenderProps ) { const {pattern, setPattern, inEditMode, controls} = props; const t = useTranslation(); @@ -708,7 +708,7 @@ export const SearchCriterionFromValues: SearchCriteriaOption + props: SearchCriteriaRenderProps ) { const {pattern, setPattern, inEditMode, controls} = props; const t = useTranslation(); @@ -777,7 +777,7 @@ function FromValuesList(props: { } function FromValuesSelector(props: Pick< - SearchCriterionProps, + SearchCriteriaRenderProps, 'pattern' | 'setPattern' >) { const {pattern, setPattern} = props; @@ -817,7 +817,7 @@ export const SearchCriterionHasProperty: SearchCriteriaOption + props: SearchCriteriaRenderProps ) { const {pattern, setPattern, inEditMode, controls} = props; const t = useTranslation(); diff --git a/src/workspace.ts b/src/workspace.ts index dedab18c..712bf529 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -275,9 +275,8 @@ export { SearchPatternConnectedTo, SearchPatternFromValues, SearchPatternHasProperty, - type SearchCriterionOption, type SearchCriterionProps, - SearchCriterionBlock, SearchCriterionWhereGroup, SearchCriterionWhere, - DefaultSearchCriterionOptions, + type SearchCriteriaOption, type SearchCriteriaRenderProps, + SearchCriterion, SearchCriterionBlock, SearchCriterionWhereGroup, SearchCriterionWhere, SearchCriterionAllOf, SearchCriterionAnyOf, SearchCriterionHasType, From ebb978853c4ff9d9962af679580c5de32f667c0b Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Fri, 13 Nov 2026 22:38:59 +0300 Subject: [PATCH 6/9] WIP Finalize JSDoc --- src/data/composite/composite.ts | 18 ------- src/data/dataProvider.ts | 10 ---- src/data/decorated/decoratedDataProvider.ts | 6 +-- src/data/decorated/emptyDataProvider.ts | 11 ++-- src/data/indexedDb/indexedDbCachedProvider.ts | 10 ++-- src/data/rdf/rdfDataProvider.ts | 6 +-- src/data/sparql/sparqlDataProvider.ts | 12 ++--- src/data/sparql/sparqlDataProviderSettings.ts | 21 ++++---- src/data/sparql/sparqlGenerator.ts | 52 +++++++------------ 9 files changed, 40 insertions(+), 106 deletions(-) diff --git a/src/data/composite/composite.ts b/src/data/composite/composite.ts index f1e8cd30..6a9a0cb6 100644 --- a/src/data/composite/composite.ts +++ b/src/data/composite/composite.ts @@ -1,7 +1,6 @@ import * as Rdf from '../rdf/rdfModel'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, - DataProviderPatternSupport, LookupPattern, } from '../dataProvider'; import { ElementTypeModel, ElementTypeGraph, LinkTypeModel, ElementModel, LinkModel, PropertyTypeModel, @@ -193,23 +192,6 @@ export class CompositeDataProvider implements DataProvider { return this.requestWithMerge(p => p.connectedLinkStats(params), mergeConnectedLinkStats); } - supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { - if (this.providers.length === 0) { - return {ok: true, errors: []}; - } - const errors: string[] = []; - for (const {provider} of this.providers) { - const result = provider.supportsPattern(pattern); - if (result.ok) { - return {ok: true, errors: []}; - } - for (const error of result.errors) { - errors.push(error); - } - } - return {ok: false, errors}; - } - lookup(params: DataProviderLookupParams): Promise { return this.requestWithMerge( p => p.lookup(params), diff --git a/src/data/dataProvider.ts b/src/data/dataProvider.ts index ab0fe5cc..26f4db02 100644 --- a/src/data/dataProvider.ts +++ b/src/data/dataProvider.ts @@ -198,11 +198,6 @@ export interface DataProvider { signal?: AbortSignal; }): Promise; - /** - * TODO - */ - supportsPattern(pattern: LookupPattern): DataProviderPatternSupport; - /** * Finds entities in a data graph by specified lookup filters. * @@ -239,11 +234,6 @@ export interface DataProviderLinkCount { readonly inexact?: boolean; } -export interface DataProviderPatternSupport { - readonly ok: boolean; - readonly errors: readonly string[]; -} - /** * Parameters for {@link DataProvider.lookup} operation. * diff --git a/src/data/decorated/decoratedDataProvider.ts b/src/data/decorated/decoratedDataProvider.ts index 302c12c2..4dd6b20c 100644 --- a/src/data/decorated/decoratedDataProvider.ts +++ b/src/data/decorated/decoratedDataProvider.ts @@ -3,7 +3,7 @@ import { delay } from '../../coreUtils/async'; import type * as Rdf from '../rdf/rdfModel'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, - DataProviderPatternSupport, LookupPattern, + LookupPattern, } from '../dataProvider'; import { ElementTypeModel, ElementTypeGraph, LinkTypeModel, ElementModel, LinkModel, @@ -166,10 +166,6 @@ export class DecoratedDataProvider implements DataProvider { return this.decorate('connectedLinkStats', [params]); } - supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { - return this.baseProvider.supportsPattern(pattern); - } - lookup(params: DataProviderLookupParams): Promise { return this.decorate('lookup', [params]); } diff --git a/src/data/decorated/emptyDataProvider.ts b/src/data/decorated/emptyDataProvider.ts index f1db752b..03235540 100644 --- a/src/data/decorated/emptyDataProvider.ts +++ b/src/data/decorated/emptyDataProvider.ts @@ -1,10 +1,9 @@ import { - ElementTypeGraph, LinkTypeModel, ElementTypeIri, ElementTypeModel, PropertyTypeIri, PropertyTypeModel, - LinkTypeIri, ElementIri, ElementModel, LinkModel, + ElementTypeGraph, LinkTypeModel, ElementTypeIri, ElementTypeModel, PropertyTypeIri, + PropertyTypeModel, LinkTypeIri, ElementIri, ElementModel, LinkModel, } from '../model'; import { - DataProvider, DataProviderLinkCount, DataProviderPatternSupport, - DataProviderLookupParams, DataProviderLookupItem, LookupPattern, + DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, } from '../dataProvider'; import { DataFactory, DefaultDataFactory } from '../rdf/rdfModel'; @@ -102,10 +101,6 @@ export class EmptyDataProvider implements DataProvider { return Promise.resolve([]); } - supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { - return {ok: false, errors: []}; - } - lookup(params: DataProviderLookupParams): Promise { return Promise.resolve([]); } diff --git a/src/data/indexedDb/indexedDbCachedProvider.ts b/src/data/indexedDb/indexedDbCachedProvider.ts index 17a17642..8873969e 100644 --- a/src/data/indexedDb/indexedDbCachedProvider.ts +++ b/src/data/indexedDb/indexedDbCachedProvider.ts @@ -7,10 +7,12 @@ import { } from '../model'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, - DataProviderPatternSupport, LookupPattern, lookupPatternOf, + LookupPattern, lookupPatternOf, } from '../dataProvider'; -import { AdjacencyRange, AdjacencyBlock, subtractAdjacencyBlocks, hashAdjacencyRange } from './adjacencyBlocks'; +import { + AdjacencyRange, AdjacencyBlock, subtractAdjacencyBlocks, hashAdjacencyRange, +} from './adjacencyBlocks'; import { Sha256 } from './sha256'; /** @@ -833,10 +835,6 @@ export class IndexedDbCachedProvider implements DataProvider { return result.stats; } - supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { - return this.baseProvider.supportsPattern(pattern); - } - async lookup(params: DataProviderLookupParams): Promise { const fullPattern = lookupPatternOf(params); if (!this.cacheTextLookups && hasTextPatternOrFilter(fullPattern)) { diff --git a/src/data/rdf/rdfDataProvider.ts b/src/data/rdf/rdfDataProvider.ts index 7fb53fed..a45a5024 100644 --- a/src/data/rdf/rdfDataProvider.ts +++ b/src/data/rdf/rdfDataProvider.ts @@ -7,7 +7,7 @@ import { } from '../model'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, - DataProviderPatternSupport, LookupPattern, lookupPatternOf, + lookupPatternOf, } from '../dataProvider'; import { makeCaseInsensitiveFilter } from '../utils'; @@ -632,10 +632,6 @@ export class RdfDataProvider implements DataProvider { return Promise.resolve(counts); } - supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { - return {ok: true, errors: []}; - } - lookup(params: DataProviderLookupParams): Promise { const fullPattern = lookupPatternOf(params); const items = matchPattern(fullPattern, { diff --git a/src/data/sparql/sparqlDataProvider.ts b/src/data/sparql/sparqlDataProvider.ts index fe479c9f..2f490df3 100644 --- a/src/data/sparql/sparqlDataProvider.ts +++ b/src/data/sparql/sparqlDataProvider.ts @@ -6,7 +6,6 @@ import { } from '../model'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, - DataProviderPatternSupport, LookupPattern, } from '../dataProvider'; import { chunkArray, chunkUndirectedCrossProduct } from './requestChunking'; @@ -178,12 +177,13 @@ export interface SparqlProviderChunkOptions { */ export class SparqlDataProvider implements DataProvider { readonly factory: Rdf.DataFactory; + private readonly source: SparqlQuerySource; private readonly options: SparqlDataProviderOptions; private readonly settings: SparqlDataProviderSettings; private readonly acceptBlankNodes = false; private readonly chunkMeasure: ChunkMeasure; - + private readonly generator: SparqlGenerator; private readonly labelPredicate: PropertyTypeIri; private readonly imagePredicate: PropertyTypeIri; @@ -498,7 +498,7 @@ export class SparqlDataProvider implements DataProvider { if (elementIds.length > 0) { await this.queryChunked(elementIds, async batch => { const ids = batch.map(escapeIri).map(id => ` (${id})`).join(' '); - const {defaultPrefix, dataLabelProperty, filterOnlyLanguages, elementInfoQuery} = this.settings; + const {defaultPrefix, dataLabelProperty, elementInfoQuery} = this.settings; const query = defaultPrefix + resolveTemplate(elementInfoQuery, { ids, dataLabelProperty, @@ -768,10 +768,6 @@ export class SparqlDataProvider implements DataProvider { return foundLinkStats; } - supportsPattern(pattern: LookupPattern): DataProviderPatternSupport { - return {ok: false, errors: []}; - } - async lookup(baseParams: DataProviderLookupParams): Promise { const {signal} = baseParams; const params: DataProviderLookupParams = { @@ -786,7 +782,7 @@ export class SparqlDataProvider implements DataProvider { signal ); - const filterQuery = this.generator.generateFilterQuery(params); + const filterQuery = this.generator.generateLookupQuery(params); const response = await this.executeSparqlSelect( filterQuery, {signal} diff --git a/src/data/sparql/sparqlDataProviderSettings.ts b/src/data/sparql/sparqlDataProviderSettings.ts index 9ea503ce..999dd6a7 100644 --- a/src/data/sparql/sparqlDataProviderSettings.ts +++ b/src/data/sparql/sparqlDataProviderSettings.ts @@ -265,7 +265,7 @@ export interface SparqlDataProviderSettings { * - `${outerProjection}` * - `${innerProjection}` * - `${filterInnerPrelude}` from {@link filterInnerPrelude} - * - `${filterByPattern}` + * - `${filterByPattern}` generated from {@link DataProviderLookupParams.pattern} * - `${filterAdditionalRestriction}` from {@link filterAdditionalRestriction} * - `${orderBy}` (when {@link DataProviderLookupParams.text} is provided) * - `${limit}` (when {@link DataProviderLookupParams.limit} is provided) @@ -274,14 +274,14 @@ export interface SparqlDataProviderSettings { * {@link linkConfigurations} is non-empty) * - `${queryElementInfo}` from {@link filterElementInfoPattern} * - * For compatibility, the following deprecated variables are parametrized - * in case `${filterByPattern}` is not found in the query: - * - `${filterByType}` from {@link filterTypePattern} - * (when {@link DataProviderLookupParams.elementTypeId} is provided) - * - `${filterByRefElementLink}` from {@link filterRefElementLinkPattern} - * (when {@link DataProviderLookupParams.refElementId} is provided) - * - `${filterByText}` from {@link fullTextSearch} - * (when {@link DataProviderLookupParams.text} is provided) + * If `${filterByPattern}` not found in the query, the generated pattern + * is prepended to `${filterAdditionalRestriction}` instead. + * + * For compatibility, the following deprecated variables are replaced by + * an empty pattern: + * - `${filterByType}` + * - `${filterByRefElementLink}` + * - `${filterByText}` * * Expected bindings: * - `?inst` element IRI @@ -577,9 +577,6 @@ export const RdfSettings: SparqlDataProviderSettings = { \${filterInnerPrelude} { SELECT DISTINCT \${innerProjection} WHERE { - \${filterByType} - \${filterByRefElementLink} - \${filterByText} \${filterByPattern} \${filterAdditionalRestriction} } \${orderBy} \${limit} diff --git a/src/data/sparql/sparqlGenerator.ts b/src/data/sparql/sparqlGenerator.ts index 496c6cb3..68935b6d 100644 --- a/src/data/sparql/sparqlGenerator.ts +++ b/src/data/sparql/sparqlGenerator.ts @@ -179,9 +179,14 @@ export class SparqlGenerator { return {navigateElementFilterIn, navigateElementFilterOut}; } - generateFilterQuery(params: DataProviderLookupParams): string { + supportsLookupPatterns(): boolean { + const {lookupQuery} = this.settings; + return /\${filterByPattern}/.test(lookupQuery); + } + + generateLookupQuery(params: DataProviderLookupParams): string { const { - defaultPrefix, dataLabelProperty, lookupQuery, + defaultPrefix, dataLabelProperty, lookupQuery, filterAdditionalRestriction, filterOnlyLanguages, filterElementInfoPattern, fullTextSearch, } = this.settings; @@ -194,11 +199,6 @@ export class SparqlGenerator { const valueSets = new Map>(); const projections = new Set<'link' | 'direction' | 'score'>(); - let filterByType = ''; - let filterByRefElementLink = ''; - let filterByText = ''; - let filterByPattern = ''; - const generateParams = { entityTerm, path: '', @@ -207,28 +207,8 @@ export class SparqlGenerator { withProjection: true, } as const; - const supportFilterByPattern = /\${filterByPattern}/.test(lookupQuery); - if (supportFilterByPattern) { - filterByPattern = this.generatePattern(pattern, generateParams); - } else { - const hasType = findPattern(pattern, p => p.type === 'hasType'); - if (hasType) { - filterByType = this.generatePattern(hasType, generateParams); - } - - const connectedTo = findPattern( - pattern, - p => p.type === 'connectedTo' - ); - if (connectedTo) { - filterByRefElementLink = this.generatePattern(connectedTo, generateParams); - } - - const withText = findPattern(pattern, p => p.type === 'withText'); - if (withText) { - filterByText = this.generatePattern(withText, generateParams); - } - } + const generatedPattern = this.generatePattern(pattern, generateParams); + const hasFilterByPattern = /\${filterByPattern}/.test(lookupQuery); let prependedValueSets = ''; for (const [variableName, setTerms] of valueSets) { @@ -264,16 +244,20 @@ export class SparqlGenerator { const limitPart = formatLimit(params.limit); + const filterByPattern = prependedValueSets + generatedPattern; + const completePrefix = defaultPrefix + '\n' + fullTextSearch.prefix + '\n'; return completePrefix + resolveTemplate(lookupQuery, { innerProjection, outerProjection, filterInnerPrelude: this.settings.filterInnerPrelude, - filterByType, - filterByRefElementLink, - filterByText, - filterByPattern: prependedValueSets + filterByPattern, - filterAdditionalRestriction: this.settings.filterAdditionalRestriction, + filterByType: '', + filterByRefElementLink: '', + filterByText: '', + filterByPattern, + filterAdditionalRestriction: hasFilterByPattern + ? filterAdditionalRestriction + : `${filterByPattern}\n${filterAdditionalRestriction}`, orderBy, limit: limitPart, queryTypes, From f71a8aea700c03d05637469c19179efd7714024f Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Fri, 13 Nov 2026 23:31:22 +0300 Subject: [PATCH 7/9] WIP Change link direction selector to a button group --- i18n/i18n.schema.json | 3 + .../en.reactodia-translation.json | 7 +- .../instancesSearch/schemaTypeSelect.tsx | 23 +----- .../instancesSearch/searchCriterion.tsx | 73 +++++++++++++++++-- styles/shared/_btnGroup.scss | 5 ++ styles/widgets/_searchCriterion.scss | 18 +++++ 6 files changed, 99 insertions(+), 30 deletions(-) diff --git a/i18n/i18n.schema.json b/i18n/i18n.schema.json index 7064d5bd..ab0e1f36 100644 --- a/i18n/i18n.schema.json +++ b/i18n/i18n.schema.json @@ -297,6 +297,9 @@ "criteria_has_type.label": { "$ref": "#/$defs/Value" }, "criteria_connected": { "$ref": "#/$defs/Value" }, "criteria_connected.any-link": { "$ref": "#/$defs/Value" }, + "criteria_connected.direction_any.title": { "$ref": "#/$defs/Value" }, + "criteria_connected.direction_in.title": { "$ref": "#/$defs/Value" }, + "criteria_connected.direction_out.title": { "$ref": "#/$defs/Value" }, "criteria_connected.header": { "$ref": "#/$defs/Value" }, "criteria_connected.label": { "$ref": "#/$defs/Value" }, "criteria_connected.target-part": { "$ref": "#/$defs/Value" }, diff --git a/i18n/translations/en.reactodia-translation.json b/i18n/translations/en.reactodia-translation.json index 68bbbddb..b1d805b5 100644 --- a/i18n/translations/en.reactodia-translation.json +++ b/i18n/translations/en.reactodia-translation.json @@ -214,12 +214,15 @@ "criteria_has_type.label": "has type", "criteria_connected": "Connected to {{entity}}", "criteria_connected.any-link": "any link", + "criteria_connected.direction_any.title": "Connected to source or target", + "criteria_connected.direction_in.title": "Connected to target", + "criteria_connected.direction_out.title": "Connected to source", "criteria_connected.header": "Connected to {{entity}} by", "criteria_connected.label": "connected to", "criteria_connected.target-part": "entity", "criteria_connected_via": "Connected by {{relationType}} to {{entity}}", - "criteria_connected_to_source": "Connected as {{sourceIcon}}\u00A0source by {{relationType}} to {{entity}}", - "criteria_connected_to_target": "Connected as {{targetIcon}}\u00A0target by {{relationType}} from {{entity}}", + "criteria_connected_to_source": "Connected by {{relationType}} to {{entity}} {{sourceIcon}}\u00A0target", + "criteria_connected_to_target": "Connected by {{relationType}} to {{entity}} {{targetIcon}}\u00A0source", "criteria_placeholder_any": "any entity", "criteria_placeholder_unknown": "Unknown pattern type", "criteria_where_caption": "where\u00A0{{part}}:", diff --git a/src/widgets/instancesSearch/schemaTypeSelect.tsx b/src/widgets/instancesSearch/schemaTypeSelect.tsx index 52044b8a..3ab23253 100644 --- a/src/widgets/instancesSearch/schemaTypeSelect.tsx +++ b/src/widgets/instancesSearch/schemaTypeSelect.tsx @@ -6,7 +6,7 @@ import { Translation, useTranslation } from '../../coreUtils/i18n'; import { KeyedSyncStore, useKeyedSyncStore } from '../../coreUtils/keyedObserver'; import { - ElementTypeIri, PropertyTypeIri, LinkTypeIri, LinkDirection, + ElementTypeIri, PropertyTypeIri, LinkTypeIri, } from '../../data/model'; import * as Rdf from '../../data/rdf/rdfModel'; import { DataDiagramModel } from '../../editor/dataDiagramModel'; @@ -264,24 +264,3 @@ export function useSchemaType( useKeyedSyncStore(subscribeTypes, typeId ? [typeId] : [], model); return typeData as SchemaType[Kind] | undefined; } - -export function LinkDirectionSelect(props: { - selected: LinkDirection | undefined; - setSelected: (direction: LinkDirection | undefined) => void; -}) { - const {selected, setSelected} = props; - return ( - - ); -} diff --git a/src/widgets/instancesSearch/searchCriterion.tsx b/src/widgets/instancesSearch/searchCriterion.tsx index 3fa69083..1f73c0e4 100644 --- a/src/widgets/instancesSearch/searchCriterion.tsx +++ b/src/widgets/instancesSearch/searchCriterion.tsx @@ -2,7 +2,7 @@ import cx from 'clsx'; import * as React from 'react'; import { TranslatedText, useTranslation } from '../../coreUtils/i18n'; -import { ElementIri } from '../../data/model'; +import { ElementIri, LinkDirection } from '../../data/model'; import { EntityElement } from '../../editor/dataElements'; import { useProvidedEntities } from '../../editor/useProvidedEntities'; import { useWorkspace } from '../../workspace/workspaceContext'; @@ -12,7 +12,7 @@ import { InlineEntity } from '../utility/inlineEntity'; import { SchemaTypeView } from '../utility/schemaTypeView'; import { EntitySelect } from './entitySelect'; -import { SchemaTypeSelect, LinkDirectionSelect, useSchemaType } from './schemaTypeSelect'; +import { SchemaTypeSelect, useSchemaType } from './schemaTypeSelect'; import { SearchPattern, SearchPatternType, SearchPatternErrorItem, SerializedSearchPattern, SearchPatternError, SearchPatternAllOf, SearchPatternAnyOf, SearchPatternConnectedTo, @@ -614,13 +614,13 @@ function ConnectedToCriterion( inline /> ) : ( -

+ {t.text('search_entities.criteria_connected.any-link')} -
+ ); - const sourceIcon = ; - const targetIcon = ; + const sourceIcon = ; + const targetIcon = ; header = ( @@ -697,6 +697,67 @@ function ConnectedToCriterion( ); } +export function LinkDirectionSelect(props: { + selected: LinkDirection | undefined; + setSelected: (direction: LinkDirection | undefined) => void; +}) { + const {selected, setSelected} = props; + const t = useTranslation(); + return ( +
+
+ ); +} + /** * Search criteria option corresponding to {@link LookupPatternFromValues}. */ diff --git a/styles/shared/_btnGroup.scss b/styles/shared/_btnGroup.scss index fbedafaf..f9f66fc2 100644 --- a/styles/shared/_btnGroup.scss +++ b/styles/shared/_btnGroup.scss @@ -36,6 +36,11 @@ border-bottom-right-radius: theme.$button-border-radius; } + &-s > .reactodia-btn { + padding: 2px 4px; + height: 25.6667px; + } + &-sm > .reactodia-btn { font-size: 12px; line-height: 1.5; diff --git a/styles/widgets/_searchCriterion.scss b/styles/widgets/_searchCriterion.scss index e3a04124..cf5ebad7 100644 --- a/styles/widgets/_searchCriterion.scss +++ b/styles/widgets/_searchCriterion.scss @@ -104,6 +104,24 @@ gap: 4px; } + &__direction-any { + @include codicon-button("arrow-both"); + } + + &__direction-in, + &__direction-out { + display: flex; + align-items: center; + } + + &__direction-in::before { + @include icon("@images/direction-in.svg"); + } + + &__direction-out::before { + @include icon("@images/direction-out.svg"); + } + &__link-in, &__link-out { display: inline-block; From 3758e6cd9f7bdd2df651f5015c24ae6e66495df6 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Sat, 14 Nov 2026 02:02:02 +0300 Subject: [PATCH 8/9] WIP Add unit tests for `lookup()` in `SparqlDataProvider`, tests for `links()` and `lookup()` in `RdfDataProvider` --- src/data/sparql/responseHandler.ts | 2 +- src/data/sparql/sparqlGenerator.ts | 2 +- test/data/orgOntologyCases.ts | 241 ++++++++++++++++++ test/data/rdf/rdfDataProvider.test.ts | 47 ++++ test/data/sparql/sparqlProviderBasic.test.ts | 121 ++++++--- .../data/sparql/sparqlProviderOptions.test.ts | 3 +- test/mock/sparqlMocks.ts | 21 -- test/utilities/dataCompare.tsx | 12 + 8 files changed, 391 insertions(+), 58 deletions(-) create mode 100644 test/data/orgOntologyCases.ts create mode 100644 test/data/rdf/rdfDataProvider.test.ts diff --git a/src/data/sparql/responseHandler.ts b/src/data/sparql/responseHandler.ts index 82692fb6..b72e7c5d 100644 --- a/src/data/sparql/responseHandler.ts +++ b/src/data/sparql/responseHandler.ts @@ -459,7 +459,7 @@ export function getFilteredData( multimapAdd(resultTypes, iri, binding.classAll.value); } - if (!openWorldLinks && binding.link && binding.direction) { + if (binding.link && binding.direction) { const predicates = ( binding.direction.value === 'in' ? inPredicates : binding.direction.value === 'out' ? outPredicates : diff --git a/src/data/sparql/sparqlGenerator.ts b/src/data/sparql/sparqlGenerator.ts index 68935b6d..1e276eff 100644 --- a/src/data/sparql/sparqlGenerator.ts +++ b/src/data/sparql/sparqlGenerator.ts @@ -385,7 +385,7 @@ export class SparqlGenerator { ); if (pattern.values && pattern.values.length > 0) { const valueSet = new HashSet(Rdf.hashTerm, Rdf.equalTerms, pattern.values); - valueSets.set(entityTerm.value, valueSet); + valueSets.set(valueTerm.value, valueSet); } return parts.join('\n'); } diff --git a/test/data/orgOntologyCases.ts b/test/data/orgOntologyCases.ts new file mode 100644 index 00000000..9dcea0ed --- /dev/null +++ b/test/data/orgOntologyCases.ts @@ -0,0 +1,241 @@ +import type { DataProvider } from '../../src/data/dataProvider'; +import type { LinkModel } from '../../src/data/model'; +import * as Rdf from '../../src/data/rdf/rdfModel'; +import { owl as owlBase, rdfs as rdfsBase } from '../../src/data/rdf/vocabulary'; + +const NAMESPACE_ORG = 'http://www.w3.org/ns/org#'; +export const org = { + $namespace: NAMESPACE_ORG, + ChangeEvent: `${NAMESPACE_ORG}ChangeEvent`, + FormalOrganization: `${NAMESPACE_ORG}FormalOrganization`, + Organization: `${NAMESPACE_ORG}Organization`, + OrganizationalUnit: `${NAMESPACE_ORG}OrganizationalUnit`, + Membership: `${NAMESPACE_ORG}Membership`, + Role: `${NAMESPACE_ORG}Role`, + Site: `${NAMESPACE_ORG}Site`, + identifier: `${NAMESPACE_ORG}identifier`, + hasMembership: `${NAMESPACE_ORG}hasMembership`, + hasUnit: `${NAMESPACE_ORG}hasUnit`, + location: `${NAMESPACE_ORG}location`, + member: `${NAMESPACE_ORG}member`, + memberDuring: `${NAMESPACE_ORG}memberDuring`, + memberOf: `${NAMESPACE_ORG}memberOf`, + organization: `${NAMESPACE_ORG}organization`, + role: `${NAMESPACE_ORG}role`, + roleProperty: `${NAMESPACE_ORG}roleProperty`, + subOrganizationOf: `${NAMESPACE_ORG}subOrganizationOf`, + transitiveSubOrganizationOf: `${NAMESPACE_ORG}transitiveSubOrganizationOf`, +} as const; + +export const owl = { + ...owlBase, + AnnotationProperty: `${owlBase.$namespace}AnnotationProperty`, + FunctionalProperty: `${owlBase.$namespace}FunctionalProperty`, + TransitiveProperty: `${owlBase.$namespace}TransitiveProperty`, + Thing: `${owlBase.$namespace}Thing`, + disjointWith: `${owlBase.$namespace}disjointWith`, +}; + +export const rdfs = { + ...rdfsBase, + comment: `${rdfsBase.$namespace}comment`, + domain: `${rdfsBase.$namespace}domain`, + isDefinedBy: `${rdfsBase.$namespace}isDefinedBy`, + range: `${rdfsBase.$namespace}range`, +}; + +export type LinksTestCase = readonly [ + name: string, + params: Parameters[0], + expectedLinks: readonly LinkModel[], +]; + +export const linksOrgOntologyCases: readonly LinksTestCase[] = [ + [ + 'disjoint primary and secondary', + { + primary: [org.Organization, org.OrganizationalUnit, org.hasUnit], + secondary: [org.FormalOrganization], + }, + [ + { + sourceId: org.FormalOrganization, + linkTypeId: rdfs.subClassOf, + targetId: org.Organization, + properties: {}, + }, + { + sourceId: org.hasUnit, + linkTypeId: rdfs.domain, + targetId: org.FormalOrganization, + properties: {}, + }, + ], + ] +]; + +export type LookupTestCase = [ + name: string, + params: Parameters[0], + expectedIds: readonly string[], +]; + +export const lookupBasicOrgOntologyCases: readonly LookupTestCase[] = [ + [ + 'type', + {elementTypeId: owl.AnnotationProperty}, + [org.roleProperty], + ], + [ + 'type and text', + {elementTypeId: owl.DatatypeProperty, text: 'ident'}, + [org.identifier], + ], + [ + 'text', + {text: 'rol'}, + [org.Role, org.role, org.roleProperty], + ], + [ + 'connected by entity and link type', + { + refElementId: org.Role, + refElementLinkId: owl.disjointWith, + }, + [org.ChangeEvent, org.Membership, org.Organization, org.Site], + ], + [ + 'connected by entity, link type and direction', + { + refElementId: org.Role, + refElementLinkId: owl.disjointWith, + linkDirection: 'in', + }, + [org.Organization], + ], + [ + 'connected by link type and direction', + { + refElementLinkId: rdfs.isDefinedBy, + linkDirection: 'out', + }, + ['http://www.w3.org/ns/org'], + ], +]; + +export const lookupPatternOrgOntologyCases: readonly LookupTestCase[] = [ + [ + '(has-type) and (from-values)', + { + pattern: { + type: 'allOf', + patterns: [ + { + type: 'hasType', + elementType: owl.DatatypeProperty, + }, + { + type: 'fromValues', + values: [org.location, org.identifier, org.organization], + }, + ], + }, + }, + [org.identifier, org.location], + ], + [ + '(from-values) - empty', + { + pattern: { + type: 'fromValues', + values: [], + }, + }, + [], + ], + [ + '(has-type) and (connected-to (from-values)) and (with-text)', + { + pattern: { + type: 'allOf', + patterns: [ + { + type: 'hasType', + elementType: owl.Class, + }, + { + type: 'connectedTo', + target: { + type: 'fromValues', + values: [org.Role], + }, + }, + { + type: 'withText', + text: 'event', + }, + ], + }, + }, + [org.ChangeEvent], + ], + [ + '(has-property) and ((has-type) or (has-type))', + { + pattern: { + type: 'allOf', + patterns: [ + { + type: 'hasProperty', + propertyType: rdfs.comment, + }, + { + type: 'anyOf', + patterns: [ + { + type: 'hasType', + elementType: owl.FunctionalProperty, + }, + { + type: 'hasType', + elementType: owl.TransitiveProperty, + }, + ], + }, + ], + }, + }, + [org.member, org.organization, org.transitiveSubOrganizationOf], + ], + [ + '(connected-to (connected-to (from-values)))', + { + pattern: { + type: 'connectedTo', + linkType: rdfs.domain, + linkDirection: 'in', + target: { + type: 'connectedTo', + linkType: rdfs.range, + linkDirection: 'out', + target: { + type: 'fromValues', + values: [org.hasMembership], + }, + }, + }, + }, + [org.member, org.memberDuring, org.organization], + ], + [ + '(has-property)', + { + pattern: { + type: 'hasProperty', + propertyType: rdfs.label, + values: [Rdf.DefaultDataFactory.literal('Formal Organization', 'en')], + } + }, + [org.FormalOrganization], + ], +]; diff --git a/test/data/rdf/rdfDataProvider.test.ts b/test/data/rdf/rdfDataProvider.test.ts new file mode 100644 index 00000000..8e450a2d --- /dev/null +++ b/test/data/rdf/rdfDataProvider.test.ts @@ -0,0 +1,47 @@ +import * as N3 from 'n3'; +import { describe, expect, it } from 'vitest'; + +import { RdfDataProvider } from '../../../src/data/rdf/rdfDataProvider'; + +import { + linksOrgOntologyCases, lookupBasicOrgOntologyCases, lookupPatternOrgOntologyCases, +} from '../orgOntologyCases'; +import { compareLinks, compareLookupItemsByIri } from '../../utilities/dataCompare'; + +import TURTLE_DATA from '../../../examples/resources/orgOntology.ttl?raw'; + +describe('RdfDataProvider', () => { + it.each( + linksOrgOntologyCases + )('provides links() by "%s"', async (name, params, expectedLinks) => { + const provider = makeRdfDataProvider(); + const links = await provider.links(params); + links.sort(compareLinks); + expect(links).toEqual(expectedLinks); + }); + + it.each( + lookupBasicOrgOntologyCases + )('provides lookup() by "%s"', async (name, params, expectedIds) => { + const provider = makeRdfDataProvider(); + const items = await provider.lookup(params); + items.sort(compareLookupItemsByIri); + expect(items.map(item => item.element.id)).toEqual(expectedIds); + }); + + it.each( + lookupPatternOrgOntologyCases + )('provides lookup() by pattern "%s"', async (name, params, expectedIds) => { + const provider = makeRdfDataProvider(); + const items = await provider.lookup(params); + items.sort(compareLookupItemsByIri); + expect(items.map(item => item.element.id)).toEqual(expectedIds); + }); +}); + +function makeRdfDataProvider() { + const quads = new N3.Parser().parse(TURTLE_DATA); + const provider = new RdfDataProvider(); + provider.addGraph(quads); + return provider; +} diff --git a/test/data/sparql/sparqlProviderBasic.test.ts b/test/data/sparql/sparqlProviderBasic.test.ts index 2759a2d3..578240b8 100644 --- a/test/data/sparql/sparqlProviderBasic.test.ts +++ b/test/data/sparql/sparqlProviderBasic.test.ts @@ -2,18 +2,24 @@ import { describe, expect, it } from 'vitest'; import type { ElementIri, ElementModel, ElementTypeIri, ElementTypeModel, LinkTypeIri, LinkTypeModel, - LinkModel, PropertyTypeIri, PropertyTypeModel, + PropertyTypeIri, PropertyTypeModel, } from '../../../src/data/model'; -import type { DataProviderLinkCount, DataProviderLookupItem } from '../../../src/data/dataProvider'; +import type { + DataProviderLinkCount, DataProviderLookupItem, +} from '../../../src/data/dataProvider'; import { type MemoryDataset } from '../../../src/data/rdf/memoryDataset'; import * as Rdf from '../../../src/data/rdf/rdfModel'; -import { rdf, owl } from '../../../src/data/rdf/vocabulary'; +import { rdf } from '../../../src/data/rdf/vocabulary'; import { OwlStatsSettings, } from '../../../src/data/sparql/sparqlDataProviderSettings'; -import { makeSparqlDataProvider, makeSparqlDataset, org, rdfs } from '../../mock/sparqlMocks'; -import { compareLinks } from '../../utilities/dataCompare'; +import { + linksOrgOntologyCases, lookupBasicOrgOntologyCases, lookupPatternOrgOntologyCases, + org, owl, rdfs, +} from '../orgOntologyCases'; +import { makeSparqlDataProvider, makeSparqlDataset } from '../../mock/sparqlMocks'; +import { compareLinks, compareLookupItemsByIri } from '../../utilities/dataCompare'; describe('SparqlDataProvider', () => { it('provides knownElementTypes()', async () => { @@ -215,38 +221,19 @@ describe('SparqlDataProvider', () => { ); }); - it('provides links()', async () => { + it.each( + linksOrgOntologyCases + )('provides links() by "%s"', async (name, params, expectedLinks) => { const provider = await makeSparqlDataProvider( {}, {...OwlStatsSettings, filterOnlyLanguages: ['en']}, ); - const elementIds: readonly ElementIri[] = [ - org.FormalOrganization, org.memberOf, org.location - ]; - const links = await provider.links({ - primary: [org.Organization, org.OrganizationalUnit, org.hasUnit], - secondary: [org.FormalOrganization], - }); + const links = await provider.links(params); links.sort(compareLinks); - expect(links).toEqual( - [ - { - sourceId: org.FormalOrganization, - linkTypeId: rdfs.subClassOf, - targetId: org.Organization, - properties: {}, - }, - { - sourceId: org.hasUnit, - linkTypeId: rdfs.domain, - targetId: org.FormalOrganization, - properties: {}, - }, - ] satisfies LinkModel[] - ); + expect(links).toEqual(expectedLinks); }); - it('provides lookup()', async () => { + it('provides lookup() by type (with links)', async () => { const provider = await makeSparqlDataProvider( {}, {...OwlStatsSettings, filterOnlyLanguages: ['en']}, @@ -282,16 +269,82 @@ describe('SparqlDataProvider', () => { ); }); - it('provides lookup() by type and text', async () => { + it('provides lookup() by connected to (with links)', async () => { const provider = await makeSparqlDataProvider( {}, {...OwlStatsSettings, filterOnlyLanguages: ['en']}, ); const items = await provider.lookup({ - elementTypeId: owl.DatatypeProperty, - text: 'ident', + refElementId: org.roleProperty, }); - expect(items.map(item => item.element.id)).toEqual([org.identifier]); + items.sort(compareLookupItemsByIri); + expect(items).toEqual( + [ + { + element: { + id: rdf.Property, + types: [owl.Thing], + properties: {} + }, + inLinks: new Set(), + outLinks: new Set([rdf.type, rdfs.range]), + }, + { + element: { + id: owl.AnnotationProperty, + types: [owl.Thing], + properties: {} + }, + inLinks: new Set(), + outLinks: new Set([rdf.type]), + }, + { + element: { + id: 'http://www.w3.org/ns/org', + types: [owl.Thing], + properties: {}, + }, + inLinks: new Set(), + outLinks: new Set([rdfs.isDefinedBy]), + }, + { + element: { + id: org.Role, + types: [rdfs.Class, owl.Class], + properties: { + [rdfs.label]: [provider.factory.literal('Role', 'en')], + } + }, + inLinks: new Set(), + outLinks: new Set([rdfs.domain]), + }, + + ] satisfies DataProviderLookupItem[] + ); + }); + + it.each( + lookupBasicOrgOntologyCases + )('provides lookup() by "%s"', async (name, params, expectedIds) => { + const provider = await makeSparqlDataProvider( + {}, + {...OwlStatsSettings, filterOnlyLanguages: ['en']}, + ); + const items = await provider.lookup(params); + items.sort(compareLookupItemsByIri); + expect(items.map(item => item.element.id)).toEqual(expectedIds); + }); + + it.each( + lookupPatternOrgOntologyCases + )('provides lookup() by "%s"', async (name, params, expectedIds) => { + const provider = await makeSparqlDataProvider( + {}, + {...OwlStatsSettings, filterOnlyLanguages: ['en']}, + ); + const items = await provider.lookup(params); + items.sort(compareLookupItemsByIri); + expect(items.map(item => item.element.id)).toEqual(expectedIds); }); }); diff --git a/test/data/sparql/sparqlProviderOptions.test.ts b/test/data/sparql/sparqlProviderOptions.test.ts index 4f914154..b8f97897 100644 --- a/test/data/sparql/sparqlProviderOptions.test.ts +++ b/test/data/sparql/sparqlProviderOptions.test.ts @@ -13,7 +13,8 @@ import { OwlStatsSettings, } from '../../../src/data/sparql/sparqlDataProviderSettings'; -import { makeSparqlDataProvider, makeSparqlDataset, org, rdfs, test } from '../../mock/sparqlMocks'; +import { org, rdfs } from '../orgOntologyCases'; +import { makeSparqlDataProvider, makeSparqlDataset, test } from '../../mock/sparqlMocks'; import { compareLinks } from '../../utilities/dataCompare'; describe('SparqlDataProvider', () => { diff --git a/test/mock/sparqlMocks.ts b/test/mock/sparqlMocks.ts index e81a5c34..7d97cdd4 100644 --- a/test/mock/sparqlMocks.ts +++ b/test/mock/sparqlMocks.ts @@ -3,7 +3,6 @@ import * as N3 from 'n3'; import { IndexQuadBy, type MemoryDataset, indexedDataset } from '../../src/data/rdf/memoryDataset'; import * as Rdf from '../../src/data/rdf/rdfModel'; -import { rdfs as rdfsBase } from '../../src/data/rdf/vocabulary'; import { SparqlDataProvider, type SparqlDataProviderOptions, } from '../../src/data/sparql/sparqlDataProvider'; @@ -16,26 +15,6 @@ import { import TURTLE_DATA from '../../examples/resources/orgOntology.ttl?raw'; -const NAMESPACE_ORG = 'http://www.w3.org/ns/org#'; -export const org = { - $namespace: NAMESPACE_ORG, - FormalOrganization: `${NAMESPACE_ORG}FormalOrganization`, - Organization: `${NAMESPACE_ORG}Organization`, - OrganizationalUnit: `${NAMESPACE_ORG}OrganizationalUnit`, - Role: `${NAMESPACE_ORG}Role`, - identifier: `${NAMESPACE_ORG}identifier`, - hasUnit: `${NAMESPACE_ORG}hasUnit`, - location: `${NAMESPACE_ORG}location`, - memberOf: `${NAMESPACE_ORG}memberOf`, - subOrganizationOf: `${NAMESPACE_ORG}subOrganizationOf`, -} as const; - -export const rdfs = { - ...rdfsBase, - comment: `${rdfsBase.$namespace}comment`, - domain: `${rdfsBase.$namespace}domain`, -}; - const NAMESPACE_TEST = 'urn:reactodia:test:'; export const test = { diff --git a/test/utilities/dataCompare.tsx b/test/utilities/dataCompare.tsx index bedeb1ba..eaa34e66 100644 --- a/test/utilities/dataCompare.tsx +++ b/test/utilities/dataCompare.tsx @@ -1,3 +1,4 @@ +import type { DataProviderLookupItem } from '../../src/data/dataProvider'; import type { LinkModel } from '../../src/data/model'; export function compareLinks(a: LinkModel, b: LinkModel): number { @@ -24,3 +25,14 @@ export function compareLinks(a: LinkModel, b: LinkModel): number { ); return result; } + +export function compareLookupItemsByIri( + a: DataProviderLookupItem, + b: DataProviderLookupItem +): number { + return ( + a.element.id < b.element.id ? -1 : + a.element.id > b.element.id ? 1 : + 0 + ); +} From a4edfc48a67dab47a478f418292756a5b35544d8 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Sun, 15 Nov 2026 02:32:39 +0300 Subject: [PATCH 9/9] WIP Rename `LookupPattern.target` -> `other` --- i18n/i18n.schema.json | 4 ++-- .../en.reactodia-translation.json | 4 ++-- src/data/dataProvider.ts | 18 +++++++-------- src/data/indexedDb/indexedDbCachedProvider.ts | 2 +- src/data/rdf/datasetLookup.ts | 4 ++-- src/data/sparql/sparqlGenerator.ts | 12 +++++----- .../instancesSearch/instanceCriteria.tsx | 2 +- .../instancesSearch/instancesSearch.tsx | 2 +- .../instancesSearch/searchCriterion.tsx | 18 +++++++-------- src/widgets/instancesSearch/searchPattern.ts | 22 +++++++++---------- test/data/orgOntologyCases.ts | 6 ++--- 11 files changed, 47 insertions(+), 47 deletions(-) diff --git a/i18n/i18n.schema.json b/i18n/i18n.schema.json index ab0e1f36..8b3c1eda 100644 --- a/i18n/i18n.schema.json +++ b/i18n/i18n.schema.json @@ -302,10 +302,10 @@ "criteria_connected.direction_out.title": { "$ref": "#/$defs/Value" }, "criteria_connected.header": { "$ref": "#/$defs/Value" }, "criteria_connected.label": { "$ref": "#/$defs/Value" }, - "criteria_connected.target-part": { "$ref": "#/$defs/Value" }, - "criteria_connected_via": { "$ref": "#/$defs/Value" }, + "criteria_connected.other-part": { "$ref": "#/$defs/Value" }, "criteria_connected_to_source": { "$ref": "#/$defs/Value" }, "criteria_connected_to_target": { "$ref": "#/$defs/Value" }, + "criteria_connected_via": { "$ref": "#/$defs/Value" }, "criteria_placeholder_any": { "$ref": "#/$defs/Value" }, "criteria_placeholder_unknown": { "$ref": "#/$defs/Value" }, "criteria_where_caption": { "$ref": "#/$defs/Value" }, diff --git a/i18n/translations/en.reactodia-translation.json b/i18n/translations/en.reactodia-translation.json index b1d805b5..d48f921a 100644 --- a/i18n/translations/en.reactodia-translation.json +++ b/i18n/translations/en.reactodia-translation.json @@ -219,10 +219,10 @@ "criteria_connected.direction_out.title": "Connected to source", "criteria_connected.header": "Connected to {{entity}} by", "criteria_connected.label": "connected to", - "criteria_connected.target-part": "entity", - "criteria_connected_via": "Connected by {{relationType}} to {{entity}}", + "criteria_connected.other-part": "entity", "criteria_connected_to_source": "Connected by {{relationType}} to {{entity}} {{sourceIcon}}\u00A0target", "criteria_connected_to_target": "Connected by {{relationType}} to {{entity}} {{targetIcon}}\u00A0source", + "criteria_connected_via": "Connected by {{relationType}} to {{entity}}", "criteria_placeholder_any": "any entity", "criteria_placeholder_unknown": "Unknown pattern type", "criteria_where_caption": "where\u00A0{{part}}:", diff --git a/src/data/dataProvider.ts b/src/data/dataProvider.ts index 26f4db02..116ace86 100644 --- a/src/data/dataProvider.ts +++ b/src/data/dataProvider.ts @@ -368,9 +368,9 @@ export interface LookupPatternHasType { } /** - * Lookup pattern that filters entities by having another connected entity - * matching a {@link target} pattern by any or specific {@link linkType link type} - * and {@link linkDirection direction}. + * Lookup pattern that filters entities that connected to another entity + * matching an {@link other} pattern, optinally restricted by specific + * {@link linkType link type} and/or {@link linkDirection direction}. * * If provided as a top-level pattern (including as part of {@link LookupPatternAllOf} * or {@link LookupPatternAnyOf}), provides {@link DataProviderLookupItem.inLinks} and @@ -382,15 +382,15 @@ export interface LookupPatternConnectedTo { */ readonly type: 'connectedTo'; /** - * Required link type between {@link target} and the entity. + * Required link type between {@link other} and the entity. * * If not specified, any link type is allowed. */ readonly linkType?: LinkTypeIri; /** - * Required link direction between {@link target} and the entity: - * - `out`: `target` → entity - * - `in`: `target` ← entity + * Required link direction between {@link other} and the entity: + * - `out`: `other` → entity + * - `in`: `other` ← entity * * If not specified, both directions are allowed. */ @@ -400,7 +400,7 @@ export interface LookupPatternConnectedTo { * * If not specified, any connected entity is allowed. */ - readonly target?: LookupPattern; + readonly other?: LookupPattern; } /** @@ -520,7 +520,7 @@ export function lookupPatternOf( type: 'connectedTo', linkType: refElementLinkId, linkDirection: linkDirection, - target: refElementId ? { + other: refElementId ? { type: 'fromValues', values: [refElementId], } : undefined, diff --git a/src/data/indexedDb/indexedDbCachedProvider.ts b/src/data/indexedDb/indexedDbCachedProvider.ts index 8873969e..43abf474 100644 --- a/src/data/indexedDb/indexedDbCachedProvider.ts +++ b/src/data/indexedDb/indexedDbCachedProvider.ts @@ -1238,7 +1238,7 @@ function serializePattern(pattern: LookupPattern): SExprLike { pattern.type, pattern.linkType ?? null, pattern.linkDirection ?? null, - pattern.target ? serializePattern(pattern.target) : null, + pattern.other ? serializePattern(pattern.other) : null, ]; } case 'withText': { diff --git a/src/data/rdf/datasetLookup.ts b/src/data/rdf/datasetLookup.ts index 08b14782..21477a0a 100644 --- a/src/data/rdf/datasetLookup.ts +++ b/src/data/rdf/datasetLookup.ts @@ -93,8 +93,8 @@ function joinWithPattern( return items; } case 'connectedTo': { - const targets = pattern.target - ? Array.from(joinWithPattern(undefined, pattern.target, context).keys()) + const targets = pattern.other + ? Array.from(joinWithPattern(undefined, pattern.other, context).keys()) : [null]; const linkTerm = pattern.linkType ? decodeTerm(pattern.linkType, context.factory) : null; const items = makeItemMap(); diff --git a/src/data/sparql/sparqlGenerator.ts b/src/data/sparql/sparqlGenerator.ts index 1e276eff..aaf69f61 100644 --- a/src/data/sparql/sparqlGenerator.ts +++ b/src/data/sparql/sparqlGenerator.ts @@ -335,9 +335,9 @@ export class SparqlGenerator { case 'connectedTo': { const parts: string[] = []; const targetTerm = - pattern.target?.type === 'fromValues' && pattern.target.values.length === 1 - ? this.factory.namedNode(pattern.target.values[0]) - : this.factory.variable!(`connectedTo_target${path}`); + pattern.other?.type === 'fromValues' && pattern.other.values.length === 1 + ? this.factory.namedNode(pattern.other.values[0]) + : this.factory.variable!(`connectedTo_other${path}`); parts.push( this.generateConnectedToPattern(pattern, { sourceTerm: entityTerm, @@ -352,10 +352,10 @@ export class SparqlGenerator { projections.add('link'); projections.add('direction'); } - if (pattern.target && targetTerm.termType === 'Variable') { + if (pattern.other && targetTerm.termType === 'Variable') { parts.push( - this.generatePattern(pattern.target, { - path: `${path}_target`, + this.generatePattern(pattern.other, { + path: `${path}_other`, entityTerm: targetTerm, valueSets, projections, diff --git a/src/widgets/instancesSearch/instanceCriteria.tsx b/src/widgets/instancesSearch/instanceCriteria.tsx index f29b784c..5477fcc9 100644 --- a/src/widgets/instancesSearch/instanceCriteria.tsx +++ b/src/widgets/instancesSearch/instanceCriteria.tsx @@ -220,7 +220,7 @@ function searchPatternFrom(criteria: SearchCriteria): SearchPattern | undefined patterns.push(new SearchPatternConnectedTo({ linkType: refElementLink, linkDirection, - target: refElement + other: refElement ? new SearchPatternFromValues({values: [refElement]}) : undefined, })); diff --git a/src/widgets/instancesSearch/instancesSearch.tsx b/src/widgets/instancesSearch/instancesSearch.tsx index ba92e56c..aa5eceea 100644 --- a/src/widgets/instancesSearch/instancesSearch.tsx +++ b/src/widgets/instancesSearch/instancesSearch.tsx @@ -71,7 +71,7 @@ export interface InstancesSearchProps { * * Built-in criteria options are always included to display or edit their * {@link SearchCriteriaOption.type pattern types}, however these options - * can be overriden by including options for these pattern types instead. + * can also be overriden by including options for these pattern types instead. * * **Built-in criteria options**: * - {@link SearchCriterionAllOf} diff --git a/src/widgets/instancesSearch/searchCriterion.tsx b/src/widgets/instancesSearch/searchCriterion.tsx index 1f73c0e4..31b2bd88 100644 --- a/src/widgets/instancesSearch/searchCriterion.tsx +++ b/src/widgets/instancesSearch/searchCriterion.tsx @@ -586,7 +586,7 @@ function ConnectedToCriterion( const {pattern, setPattern, inEditMode, controls} = props; const t = useTranslation(); - const {target, linkDirection} = pattern.state; + const {linkDirection, other} = pattern.state; const linkType = useSchemaType('linkType', pattern.state.linkType); @@ -596,13 +596,13 @@ function ConnectedToCriterion( if (!inEditMode) { let entity: React.ReactElement; - if (!target || target instanceof SearchPatternFromValues) { + if (!other || other instanceof SearchPatternFromValues) { inlineEntity = true; - entity = ; + entity = ; } else { entity = ( - {t.text('search_entities.criteria_connected.target-part')} + {t.text('search_entities.criteria_connected.other-part')} ); } @@ -649,7 +649,7 @@ function ConnectedToCriterion( {t.template('search_entities.criteria_connected.header', { entity: ( - {t.text('search_entities.criteria_connected.target-part')} + {t.text('search_entities.criteria_connected.other-part')} ), })} @@ -682,11 +682,11 @@ function ConnectedToCriterion( {inlineEntity ? null : ( - setPattern(new SearchPatternConnectedTo({ + part={t.text('search_entities.criteria_connected.other-part')}> + setPattern(new SearchPatternConnectedTo({ ...pattern.state, - target, + other, }))} parent={pattern} /> diff --git a/src/widgets/instancesSearch/searchPattern.ts b/src/widgets/instancesSearch/searchPattern.ts index ca302d2b..672a47e6 100644 --- a/src/widgets/instancesSearch/searchPattern.ts +++ b/src/widgets/instancesSearch/searchPattern.ts @@ -370,21 +370,21 @@ interface SerializedHasType { export class SearchPatternConnectedTo extends SearchPattern<{ readonly linkType?: LinkTypeIri; readonly linkDirection?: 'in' | 'out'; - readonly target?: SearchPattern; + readonly other?: SearchPattern; }> { generate( context: SearchPatternGenerateContext ): LookupPattern | SearchPatternError { - const {linkType, linkDirection, target} = this.state; - const targetPattern = target?.generate(context); - if (targetPattern?.type === 'error') { - return targetPattern; + const {linkType, linkDirection, other} = this.state; + const otherPattern = other?.generate(context); + if (otherPattern?.type === 'error') { + return otherPattern; } return { type: 'connectedTo', linkType, linkDirection, - target: targetPattern, + other: otherPattern, }; } @@ -394,21 +394,21 @@ export class SearchPatternConnectedTo extends SearchPattern<{ raw: SerializedConnectedTo, context: SearchPatternFromJsonContext ): SearchPatternConnectedTo { - const {linkType, linkDirection, target} = raw; + const {linkType, linkDirection, other} = raw; return new SearchPatternConnectedTo({ linkType, linkDirection, - target: target ? context.fromJson(target) : undefined, + other: other ? context.fromJson(other) : undefined, }); } toJSON(context: SearchPatternToJsonContext): SerializedConnectedTo { - const {linkType, linkDirection, target} = this.state; + const {linkType, linkDirection, other} = this.state; return { '@type': 'ConnectedTo', linkType, linkDirection, - target: target ? context.toJson(target) : undefined, + other: other ? context.toJson(other) : undefined, }; } } @@ -419,7 +419,7 @@ interface SerializedConnectedTo { '@type': 'ConnectedTo'; linkType?: LinkTypeIri; linkDirection?: 'in' | 'out'; - target?: SerializedSearchPattern; + other?: SerializedSearchPattern; } /** diff --git a/test/data/orgOntologyCases.ts b/test/data/orgOntologyCases.ts index 9dcea0ed..7bcdd120 100644 --- a/test/data/orgOntologyCases.ts +++ b/test/data/orgOntologyCases.ts @@ -165,7 +165,7 @@ export const lookupPatternOrgOntologyCases: readonly LookupTestCase[] = [ }, { type: 'connectedTo', - target: { + other: { type: 'fromValues', values: [org.Role], }, @@ -214,11 +214,11 @@ export const lookupPatternOrgOntologyCases: readonly LookupTestCase[] = [ type: 'connectedTo', linkType: rdfs.domain, linkDirection: 'in', - target: { + other: { type: 'connectedTo', linkType: rdfs.range, linkDirection: 'out', - target: { + other: { type: 'fromValues', values: [org.hasMembership], },