diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5510435d..325dc121 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to the Reactodia will be documented in this document.
The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
+#### 🚀 New Features
+- Add `elementIris` lookup parameter to `DataProvider.lookup()` and `SearchCriteria` to restrict the entity search to a given set of candidate elements (`VALUES` restriction in `SparqlDataProvider`, supported by `RdfDataProvider` as well), shown as a removable "Among N given entities" criterion.
+- Add `renderCriteriaActions` prop to `InstancesSearch` and `SearchSectionEntities` to render custom content next to the search criteria with access to the current criteria and a way to set them.
+- SPARQL example: "Filter by SPARQL query…" button in the entity search which runs a `SELECT` query on the connected endpoint and restricts the results to the entities bound by its first projected variable, in addition to the other criteria (e.g. the selected entity type).
## [0.35.2] - 2026-08-08
#### 🐛 Fixed
diff --git a/examples/resources/sparqlFilter.tsx b/examples/resources/sparqlFilter.tsx
new file mode 100644
index 00000000..c7625255
--- /dev/null
+++ b/examples/resources/sparqlFilter.tsx
@@ -0,0 +1,187 @@
+import * as React from 'react';
+import * as Reactodia from '../../src/workspace';
+
+const LAST_QUERY_SESSION_KEY = 'reactodia-sparql-filter-query';
+/**
+ * Upper bound for the candidate set: the IRIs are inlined into the lookup
+ * query as a `VALUES` block, which is sent as a GET request.
+ */
+const MAX_CANDIDATES = 500;
+
+/**
+ * Button for {@link Reactodia.InstancesSearchProps.renderCriteriaActions} which
+ * opens a dialog to restrict the entity search results to the bindings
+ * of a SPARQL `SELECT` query.
+ */
+export function SparqlFilterAction(props: {
+ context: Reactodia.CriteriaActionsContext;
+}) {
+ const {context} = props;
+ const workspace = Reactodia.useWorkspace();
+ return (
+
+ );
+}
+
+export function showSparqlFilterDialog(
+ context: Reactodia.CriteriaActionsContext,
+ workspace: Reactodia.WorkspaceContext
+): void {
+ const {overlay} = workspace;
+ overlay.showDialog({
+ style: {
+ caption: 'Filter by SPARQL query',
+ defaultSize: {width: 600, height: 500},
+ resizableBy: 'all',
+ },
+ content: (
+ {
+ overlay.hideDialog();
+ context.setCriteria({...context.criteria, elementIris});
+ }}
+ />
+ ),
+ });
+}
+
+export function SparqlFilterForm(props: {
+ onApply: (elementIris: ReadonlyArray) => void;
+}) {
+ const {onApply} = props;
+ const {model} = Reactodia.useWorkspace();
+ const [query, setQuery] = React.useState(loadLastQuery);
+ const [querying, setQuerying] = React.useState(false);
+ const [error, setError] = React.useState();
+ const cancellation = React.useRef();
+ React.useEffect(() => () => cancellation.current?.abort(), []);
+
+ const canSubmit = query.trim().length > 0 && !querying;
+ const submit = async () => {
+ const {dataProvider} = model;
+ if (!(dataProvider instanceof Reactodia.SparqlDataProvider)) {
+ setError('The workspace is not connected to a SPARQL endpoint.');
+ return;
+ }
+ cancellation.current?.abort();
+ const controller = new AbortController();
+ cancellation.current = controller;
+ setQuerying(true);
+ setError(undefined);
+ try {
+ const elementIris = await queryElementIris(dataProvider, query, controller.signal);
+ if (controller.signal.aborted) {
+ return;
+ }
+ storeLastQuery(query);
+ onApply(elementIris);
+ } catch (err) {
+ if (controller.signal.aborted) {
+ return;
+ }
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ if (!controller.signal.aborted) {
+ setQuerying(false);
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ The query is sent to the connected endpoint as is (with the graphs
+ of the connection). Up to {MAX_CANDIDATES} distinct IRIs from its
+ results are used; add a LIMIT for larger result sets.
+
+
+
+
+
+
+ );
+}
+
+async function queryElementIris(
+ dataProvider: Reactodia.SparqlDataProvider,
+ query: string,
+ signal: AbortSignal
+): Promise> {
+ const response = await dataProvider.executeSparqlSelect>(
+ query, {signal}
+ );
+ const variable = response.head.vars[0];
+ if (!variable) {
+ throw new Error('The query projects no variables.');
+ }
+ const elementIris = new Set();
+ for (const binding of response.results.bindings) {
+ const term = binding[variable];
+ if (term && term.termType === 'NamedNode') {
+ elementIris.add(term.value);
+ }
+ if (elementIris.size >= MAX_CANDIDATES) {
+ break;
+ }
+ }
+ return Array.from(elementIris);
+}
+
+function loadLastQuery(): string {
+ try {
+ return sessionStorage.getItem(LAST_QUERY_SESSION_KEY) ?? '';
+ } catch (e) {
+ return '';
+ }
+}
+
+function storeLastQuery(query: string): void {
+ try {
+ sessionStorage.setItem(LAST_QUERY_SESSION_KEY, query);
+ } catch (e) {
+ /* ignore */
+ }
+}
diff --git a/examples/sparql.tsx b/examples/sparql.tsx
index d7572637..e53ece8e 100644
--- a/examples/sparql.tsx
+++ b/examples/sparql.tsx
@@ -11,6 +11,7 @@ import {
import {
SparqlConnectionSettings, SparqlConnectionAction, showConnectionDialog,
} from './resources/sparqlConnection';
+import { SparqlFilterAction } from './resources/sparqlFilter';
const Layouts = Reactodia.defineLayoutWorker(() => new Worker(
new URL('../src/layout.worker.ts', import.meta.url),
@@ -66,29 +67,75 @@ function SparqlExample() {
return (
- }
- languages={[
- {code: 'de', label: 'Deutsch'},
- {code: 'en', label: 'English'},
- {code: 'es', label: 'Español'},
- {code: 'fr', label: 'Français'},
- {code: 'hi', label: 'हिन्दी'},
- {code: 'it', label: 'Italiano'},
- {code: 'ja', label: '日本語'},
- {code: 'pt', label: 'português'},
- {code: 'ru', label: 'Русский'},
- {code: 'zh', label: '汉语'},
- ]}>
-
-
-
-
+
);
}
+function SparqlWorkspace(props: {
+ connectionSettings: SparqlConnectionSettings | undefined;
+ applyConnectionSettings: (settings: SparqlConnectionSettings) => void;
+}) {
+ const {connectionSettings, applyConnectionSettings} = props;
+ const searchSections = useSearchSections();
+ return (
+ }
+ search={{sections: searchSections}}
+ languages={[
+ {code: 'de', label: 'Deutsch'},
+ {code: 'en', label: 'English'},
+ {code: 'es', label: 'Español'},
+ {code: 'fr', label: 'Français'},
+ {code: 'hi', label: 'हिन्दी'},
+ {code: 'it', label: 'Italiano'},
+ {code: 'ja', label: '日本語'},
+ {code: 'pt', label: 'português'},
+ {code: 'ru', label: 'Русский'},
+ {code: 'zh', label: '汉语'},
+ ]}>
+
+
+
+
+ );
+}
+
+/**
+ * Same as the default search sections, with a SPARQL query filter
+ * for the entity search.
+ */
+function useSearchSections(): ReadonlyArray {
+ const t = Reactodia.useTranslation();
+ return React.useMemo((): ReadonlyArray => [
+ {
+ key: 'elementTypes',
+ label: t.text('default_workspace.search_section_entity_types.label'),
+ title: t.text('default_workspace.search_section_entity_types.title'),
+ component: ,
+ },
+ {
+ key: 'entities',
+ label: t.text('default_workspace.search_section_entities.label'),
+ title: t.text('default_workspace.search_section_entities.title'),
+ component: (
+ }
+ />
+ ),
+ },
+ {
+ key: 'linkTypes',
+ label: t.text('default_workspace.search_section_link_types.label'),
+ title: t.text('default_workspace.search_section_link_types.title'),
+ component: ,
+ },
+ ], [t]);
+}
+
mountOnLoad();
diff --git a/i18n/i18n.schema.json b/i18n/i18n.schema.json
index 5f5e306e..39e72f67 100644
--- a/i18n/i18n.schema.json
+++ b/i18n/i18n.schema.json
@@ -268,6 +268,7 @@
"add_selected.label": { "$ref": "#/$defs/Value" },
"add_selected.title": { "$ref": "#/$defs/Value" },
"criteria_has_type": { "$ref": "#/$defs/Value" },
+ "criteria_among": { "$ref": "#/$defs/Value" },
"criteria_connected": { "$ref": "#/$defs/Value" },
"criteria_connected_via": { "$ref": "#/$defs/Value" },
"criteria_connected_to_source": { "$ref": "#/$defs/Value" },
diff --git a/i18n/translations/en.reactodia-translation.json b/i18n/translations/en.reactodia-translation.json
index 62047439..2c9efca2 100644
--- a/i18n/translations/en.reactodia-translation.json
+++ b/i18n/translations/en.reactodia-translation.json
@@ -185,6 +185,7 @@
"add_selected.label": "Add selected",
"add_selected.title": "Place selected entities on the canvas",
"criteria_has_type": "Has type {{entityType}}",
+ "criteria_among": "Among {{count}} given entities",
"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",
diff --git a/src/data/dataProvider.ts b/src/data/dataProvider.ts
index 2ac0a4e0..d5c63148 100644
--- a/src/data/dataProvider.ts
+++ b/src/data/dataProvider.ts
@@ -193,6 +193,14 @@ export interface DataProviderLookupParams {
*/
text?: string;
+ /**
+ * Filter by a set of candidate elements: only elements from the set
+ * (which also match the other criteria) are returned.
+ *
+ * An empty set matches nothing.
+ */
+ elementIris?: ReadonlyArray;
+
/**
* Filter by having a connected element with specified IRI.
*/
diff --git a/src/data/rdf/rdfDataProvider.ts b/src/data/rdf/rdfDataProvider.ts
index dee40117..8aaa0c94 100644
--- a/src/data/rdf/rdfDataProvider.ts
+++ b/src/data/rdf/rdfDataProvider.ts
@@ -515,6 +515,22 @@ export class RdfDataProvider implements DataProvider {
}
}
requiredTextFilter = undefined;
+ } else if (params.elementIris) {
+ for (const iri of params.elementIris) {
+ const term = this.decodeTerm(iri);
+ if (isResourceTerm(term) && !items.has(term)) {
+ items.set(term, {term});
+ }
+ }
+ }
+
+ if (params.elementIris) {
+ const candidates = new Set(params.elementIris);
+ for (const item of Array.from(items.values())) {
+ if (!candidates.has(this.encodeTerm(item.term))) {
+ items.delete(item.term);
+ }
+ }
}
const linkedElements: DataProviderLookupItem[] = [];
diff --git a/src/data/sparql/sparqlDataProvider.ts b/src/data/sparql/sparqlDataProvider.ts
index fe4e258a..210cc663 100644
--- a/src/data/sparql/sparqlDataProvider.ts
+++ b/src/data/sparql/sparqlDataProvider.ts
@@ -727,6 +727,10 @@ export class SparqlDataProvider implements DataProvider {
limit: baseParams.limit === undefined ? 100 : baseParams.limit,
};
+ if (params.elementIris && params.elementIris.length === 0) {
+ return [];
+ }
+
// query types to match link configuration domains
const types = this.querySingleElementTypes(
params.refElementId && this.settings.linkConfigurations.length > 0
@@ -812,6 +816,13 @@ export class SparqlDataProvider implements DataProvider {
}
}
+ let filterAdditionalRestriction = this.settings.filterAdditionalRestriction;
+ if (params.elementIris) {
+ const candidates = params.elementIris.map(escapeIri).map(iri => ` ( ${iri} )`).join(' ');
+ filterAdditionalRestriction += `
+VALUES (?inst) {${candidates} }`;
+ }
+
let limitPart = '';
if (typeof params.limit === 'number') {
limitPart = `LIMIT ${params.limit}`;
@@ -825,7 +836,7 @@ export class SparqlDataProvider implements DataProvider {
filterByType,
filterByRefElementLink,
filterByText,
- filterAdditionalRestriction: this.settings.filterAdditionalRestriction,
+ filterAdditionalRestriction,
orderBy,
limit: limitPart,
queryTypes,
diff --git a/src/widgets/instancesSearch.tsx b/src/widgets/instancesSearch.tsx
index f880d6d9..e4cc6682 100644
--- a/src/widgets/instancesSearch.tsx
+++ b/src/widgets/instancesSearch.tsx
@@ -59,6 +59,11 @@ export interface InstancesSearchProps {
* @default 3
*/
minSearchTermLength?: number;
+ /**
+ * Renders additional content (e.g. a button to set a custom criterion)
+ * next to the search criteria.
+ */
+ renderCriteriaActions?: (context: CriteriaActionsContext) => React.ReactNode;
/**
* Handler for the search criteria changes.
*/
@@ -113,6 +118,11 @@ export interface SearchCriteria {
* Filter by an element type.
*/
readonly elementType?: ElementTypeIri;
+ /**
+ * Filter by a set of candidate elements: only elements from the set
+ * (which also match the other criteria) are shown.
+ */
+ readonly elementIris?: ReadonlyArray;
/**
* Filter by having a connected element with specified IRI.
*/
@@ -131,6 +141,20 @@ export interface SearchCriteria {
readonly linkDirection?: 'in' | 'out';
}
+/**
+ * Context for {@link InstancesSearchProps.renderCriteriaActions}.
+ */
+export interface CriteriaActionsContext {
+ /**
+ * Current search criteria.
+ */
+ readonly criteria: SearchCriteria;
+ /**
+ * Sets the search criteria and initiates the search.
+ */
+ readonly setCriteria: (criteria: SearchCriteria) => void;
+}
+
/**
* Component to search for entities by various filter criteria
* to add them as elements to the diagram.
@@ -483,6 +507,36 @@ class InstancesSearchInner extends React.Component
+ {this.renderRemoveCriterionButtons(() => this.setState(
+ {
+ criteria: {...criteria, elementIris: undefined},
+ },
+ () => this.props.onChangeCriteria?.(this.state.criteria)
+ ))}
+ {t.text('search_entities.criteria_among', {
+ count: String(criteria.elementIris.length),
+ })}
+
+ );
+ }
+
+ const {renderCriteriaActions} = this.props;
+ if (renderCriteriaActions) {
+ const {workspace} = this.props;
+ criterions.push(
+
+ {renderCriteriaActions({
+ criteria,
+ setCriteria: nextCriteria => workspace.getCommandBus(InstancesSearchTopic)
+ .trigger('setCriteria', {criteria: nextCriteria}),
+ })}
+
+ );
+ }
+
return
{criterions}
;
}
@@ -532,7 +586,10 @@ class InstancesSearchInner extends React.Component {
if (shouldRender) {
setSectionActive(true, criteriaAsSearchExtra(criteria));
@@ -121,7 +128,7 @@ export function SearchSectionEntities(props: {
}
function criteriaAsSearchExtra(criteria: SearchCriteria): object | undefined {
- if (criteria.text || criteria.elementType || criteria.refElement) {
+ if (criteria.text || criteria.elementType || criteria.elementIris || criteria.refElement) {
return criteria;
} else {
return undefined;
diff --git a/src/workspace.ts b/src/workspace.ts
index 66aefe2a..58b42f75 100644
--- a/src/workspace.ts
+++ b/src/workspace.ts
@@ -260,6 +260,7 @@ export { Halo, type HaloProps } from './widgets/halo';
export { HaloLink, type HaloLinkProps } from './widgets/haloLink';
export {
InstancesSearch, type InstancesSearchProps, type InstancesSearchCommands,
+ type SearchCriteria, type CriteriaActionsContext,
} from './widgets/instancesSearch';
export {
type LinkActionContext, useLinkActionContext,
diff --git a/test/data/sparql/sparqlProviderBasic.test.ts b/test/data/sparql/sparqlProviderBasic.test.ts
index 28180827..eaefc684 100644
--- a/test/data/sparql/sparqlProviderBasic.test.ts
+++ b/test/data/sparql/sparqlProviderBasic.test.ts
@@ -228,6 +228,23 @@ describe('SparqlDataProvider', () => {
] satisfies DataProviderLookupItem[]
);
});
+
+ it('provides lookup() by type among candidate elements', async () => {
+ const provider = await makeSparqlDataProvider(
+ {},
+ {...OwlStatsSettings, filterOnlyLanguages: ['en']},
+ );
+ const items = await provider.lookup({
+ elementTypeId: owl.DatatypeProperty,
+ elementIris: [org.location, org.subOrganizationOf],
+ });
+ expect(items.map(item => item.element.id)).toEqual([org.location]);
+ const none = await provider.lookup({
+ elementTypeId: owl.DatatypeProperty,
+ elementIris: [],
+ });
+ expect(none).toEqual([]);
+ });
});
function readPropertyValues(