Skip to content

Commit 7305e60

Browse files
committed
feat(yasgui): improve the SPARQL Anything query builder and open the Source view on load
- Build query: action buttons at the top of the view, disabled until a value is ticked - Build query: All values checkbox to tick or clear every value at once - Load structure (chip, Retry and Ctrl+K) opens the Source view of the side panel
1 parent a23f7c3 commit 7305e60

6 files changed

Lines changed: 185 additions & 55 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@matdata/yasgui": minor
3+
---
4+
5+
SPARQL Anything source inspector: **Load structure** on the chip (and in the Ctrl+K search) now opens the Source view of the side panel. The **Build query** view has its buttons at the top, and an **All values** checkbox to tick every value of the source at once.

‎docs/user-guide.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,7 +1071,7 @@ SELECT * WHERE {
10711071

10721072
**Loading the structure**
10731073

1074-
As soon as you close the block with `}`, a chip appears after its opening brace, with the file name and **Load structure**. Clicking it sends one query to the endpoint of the tab, with your SERVICE IRI and `fx:properties` options unchanged. The endpoint reads the file, so this also works for files that only the endpoint can reach (a path on the server, a URL behind a firewall, or a URL that needs authentication). Nothing is sent before you click: SPARQL Anything reads the whole file to answer, which can take a while for big files. While it runs the chip offers **Cancel**; when it fails it shows **Retry**, with the endpoint's error when you hover it.
1074+
As soon as you close the block with `}`, a chip appears after its opening brace, with the file name and **Load structure**. Clicking it opens the **Source** view of the side panel and sends one query to the endpoint of the tab, with your SERVICE IRI and `fx:properties` options unchanged. The endpoint reads the file, so this also works for files that only the endpoint can reach (a path on the server, a URL behind a firewall, or a URL that needs authentication). Nothing is sent before you click: SPARQL Anything reads the whole file to answer, which can take a while for big files. While it runs the chip offers **Cancel**; when it fails it shows **Retry**, with the endpoint's error when you hover it.
10751075

10761076
When the structure is loaded, the chip shows the number of properties and **Inspect**, and:
10771077

@@ -1084,7 +1084,7 @@ The structure is remembered per endpoint and set of options for a day; the reloa
10841084

10851085
- **Structure**: the tree of the file as SPARQL Anything sees it: the document (`fx:root`), its rows, array items or elements, and their properties, each with a count and an example value. Click a property or element to insert it at the cursor.
10861086
- **Preview**: the first 100 triples of the converted file, in Turtle, fetched with a `CONSTRUCT ... LIMIT 100` query. **Open as query** puts that query in a new tab.
1087-
- **Build query**: tick the values you need. MatGUI writes the patterns from the root to each value: `fx:anySlot` for rows and array items, `a xyz:<element>` for XML elements, and `OPTIONAL` for values you mark as optional (the first value you tick is required, later ones optional; click the label to switch). **Replace block** puts the patterns in the SERVICE block (keeping its `fx:properties` options; `Ctrl+Z` undoes it), **Insert at cursor** inserts them, and **New tab** opens a complete SELECT query.
1087+
- **Build query**: tick the values you need, or **All values** at the top to tick them all (click it again to clear the selection). MatGUI writes the patterns from the root to each value: `fx:anySlot` for rows and array items, `a xyz:<element>` for XML elements, and `OPTIONAL` for values you mark as optional (the first value you tick is required, later ones optional; click the label to switch). The buttons stay at the top of the view: **Replace block** puts the patterns in the SERVICE block (keeping its `fx:properties` options; `Ctrl+Z` undoes it), **Insert at cursor** inserts them, and **New tab** opens a complete SELECT query.
10881088

10891089
CSV, JSON and XML sources are shown best; other formats SPARQL Anything supports work too, with a more generic tree. A site can switch all of this off with `sparqlAnything: false`. To try it, the repository has a Docker Compose setup with a SPARQL Anything server and sample files in `dev/sparql-anything`.
10901090

‎packages/yasgui/src/sparqlAnything/SourceView.ts‎

Lines changed: 81 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -490,8 +490,83 @@ export default class SourceView {
490490
this.render();
491491
};
492492

493+
// Every value of the source, in tree order, for "All values"
494+
const values: string[] = [];
495+
walk(tree, (node) => {
496+
if (node.kind === "literal") values.push(stableKey(node));
497+
});
498+
const pickedValues: PickedValue[] = picks.map((p) => ({ node: nodesByKey.get(p.key)!, optional: p.optional }));
499+
const built = pickedValues.length ? buildPatterns(pickedValues, source, queryPrefixes) : undefined;
500+
501+
// The actions stay on top, so they're in reach without scrolling past a long tree
502+
const toolbar = el("div", "yasgui-sa__actions top");
503+
const allLabel = el("label", "yasgui-sa__all");
504+
const all = el("input", "yasgui-sa__check");
505+
all.type = "checkbox";
506+
all.checked = values.length > 0 && picks.length === values.length;
507+
all.indeterminate = picks.length > 0 && picks.length < values.length;
508+
all.disabled = !values.length;
509+
all.addEventListener("change", () => {
510+
if (!all.checked) return setPicks([]);
511+
// Keep what's picked; like ticking one by one, the first value is required and later ones optional
512+
const next = [...picks];
513+
for (const key of values) {
514+
if (!next.some((p) => p.key === key)) next.push({ key, optional: next.some((p) => !p.optional) });
515+
}
516+
setPicks(next);
517+
});
518+
allLabel.title = "Select all values, or clear the selection";
519+
allLabel.append(all, el("span", undefined, picks.length ? `${picks.length} of ${values.length}` : "All values"));
520+
toolbar.append(allLabel);
521+
522+
const replace = button(
523+
"Replace block",
524+
"yasgui-sa__button primary",
525+
"Replace the content of the SERVICE block (Ctrl+Z undoes it)",
526+
);
527+
replace.addEventListener("click", () => {
528+
if (!yasqe || !built) return;
529+
const current = yasqe.getSparqlAnythingSources().find((s) => s.key === source.key);
530+
if (!current || current.closeBrace === undefined) return;
531+
const doc = yasqe.getDoc();
532+
const lineStart = doc.posFromIndex(current.from);
533+
const indent = (doc.getLine(lineStart.line).match(/^\s*/) || [""])[0];
534+
const fxPrefix = Object.keys(prefixes).find((p) => prefixes[p] === FX)!;
535+
const used = { ...built.prefixes };
536+
const body: string[] = [];
537+
if (current.optionTriples.length) {
538+
used[fxPrefix] = FX;
539+
body.push(
540+
`${fxPrefix}:properties ` +
541+
current.optionTriples
542+
.map((t) => `${fxPrefix}:${t.name} ${t.raw}`)
543+
.join(` ;\n${indent} ${" ".repeat(fxPrefix.length + 12)}`) +
544+
" .",
545+
);
546+
}
547+
body.push(...built.lines);
548+
const text = "\n" + body.map((line) => `${indent} ${line}`).join("\n") + `\n${indent}`;
549+
doc.replaceRange(text, doc.posFromIndex(current.openBrace + 1), doc.posFromIndex(current.closeBrace));
550+
yasqe.addPrefixes(used);
551+
yasqe.focus();
552+
});
553+
const insert = button("Insert at cursor", "yasgui-sa__button", "Insert the patterns at the cursor");
554+
insert.addEventListener("click", () => {
555+
if (!yasqe || !built) return;
556+
yasqe.replaceSelection(built.lines.join("\n"));
557+
yasqe.addPrefixes(built.prefixes);
558+
yasqe.focus();
559+
});
560+
const newTab = button("New tab", "yasgui-sa__button", "Open a SELECT query for these values in a new tab");
561+
newTab.addEventListener("click", () => {
562+
if (built) this.openInNewTab(tab, buildQuery(pickedValues, source, queryPrefixes));
563+
});
564+
for (const action of [replace, insert, newTab]) action.disabled = !built;
565+
toolbar.append(replace, insert, newTab);
566+
wrapper.append(toolbar);
567+
wrapper.append(el("p", "yasgui-sa__hint", "Tick the values you need. Rows and array items become fx:anySlot."));
568+
493569
wrapper.append(
494-
el("p", "yasgui-sa__hint", "Tick the values you need. Rows and array items become fx:anySlot."),
495570
this.drawTree(
496571
tree,
497572
prefixes,
@@ -534,56 +609,11 @@ export default class SourceView {
534609
),
535610
);
536611

537-
if (!picks.length) return wrapper;
538-
const picked: PickedValue[] = picks.map((p) => ({ node: nodesByKey.get(p.key)!, optional: p.optional }));
539-
const built = buildPatterns(picked, source, queryPrefixes);
540-
const pre = el("pre", "yasgui-sa__code");
541-
pre.textContent = built.lines.join("\n");
542-
wrapper.append(pre);
543-
544-
const actions = el("div", "yasgui-sa__actions");
545-
const replace = button(
546-
"Replace block",
547-
"yasgui-sa__button primary",
548-
"Replace the content of the SERVICE block (Ctrl+Z undoes it)",
549-
);
550-
replace.addEventListener("click", () => {
551-
if (!yasqe) return;
552-
const current = yasqe.getSparqlAnythingSources().find((s) => s.key === source.key);
553-
if (!current || current.closeBrace === undefined) return;
554-
const doc = yasqe.getDoc();
555-
const lineStart = doc.posFromIndex(current.from);
556-
const indent = (doc.getLine(lineStart.line).match(/^\s*/) || [""])[0];
557-
const fxPrefix = Object.keys(prefixes).find((p) => prefixes[p] === FX)!;
558-
const used = { ...built.prefixes };
559-
const body: string[] = [];
560-
if (current.optionTriples.length) {
561-
used[fxPrefix] = FX;
562-
body.push(
563-
`${fxPrefix}:properties ` +
564-
current.optionTriples
565-
.map((t) => `${fxPrefix}:${t.name} ${t.raw}`)
566-
.join(` ;\n${indent} ${" ".repeat(fxPrefix.length + 12)}`) +
567-
" .",
568-
);
569-
}
570-
body.push(...built.lines);
571-
const text = "\n" + body.map((line) => `${indent} ${line}`).join("\n") + `\n${indent}`;
572-
doc.replaceRange(text, doc.posFromIndex(current.openBrace + 1), doc.posFromIndex(current.closeBrace));
573-
yasqe.addPrefixes(used);
574-
yasqe.focus();
575-
});
576-
const insert = button("Insert at cursor", "yasgui-sa__button", "Insert the patterns at the cursor");
577-
insert.addEventListener("click", () => {
578-
if (!yasqe) return;
579-
yasqe.replaceSelection(built.lines.join("\n"));
580-
yasqe.addPrefixes(built.prefixes);
581-
yasqe.focus();
582-
});
583-
const newTab = button("New tab", "yasgui-sa__button", "Open a SELECT query for these values in a new tab");
584-
newTab.addEventListener("click", () => this.openInNewTab(tab, buildQuery(picked, source, queryPrefixes)));
585-
actions.append(replace, insert, newTab);
586-
wrapper.append(actions);
612+
if (built) {
613+
const pre = el("pre", "yasgui-sa__code");
614+
pre.textContent = built.lines.join("\n");
615+
wrapper.append(pre);
616+
}
587617
return wrapper;
588618
}
589619

‎packages/yasgui/src/sparqlAnything/SparqlAnything.scss‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,12 @@
326326
border-color: var(--yasgui-line-strong, #808893);
327327
}
328328

329+
&:disabled {
330+
opacity: 0.5;
331+
cursor: default;
332+
pointer-events: none;
333+
}
334+
329335
&.primary {
330336
border-color: var(--yasgui-brand-fill, #0563bb);
331337
background: var(--yasgui-brand-fill, #0563bb);
@@ -487,6 +493,32 @@
487493
flex-wrap: wrap;
488494
gap: 6px;
489495
margin-top: 10px;
496+
497+
// The query builder's actions stay at the top of the scrolling body
498+
&.top {
499+
position: sticky;
500+
z-index: 1;
501+
top: -8px;
502+
align-items: center;
503+
margin: -8px -6px 8px;
504+
padding: 8px 6px;
505+
border-bottom: 1px solid var(--yasgui-line, #dfe5ec);
506+
background: var(--yasgui-surface-raised, #fff);
507+
}
508+
}
509+
510+
// Below the action buttons, on its own line
511+
&__all {
512+
display: flex;
513+
flex-basis: 100%;
514+
order: 1;
515+
align-items: center;
516+
gap: 6px;
517+
padding: 2px 0 0 4px;
518+
color: var(--yasgui-ink-body, #3a4552);
519+
font-size: 12px;
520+
cursor: pointer;
521+
user-select: none;
490522
}
491523

492524
&__footer {

‎packages/yasgui/src/sparqlAnything/SparqlAnythingSupport.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,10 @@ export default class SparqlAnythingSupport {
230230
label: `${state?.status === "done" ? "Reload" : "Load"} structure of ${source.label}`,
231231
icon: "fileSearch",
232232
keywords: "sparql anything source columns keys autocomplete facade-x",
233-
run: () => void this.load(tab, source),
233+
run: () => {
234+
this.view.show(source);
235+
void this.load(tab, source);
236+
},
234237
});
235238
items.push({
236239
section: "commands",
@@ -292,6 +295,8 @@ export default class SparqlAnythingSupport {
292295
add(muted("·"));
293296
add(
294297
action("Load structure", "Send a query to the endpoint to read the structure of this source", () => {
298+
// Open the Source view, so the progress and the result show up next to the editor
299+
inspect();
295300
void this.load(tab, source);
296301
}),
297302
);
@@ -306,7 +311,17 @@ export default class SparqlAnythingSupport {
306311
add(iconElement("alert", 13));
307312
add(name);
308313
add(muted("· could not load"));
309-
add(action("Retry", state.error || "", () => void this.load(tab, source), "danger"));
314+
add(
315+
action(
316+
"Retry",
317+
state.error || "",
318+
() => {
319+
inspect();
320+
void this.load(tab, source);
321+
},
322+
"danger",
323+
),
324+
);
310325
chip.title = state.error || "";
311326
} else {
312327
chip.classList.add("loaded");

‎test/sparql-anything-browser.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,54 @@ describe("SPARQL Anything", function () {
150150
);
151151
});
152152

153+
it("opens the Source view when loading the structure", async function () {
154+
await page.evaluate(() => (window as any).yasgui.sidePanel.close());
155+
await loadStructure();
156+
const panel = await page.evaluate(() => {
157+
const sidePanel = (window as any).yasgui.sidePanel;
158+
return { open: sidePanel.isOpen(), view: sidePanel.getView() };
159+
});
160+
expect(panel).to.deep.equal({ open: true, view: "source" });
161+
});
162+
163+
it("selects all values in the query builder", async function () {
164+
await loadStructure();
165+
await page.evaluate(() =>
166+
Array.from(document.querySelectorAll<HTMLElement>(".yasgui-sa__tab"))
167+
.find((b) => b.textContent === "Build query")
168+
?.click(),
169+
);
170+
await page.waitForSelector(".yasgui-sa__all input", { visible: true });
171+
// The actions are above the tree and wait for a selection
172+
expect(
173+
await page.$eval(".yasgui-sa__section", (section) => {
174+
const actions = section.querySelector(".yasgui-sa__actions")!;
175+
const tree = section.querySelector(".yasgui-sa__tree")!;
176+
return !!(actions.compareDocumentPosition(tree) & Node.DOCUMENT_POSITION_FOLLOWING);
177+
}),
178+
).to.equal(true);
179+
expect(await page.$eval(".yasgui-sa__actions .primary", (b) => (b as HTMLButtonElement).disabled)).to.equal(true);
180+
181+
// The view redraws often, so click the current checkbox rather than a handle to it
182+
await page.evaluate(() => document.querySelector<HTMLInputElement>(".yasgui-sa__all input")?.click());
183+
const checked = await page.$$eval(".yasgui-sa__node .yasgui-sa__check", (boxes) =>
184+
boxes.map((b) => (b as HTMLInputElement).checked),
185+
);
186+
expect(checked.length).to.be.greaterThan(1);
187+
expect(checked.every(Boolean)).to.equal(true);
188+
expect(await page.$eval(".yasgui-sa__all", (el) => el.textContent)).to.equal(
189+
`${checked.length} of ${checked.length}`,
190+
);
191+
const code = await page.$eval(".yasgui-sa__code", (el) => el.textContent || "");
192+
expect(code).to.contain("xyz:email");
193+
expect(code).to.contain("xyz:name");
194+
195+
// Unticking clears the selection
196+
// The view redraws often, so click the current checkbox rather than a handle to it
197+
await page.evaluate(() => document.querySelector<HTMLInputElement>(".yasgui-sa__all input")?.click());
198+
expect(await page.$$eval(".yasgui-sa__node .yasgui-sa__check:checked", (boxes) => boxes.length)).to.equal(0);
199+
});
200+
153201
it("suggests the columns in autocompletion", async function () {
154202
await loadStructure();
155203
await page.evaluate(() => {

0 commit comments

Comments
 (0)