diff --git a/.changeset/sparql-anything-builder-tweaks.md b/.changeset/sparql-anything-builder-tweaks.md new file mode 100644 index 00000000..9cbdf1b5 --- /dev/null +++ b/.changeset/sparql-anything-builder-tweaks.md @@ -0,0 +1,5 @@ +--- +"@matdata/yasgui": minor +--- + +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 one compact bar at the top with an **All** checkbox to tick every value of the source at once, **Replace block**, and icon buttons to insert at the cursor or open in a new tab. diff --git a/docs/user-guide.md b/docs/user-guide.md index 6a914942..53f01b16 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1071,7 +1071,7 @@ SELECT * WHERE { **Loading the structure** -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. +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. When the structure is loaded, the chip shows the number of properties and **Inspect**, and: @@ -1084,7 +1084,7 @@ The structure is remembered per endpoint and set of options for a day; the reloa - **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. - **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. -- **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:` 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. +- **Build query**: tick the values you need, or **All** at the top to tick them all (click it again to clear the selection; next to it you see how many are ticked). MatGUI writes the patterns from the root to each value: `fx:anySlot` for rows and array items, `a xyz:` 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 bar with **All** stays at the top of the view and has the actions: **Replace block** puts the patterns in the SERVICE block (keeping its `fx:properties` options; `Ctrl+Z` undoes it), the text cursor button inserts them at the cursor, and the arrow button opens a complete SELECT query in a new tab. 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`. diff --git a/packages/yasgui/src/icons.ts b/packages/yasgui/src/icons.ts index d3ca28ee..46f62729 100644 --- a/packages/yasgui/src/icons.ts +++ b/packages/yasgui/src/icons.ts @@ -54,6 +54,10 @@ const paths = { '', refresh: '', + externalLink: + '', + textCursor: + '', cornerDownLeft: '', code: '', command: '', diff --git a/packages/yasgui/src/sparqlAnything/SourceView.ts b/packages/yasgui/src/sparqlAnything/SourceView.ts index 1d8df248..cff5aa70 100644 --- a/packages/yasgui/src/sparqlAnything/SourceView.ts +++ b/packages/yasgui/src/sparqlAnything/SourceView.ts @@ -490,8 +490,98 @@ export default class SourceView { this.render(); }; + // Every value of the source, in tree order, for "All values" + const values: string[] = []; + walk(tree, (node) => { + if (node.kind === "literal") values.push(stableKey(node)); + }); + const pickedValues: PickedValue[] = picks.map((p) => ({ node: nodesByKey.get(p.key)!, optional: p.optional })); + const built = pickedValues.length ? buildPatterns(pickedValues, source, queryPrefixes) : undefined; + + // One bar on top with the selection and the actions, so they're in reach without scrolling past a long tree + const toolbar = el("div", "yasgui-sa__buildbar"); + const allLabel = el("label", "yasgui-sa__all"); + const all = el("input", "yasgui-sa__check"); + all.type = "checkbox"; + all.checked = values.length > 0 && picks.length === values.length; + all.indeterminate = picks.length > 0 && picks.length < values.length; + all.disabled = !values.length; + all.addEventListener("change", () => { + if (!all.checked) return setPicks([]); + // Keep what's picked; like ticking one by one, the first value is required and later ones optional + const next = [...picks]; + for (const key of values) { + if (!next.some((p) => p.key === key)) next.push({ key, optional: next.some((p) => !p.optional) }); + } + setPicks(next); + }); + allLabel.title = picks.length ? "Clear the selection" : "Select all values"; + allLabel.append( + all, + el("span", undefined, "All"), + el("span", "yasgui-sa__allCount", `${picks.length}/${values.length}`), + ); + toolbar.append(allLabel); + + const replace = iconButton("braces", "Replace block", { + className: "yasgui-sa__buildButton primary", + showLabel: true, + title: "Replace the content of the SERVICE block (Ctrl+Z undoes it)", + size: 14, + }); + replace.addEventListener("click", () => { + if (!yasqe || !built) return; + const current = yasqe.getSparqlAnythingSources().find((s) => s.key === source.key); + if (!current || current.closeBrace === undefined) return; + const doc = yasqe.getDoc(); + const lineStart = doc.posFromIndex(current.from); + const indent = (doc.getLine(lineStart.line).match(/^\s*/) || [""])[0]; + const fxPrefix = Object.keys(prefixes).find((p) => prefixes[p] === FX)!; + const used = { ...built.prefixes }; + const body: string[] = []; + if (current.optionTriples.length) { + used[fxPrefix] = FX; + body.push( + `${fxPrefix}:properties ` + + current.optionTriples + .map((t) => `${fxPrefix}:${t.name} ${t.raw}`) + .join(` ;\n${indent} ${" ".repeat(fxPrefix.length + 12)}`) + + " .", + ); + } + body.push(...built.lines); + const text = "\n" + body.map((line) => `${indent} ${line}`).join("\n") + `\n${indent}`; + doc.replaceRange(text, doc.posFromIndex(current.openBrace + 1), doc.posFromIndex(current.closeBrace)); + yasqe.addPrefixes(used); + yasqe.focus(); + }); + const insert = iconButton("textCursor", "Insert at cursor", { + className: "yasgui-sa__buildButton", + title: "Insert the patterns at the cursor", + size: 15, + }); + insert.addEventListener("click", () => { + if (!yasqe || !built) return; + yasqe.replaceSelection(built.lines.join("\n")); + yasqe.addPrefixes(built.prefixes); + yasqe.focus(); + }); + const newTab = iconButton("externalLink", "New tab", { + className: "yasgui-sa__buildButton", + title: "Open a SELECT query for these values in a new tab", + size: 15, + }); + newTab.addEventListener("click", () => { + if (built) this.openInNewTab(tab, buildQuery(pickedValues, source, queryPrefixes)); + }); + for (const action of [replace, insert, newTab]) action.disabled = !built; + const actions = el("div", "yasgui-sa__buildActions"); + actions.append(replace, insert, newTab); + toolbar.append(actions); + wrapper.append(toolbar); + wrapper.append(el("p", "yasgui-sa__hint", "Tick the values you need. Rows and array items become fx:anySlot.")); + wrapper.append( - el("p", "yasgui-sa__hint", "Tick the values you need. Rows and array items become fx:anySlot."), this.drawTree( tree, prefixes, @@ -534,56 +624,11 @@ export default class SourceView { ), ); - if (!picks.length) return wrapper; - const picked: PickedValue[] = picks.map((p) => ({ node: nodesByKey.get(p.key)!, optional: p.optional })); - const built = buildPatterns(picked, source, queryPrefixes); - const pre = el("pre", "yasgui-sa__code"); - pre.textContent = built.lines.join("\n"); - wrapper.append(pre); - - const actions = el("div", "yasgui-sa__actions"); - const replace = button( - "Replace block", - "yasgui-sa__button primary", - "Replace the content of the SERVICE block (Ctrl+Z undoes it)", - ); - replace.addEventListener("click", () => { - if (!yasqe) return; - const current = yasqe.getSparqlAnythingSources().find((s) => s.key === source.key); - if (!current || current.closeBrace === undefined) return; - const doc = yasqe.getDoc(); - const lineStart = doc.posFromIndex(current.from); - const indent = (doc.getLine(lineStart.line).match(/^\s*/) || [""])[0]; - const fxPrefix = Object.keys(prefixes).find((p) => prefixes[p] === FX)!; - const used = { ...built.prefixes }; - const body: string[] = []; - if (current.optionTriples.length) { - used[fxPrefix] = FX; - body.push( - `${fxPrefix}:properties ` + - current.optionTriples - .map((t) => `${fxPrefix}:${t.name} ${t.raw}`) - .join(` ;\n${indent} ${" ".repeat(fxPrefix.length + 12)}`) + - " .", - ); - } - body.push(...built.lines); - const text = "\n" + body.map((line) => `${indent} ${line}`).join("\n") + `\n${indent}`; - doc.replaceRange(text, doc.posFromIndex(current.openBrace + 1), doc.posFromIndex(current.closeBrace)); - yasqe.addPrefixes(used); - yasqe.focus(); - }); - const insert = button("Insert at cursor", "yasgui-sa__button", "Insert the patterns at the cursor"); - insert.addEventListener("click", () => { - if (!yasqe) return; - yasqe.replaceSelection(built.lines.join("\n")); - yasqe.addPrefixes(built.prefixes); - yasqe.focus(); - }); - const newTab = button("New tab", "yasgui-sa__button", "Open a SELECT query for these values in a new tab"); - newTab.addEventListener("click", () => this.openInNewTab(tab, buildQuery(picked, source, queryPrefixes))); - actions.append(replace, insert, newTab); - wrapper.append(actions); + if (built) { + const pre = el("pre", "yasgui-sa__code"); + pre.textContent = built.lines.join("\n"); + wrapper.append(pre); + } return wrapper; } diff --git a/packages/yasgui/src/sparqlAnything/SparqlAnything.scss b/packages/yasgui/src/sparqlAnything/SparqlAnything.scss index 06765280..c262c36c 100644 --- a/packages/yasgui/src/sparqlAnything/SparqlAnything.scss +++ b/packages/yasgui/src/sparqlAnything/SparqlAnything.scss @@ -326,6 +326,12 @@ border-color: var(--yasgui-line-strong, #808893); } + &:disabled { + opacity: 0.5; + cursor: default; + pointer-events: none; + } + &.primary { border-color: var(--yasgui-brand-fill, #0563bb); background: var(--yasgui-brand-fill, #0563bb); @@ -482,11 +488,92 @@ color: var(--yasgui-ink-muted, #56606c); } - &__actions { + // The query builder's bar: selection on the left, actions on the right; stays at the top of the scrolling body + &__buildbar { + position: sticky; + z-index: 1; + top: -8px; display: flex; flex-wrap: wrap; + align-items: center; + justify-content: space-between; gap: 6px; - margin-top: 10px; + margin: -8px -6px 6px; + padding: 6px 4px 6px 10px; + border-bottom: 1px solid var(--yasgui-line, #dfe5ec); + background: var(--yasgui-surface-raised, #fff); + } + + &__all { + display: flex; + align-items: center; + gap: 6px; + color: var(--yasgui-ink-body, #3a4552); + font-size: 12px; + cursor: pointer; + user-select: none; + } + + &__allCount { + padding: 0 6px; + border-radius: 999px; + background: var(--yasgui-surface-sunken, #f2f5f8); + color: var(--yasgui-ink-subtle, #6a7380); + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 18px; + } + + &__buildActions { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; + } + + &__buildButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-width: 28px; + height: 28px; + padding: 0 6px; + border: 0; + border-radius: var(--yasgui-radius-sm, 6px); + background: transparent; + color: var(--yasgui-ink-muted, #56606c); + font: inherit; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + + .yasgui-btnLabel { + font-size: 12px; + } + + &:hover { + background: var(--yasgui-surface-sunken, #f2f5f8); + color: var(--yasgui-ink, #1c2633); + } + + &.primary { + margin-right: 2px; + padding: 0 9px 0 7px; + background: var(--yasgui-brand-fill, #0563bb); + color: var(--yasgui-on-brand, #fff); + + &:hover { + background: var(--yasgui-brand-fill-strong, #044a8c); + } + } + + &:disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; + } } &__footer { diff --git a/packages/yasgui/src/sparqlAnything/SparqlAnythingSupport.ts b/packages/yasgui/src/sparqlAnything/SparqlAnythingSupport.ts index 31ad3ca5..e082ea8d 100644 --- a/packages/yasgui/src/sparqlAnything/SparqlAnythingSupport.ts +++ b/packages/yasgui/src/sparqlAnything/SparqlAnythingSupport.ts @@ -230,7 +230,10 @@ export default class SparqlAnythingSupport { label: `${state?.status === "done" ? "Reload" : "Load"} structure of ${source.label}`, icon: "fileSearch", keywords: "sparql anything source columns keys autocomplete facade-x", - run: () => void this.load(tab, source), + run: () => { + this.view.show(source); + void this.load(tab, source); + }, }); items.push({ section: "commands", @@ -292,6 +295,8 @@ export default class SparqlAnythingSupport { add(muted("·")); add( action("Load structure", "Send a query to the endpoint to read the structure of this source", () => { + // Open the Source view, so the progress and the result show up next to the editor + inspect(); void this.load(tab, source); }), ); @@ -306,7 +311,17 @@ export default class SparqlAnythingSupport { add(iconElement("alert", 13)); add(name); add(muted("· could not load")); - add(action("Retry", state.error || "", () => void this.load(tab, source), "danger")); + add( + action( + "Retry", + state.error || "", + () => { + inspect(); + void this.load(tab, source); + }, + "danger", + ), + ); chip.title = state.error || ""; } else { chip.classList.add("loaded"); diff --git a/test/sparql-anything-browser.ts b/test/sparql-anything-browser.ts index 7c2feb38..bc2c33b3 100644 --- a/test/sparql-anything-browser.ts +++ b/test/sparql-anything-browser.ts @@ -150,6 +150,54 @@ describe("SPARQL Anything", function () { ); }); + it("opens the Source view when loading the structure", async function () { + await page.evaluate(() => (window as any).yasgui.sidePanel.close()); + await loadStructure(); + const panel = await page.evaluate(() => { + const sidePanel = (window as any).yasgui.sidePanel; + return { open: sidePanel.isOpen(), view: sidePanel.getView() }; + }); + expect(panel).to.deep.equal({ open: true, view: "source" }); + }); + + it("selects all values in the query builder", async function () { + await loadStructure(); + await page.evaluate(() => + Array.from(document.querySelectorAll(".yasgui-sa__tab")) + .find((b) => b.textContent === "Build query") + ?.click(), + ); + await page.waitForSelector(".yasgui-sa__all input", { visible: true }); + // The actions are above the tree and wait for a selection + expect( + await page.$eval(".yasgui-sa__section", (section) => { + const actions = section.querySelector(".yasgui-sa__buildbar")!; + const tree = section.querySelector(".yasgui-sa__tree")!; + return !!(actions.compareDocumentPosition(tree) & Node.DOCUMENT_POSITION_FOLLOWING); + }), + ).to.equal(true); + expect(await page.$eval(".yasgui-sa__buildbar .primary", (b) => (b as HTMLButtonElement).disabled)).to.equal(true); + + // The view redraws often, so click the current checkbox rather than a handle to it + await page.evaluate(() => document.querySelector(".yasgui-sa__all input")?.click()); + const checked = await page.$$eval(".yasgui-sa__node .yasgui-sa__check", (boxes) => + boxes.map((b) => (b as HTMLInputElement).checked), + ); + expect(checked.length).to.be.greaterThan(1); + expect(checked.every(Boolean)).to.equal(true); + expect(await page.$eval(".yasgui-sa__allCount", (el) => el.textContent)).to.equal( + `${checked.length}/${checked.length}`, + ); + const code = await page.$eval(".yasgui-sa__code", (el) => el.textContent || ""); + expect(code).to.contain("xyz:email"); + expect(code).to.contain("xyz:name"); + + // Unticking clears the selection + // The view redraws often, so click the current checkbox rather than a handle to it + await page.evaluate(() => document.querySelector(".yasgui-sa__all input")?.click()); + expect(await page.$$eval(".yasgui-sa__node .yasgui-sa__check:checked", (boxes) => boxes.length)).to.equal(0); + }); + it("suggests the columns in autocompletion", async function () { await loadStructure(); await page.evaluate(() => { @@ -201,8 +249,8 @@ describe("SPARQL Anything", function () { ); row?.querySelector(".yasgui-sa__check")?.click(); }); - await page.waitForSelector(".yasgui-sa__actions .primary", { visible: true }); - await page.click(".yasgui-sa__actions .primary"); + await page.waitForSelector(".yasgui-sa__buildbar .primary", { visible: true }); + await page.click(".yasgui-sa__buildbar .primary"); const value = await page.evaluate(() => (window as any).yasgui.getTab().getYasqe().getValue()); expect(value).to.contain("?root fx:anySlot ?row ."); expect(value).to.contain("?row xyz:email ?email .");