Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
187 changes: 187 additions & 0 deletions examples/resources/sparqlFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button type='button'
className='reactodia-btn reactodia-btn-default'
style={{margin: '0 10px 4px 10px'}}
title='Restrict the results to the entities bound by a SPARQL query'
onClick={() => showSparqlFilterDialog(context, workspace)}>
Filter by SPARQL query…
</button>
);
}

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: (
<SparqlFilterForm
onApply={elementIris => {
overlay.hideDialog();
context.setCriteria({...context.criteria, elementIris});
}}
/>
),
});
}

export function SparqlFilterForm(props: {
onApply: (elementIris: ReadonlyArray<Reactodia.ElementIri>) => 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<string | undefined>();
const cancellation = React.useRef<AbortController>();
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 (
<div className='reactodia-form'>
<div className='reactodia-form__body'
style={{display: 'flex', flexDirection: 'column'}}>
<div className='reactodia-form__control-row'
style={{flex: 'auto', display: 'flex', flexDirection: 'column'}}>
<label htmlFor='sparqlFilterQuery'>
SPARQL <code>SELECT</code> query
</label>
<textarea id='sparqlFilterQuery'
className='reactodia-form-control'
style={{flex: 'auto', fontFamily: 'monospace', resize: 'none'}}
placeholder={
'The entities bound to the first projected variable ' +
'restrict the search results, in addition to the other criteria.'
}
autoFocus
spellCheck={false}
value={query}
onChange={e => setQuery(e.currentTarget.value)}
onKeyDown={e => {
if (e.key === 'Enter' && e.ctrlKey && canSubmit) {
void submit();
}
}}
/>
{error ? (
<div className='reactodia-form__control-error'
style={{position: 'static', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere'}}>
{error}
</div>
) : null}
</div>
<div className='reactodia-form__control-row'>
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 <code>LIMIT</code> for larger result sets.
</div>
</div>
<div className='reactodia-form__controls'>
<button className='reactodia-btn reactodia-btn-primary'
type='button'
disabled={!canSubmit}
title='Run the query and apply its results as a filter (Ctrl+Enter)'
onClick={() => void submit()}>
{querying ? 'Querying…' : 'Apply'}
</button>
</div>
</div>
);
}

async function queryElementIris(
dataProvider: Reactodia.SparqlDataProvider,
query: string,
signal: AbortSignal
): Promise<ReadonlyArray<Reactodia.ElementIri>> {
const response = await dataProvider.executeSparqlSelect<Record<string, Reactodia.Rdf.Term>>(
query, {signal}
);
const variable = response.head.vars[0];
if (!variable) {
throw new Error('The query projects no variables.');
}
const elementIris = new Set<Reactodia.ElementIri>();
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 */
}
}
89 changes: 68 additions & 21 deletions examples/sparql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -66,29 +67,75 @@ function SparqlExample() {
return (
<Reactodia.WorkspaceProvider workspace={workspace}
onMount={onMount}>
<Reactodia.DefaultWorkspace
menu={<ExampleToolbarMenu />}
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: '汉语'},
]}>
<Reactodia.Toolbar dock='sw'
dockOffsetY={40}>
<SparqlConnectionAction settings={connectionSettings}
applySettings={applyConnectionSettings}
/>
</Reactodia.Toolbar>
</Reactodia.DefaultWorkspace>
<SparqlWorkspace connectionSettings={connectionSettings}
applyConnectionSettings={applyConnectionSettings}
/>
</Reactodia.WorkspaceProvider>
);
}

function SparqlWorkspace(props: {
connectionSettings: SparqlConnectionSettings | undefined;
applyConnectionSettings: (settings: SparqlConnectionSettings) => void;
}) {
const {connectionSettings, applyConnectionSettings} = props;
const searchSections = useSearchSections();
return (
<Reactodia.DefaultWorkspace
menu={<ExampleToolbarMenu />}
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: '汉语'},
]}>
<Reactodia.Toolbar dock='sw'
dockOffsetY={40}>
<SparqlConnectionAction settings={connectionSettings}
applySettings={applyConnectionSettings}
/>
</Reactodia.Toolbar>
</Reactodia.DefaultWorkspace>
);
}

/**
* Same as the default search sections, with a SPARQL query filter
* for the entity search.
*/
function useSearchSections(): ReadonlyArray<Reactodia.UnifiedSearchSection> {
const t = Reactodia.useTranslation();
return React.useMemo((): ReadonlyArray<Reactodia.UnifiedSearchSection> => [
{
key: 'elementTypes',
label: t.text('default_workspace.search_section_entity_types.label'),
title: t.text('default_workspace.search_section_entity_types.title'),
component: <Reactodia.SearchSectionElementTypes />,
},
{
key: 'entities',
label: t.text('default_workspace.search_section_entities.label'),
title: t.text('default_workspace.search_section_entities.title'),
component: (
<Reactodia.SearchSectionEntities
renderCriteriaActions={context => <SparqlFilterAction context={context} />}
/>
),
},
{
key: 'linkTypes',
label: t.text('default_workspace.search_section_link_types.label'),
title: t.text('default_workspace.search_section_link_types.title'),
component: <Reactodia.SearchSectionLinkTypes />,
},
], [t]);
}

mountOnLoad(<SparqlExample />);
1 change: 1 addition & 0 deletions i18n/i18n.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 1 addition & 0 deletions i18n/translations/en.reactodia-translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/data/dataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ElementIri>;

/**
* Filter by having a connected element with specified IRI.
*/
Expand Down
16 changes: 16 additions & 0 deletions src/data/rdf/rdfDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
13 changes: 12 additions & 1 deletion src/data/sparql/sparqlDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`;
Expand All @@ -825,7 +836,7 @@ export class SparqlDataProvider implements DataProvider {
filterByType,
filterByRefElementLink,
filterByText,
filterAdditionalRestriction: this.settings.filterAdditionalRestriction,
filterAdditionalRestriction,
orderBy,
limit: limitPart,
queryTypes,
Expand Down
Loading