From 7305e60726c34d5b96bdeeee54b6f8f65fb6e178 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Tue, 27 Oct 2026 15:23:52 +0000 Subject: [PATCH 1/2] 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 --- .changeset/sparql-anything-builder-tweaks.md | 5 + docs/user-guide.md | 4 +- .../yasgui/src/sparqlAnything/SourceView.ts | 132 +++++++++++------- .../src/sparqlAnything/SparqlAnything.scss | 32 +++++ .../sparqlAnything/SparqlAnythingSupport.ts | 19 ++- test/sparql-anything-browser.ts | 48 +++++++ 6 files changed, 185 insertions(+), 55 deletions(-) create mode 100644 .changeset/sparql-anything-builder-tweaks.md diff --git a/.changeset/sparql-anything-builder-tweaks.md b/.changeset/sparql-anything-builder-tweaks.md new file mode 100644 index 00000000..f5cb9057 --- /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 its buttons at the top, and an **All values** checkbox to tick every value of the source at once. diff --git a/docs/user-guide.md b/docs/user-guide.md index 6a914942..a6486d86 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 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:` 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. 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/sparqlAnything/SourceView.ts b/packages/yasgui/src/sparqlAnything/SourceView.ts index 1d8df248..69b31b79 100644 --- a/packages/yasgui/src/sparqlAnything/SourceView.ts +++ b/packages/yasgui/src/sparqlAnything/SourceView.ts @@ -490,8 +490,83 @@ 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; + + // The actions stay on top, so they're in reach without scrolling past a long tree + const toolbar = el("div", "yasgui-sa__actions top"); + 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 = "Select all values, or clear the selection"; + allLabel.append(all, el("span", undefined, picks.length ? `${picks.length} of ${values.length}` : "All values")); + toolbar.append(allLabel); + + 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 || !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 = button("Insert at cursor", "yasgui-sa__button", "Insert the patterns at the cursor"); + insert.addEventListener("click", () => { + if (!yasqe || !built) 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", () => { + if (built) this.openInNewTab(tab, buildQuery(pickedValues, source, queryPrefixes)); + }); + for (const action of [replace, insert, newTab]) action.disabled = !built; + toolbar.append(replace, insert, newTab); + 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 +609,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..729b960d 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); @@ -487,6 +493,32 @@ flex-wrap: wrap; gap: 6px; margin-top: 10px; + + // The query builder's actions stay at the top of the scrolling body + &.top { + position: sticky; + z-index: 1; + top: -8px; + align-items: center; + margin: -8px -6px 8px; + padding: 8px 6px; + border-bottom: 1px solid var(--yasgui-line, #dfe5ec); + background: var(--yasgui-surface-raised, #fff); + } + } + + // Below the action buttons, on its own line + &__all { + display: flex; + flex-basis: 100%; + order: 1; + align-items: center; + gap: 6px; + padding: 2px 0 0 4px; + color: var(--yasgui-ink-body, #3a4552); + font-size: 12px; + cursor: pointer; + user-select: 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..038bb550 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__actions")!; + const tree = section.querySelector(".yasgui-sa__tree")!; + return !!(actions.compareDocumentPosition(tree) & Node.DOCUMENT_POSITION_FOLLOWING); + }), + ).to.equal(true); + expect(await page.$eval(".yasgui-sa__actions .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__all", (el) => el.textContent)).to.equal( + `${checked.length} of ${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(() => { From 5ccec0483d9042ac6192c9d4a18a347f0ef6c308 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Tue, 27 Oct 2026 21:26:05 +0000 Subject: [PATCH 2/2] style(yasgui): compact Build query bar with icon actions One row at the top of the Build query view: All checkbox with a ticked count on the left, Replace block and icon buttons for insert at cursor and new tab on the right. --- .changeset/sparql-anything-builder-tweaks.md | 2 +- docs/user-guide.md | 2 +- packages/yasgui/src/icons.ts | 4 + .../yasgui/src/sparqlAnything/SourceView.ts | 39 +++++--- .../src/sparqlAnything/SparqlAnything.scss | 91 +++++++++++++++---- test/sparql-anything-browser.ts | 12 +-- 6 files changed, 112 insertions(+), 38 deletions(-) diff --git a/.changeset/sparql-anything-builder-tweaks.md b/.changeset/sparql-anything-builder-tweaks.md index f5cb9057..9cbdf1b5 100644 --- a/.changeset/sparql-anything-builder-tweaks.md +++ b/.changeset/sparql-anything-builder-tweaks.md @@ -2,4 +2,4 @@ "@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 its buttons at the top, and an **All values** checkbox to tick every value of the source at once. +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 a6486d86..53f01b16 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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, 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:` 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. +- **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 69b31b79..cff5aa70 100644 --- a/packages/yasgui/src/sparqlAnything/SourceView.ts +++ b/packages/yasgui/src/sparqlAnything/SourceView.ts @@ -498,8 +498,8 @@ export default class SourceView { const pickedValues: PickedValue[] = picks.map((p) => ({ node: nodesByKey.get(p.key)!, optional: p.optional })); const built = pickedValues.length ? buildPatterns(pickedValues, source, queryPrefixes) : undefined; - // The actions stay on top, so they're in reach without scrolling past a long tree - const toolbar = el("div", "yasgui-sa__actions top"); + // 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"; @@ -515,15 +515,20 @@ export default class SourceView { } setPicks(next); }); - allLabel.title = "Select all values, or clear the selection"; - allLabel.append(all, el("span", undefined, picks.length ? `${picks.length} of ${values.length}` : "All values")); + 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 = button( - "Replace block", - "yasgui-sa__button primary", - "Replace the content of the SERVICE block (Ctrl+Z undoes it)", - ); + 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); @@ -550,19 +555,29 @@ export default class SourceView { yasqe.addPrefixes(used); yasqe.focus(); }); - const insert = button("Insert at cursor", "yasgui-sa__button", "Insert the patterns at the cursor"); + 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 = button("New tab", "yasgui-sa__button", "Open a SELECT query for these values in a new tab"); + 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; - toolbar.append(replace, insert, newTab); + 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.")); diff --git a/packages/yasgui/src/sparqlAnything/SparqlAnything.scss b/packages/yasgui/src/sparqlAnything/SparqlAnything.scss index 729b960d..c262c36c 100644 --- a/packages/yasgui/src/sparqlAnything/SparqlAnything.scss +++ b/packages/yasgui/src/sparqlAnything/SparqlAnything.scss @@ -488,39 +488,94 @@ 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; - - // The query builder's actions stay at the top of the scrolling body - &.top { - position: sticky; - z-index: 1; - top: -8px; - align-items: center; - margin: -8px -6px 8px; - padding: 8px 6px; - border-bottom: 1px solid var(--yasgui-line, #dfe5ec); - background: var(--yasgui-surface-raised, #fff); - } + margin: -8px -6px 6px; + padding: 6px 4px 6px 10px; + border-bottom: 1px solid var(--yasgui-line, #dfe5ec); + background: var(--yasgui-surface-raised, #fff); } - // Below the action buttons, on its own line &__all { display: flex; - flex-basis: 100%; - order: 1; align-items: center; gap: 6px; - padding: 2px 0 0 4px; 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 { display: flex; flex-wrap: wrap; diff --git a/test/sparql-anything-browser.ts b/test/sparql-anything-browser.ts index 038bb550..bc2c33b3 100644 --- a/test/sparql-anything-browser.ts +++ b/test/sparql-anything-browser.ts @@ -171,12 +171,12 @@ describe("SPARQL Anything", function () { // 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__actions")!; + 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__actions .primary", (b) => (b as HTMLButtonElement).disabled)).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()); @@ -185,8 +185,8 @@ describe("SPARQL Anything", function () { ); expect(checked.length).to.be.greaterThan(1); expect(checked.every(Boolean)).to.equal(true); - expect(await page.$eval(".yasgui-sa__all", (el) => el.textContent)).to.equal( - `${checked.length} of ${checked.length}`, + 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"); @@ -249,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 .");