From f7b948288ccadf2ee3e6f50710ff7f256594fe12 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Fri, 6 Nov 2026 23:03:10 +0000 Subject: [PATCH 1/2] feat(yasgui): hide the query editor and redesign the share popup Hide query folds the editor to one line so the results take the card (#258). The state is kept per tab, can be toggled from Ctrl+K, and share links open that way with hideQuery=true ("Show only the results" in the share popup). An update or invalid query from a link is always shown. New tabs can start hidden with the hideQuery config option. The share popup moves to its own module in the MatData look: the link with a Copy button, the option, the shortener and the cURL, PowerShell and wget commands, with inline Copied feedback and a credentials note instead of toasts. It closes on Escape and spans a narrow viewport. --- .changeset/hide-query.md | 6 + packages/yasgui/src/CommandPalette.ts | 7 + packages/yasgui/src/Tab.ts | 83 ++++- packages/yasgui/src/defaults.ts | 1 + packages/yasgui/src/index.ts | 10 + packages/yasgui/src/linkUtils.ts | 15 +- packages/yasgui/src/tab.scss | 101 +++++- packages/yasgui/src/themes.scss | 12 - packages/yasgui/src/whatsNew/releases.ts | 9 + packages/yasqe/src/index.ts | 266 +--------------- packages/yasqe/src/scss/buttons.scss | 380 ++++++++++++++++------- packages/yasqe/src/sharePopup.ts | 306 ++++++++++++++++++ test/share-link-browser.ts | 29 ++ 13 files changed, 836 insertions(+), 389 deletions(-) create mode 100644 .changeset/hide-query.md create mode 100644 packages/yasqe/src/sharePopup.ts diff --git a/.changeset/hide-query.md b/.changeset/hide-query.md new file mode 100644 index 00000000..d6067300 --- /dev/null +++ b/.changeset/hide-query.md @@ -0,0 +1,6 @@ +--- +"@matdata/yasgui": minor +"@matdata/yasqe": minor +--- + +Hide the query editor so the results take the whole card (#258): a "Hide query" button in the editor toolbar and a Ctrl+K command, remembered per tab. Share links can open with the query hidden (`hideQuery=true`, offered as "Show only the results" in the share popup), and the `hideQuery` config option opens new tabs that way. An update or invalid query from a link is always shown. The share popup is redesigned in the MatData look: the link with a Copy button, the option, the shortener and the cURL, PowerShell and wget commands, with inline "Copied" feedback instead of toasts. Yasqe's `createShareableLink` now receives `{ hideQuery }`, and the new `shareHideQueryOption` config shows the option. diff --git a/packages/yasgui/src/CommandPalette.ts b/packages/yasgui/src/CommandPalette.ts index 8b932cc9..5fb975a8 100644 --- a/packages/yasgui/src/CommandPalette.ts +++ b/packages/yasgui/src/CommandPalette.ts @@ -364,6 +364,13 @@ export default class CommandPalette { keywords: "orientation stacked horizontal vertical", run: () => tab()?.toggleOrientation(), }, + { + section: "commands", + label: tab()?.isQueryHidden() ? "Show query" : "Hide query", + icon: "code", + keywords: "editor collapse expand results only toggle", + run: () => tab()?.toggleQueryHidden(), + }, ); const tools: Array<[string, string, IconName, string]> = [ [".yasqe_formatButton", "Format query", "alignLeft", "beautify indent"], diff --git a/packages/yasgui/src/Tab.ts b/packages/yasgui/src/Tab.ts index 0e74bf27..69bee326 100644 --- a/packages/yasgui/src/Tab.ts +++ b/packages/yasgui/src/Tab.ts @@ -51,6 +51,8 @@ export interface PersistedJson { requestConfig: YasguiRequestConfig; orientation?: "vertical" | "horizontal"; managedQuery?: ManagedTabMetadata; + /** The query editor is hidden, so the results take the whole card (e.g. a link to a result) */ + queryHidden?: boolean; } export interface Tab { @@ -94,6 +96,7 @@ export class Tab extends EventEmitter { private titleEl?: HTMLDivElement; private currentOrientation: "vertical" | "horizontal"; private orientationToggleButton?: HTMLButtonElement; + private queryToggleButton?: HTMLButtonElement; private editorToolsEl?: HTMLDivElement; private verticalResizerEl?: HTMLDivElement; private editorWrapperEl?: HTMLDivElement; @@ -401,6 +404,7 @@ export class Tab extends EventEmitter { // Apply orientation class addClass(this.rootEl, `orientation-${this.currentOrientation}`); + if (this.isQueryHidden()) addClass(this.rootEl, "queryHidden"); // We group controlbar and Yasqe, so that users can easily .appendChild() to the .editorwrapper div // to add a div that goes alongside the controlbar and editor, while YASR still goes full width @@ -759,7 +763,73 @@ export class Tab extends EventEmitter { } this.initOrientationToggle(); + this.initQueryToggle(); this.yasqeWrapperEl.appendChild(toolbar); + + // Shown instead of the editor while the query is hidden + const note = document.createElement("button"); + note.type = "button"; + addClass(note, "yasgui-queryHiddenNote"); + note.innerHTML = `${icon("code", 14)}The query is hidden`; + note.title = "Show the query"; + note.addEventListener("click", () => this.setQueryHidden(false)); + this.yasqeWrapperEl.appendChild(note); + } + + /** Button at the end of the editor toolbar that hides or shows the query editor */ + private initQueryToggle() { + if (!this.editorToolbarEl) return; + const button = document.createElement("button"); + button.type = "button"; + button.className = "yasgui-editorToolbar__button yasgui-queryToggle"; + button.addEventListener("click", () => this.toggleQueryHidden()); + this.queryToggleButton = button; + this.updateQueryToggle(); + this.editorToolbarEl.appendChild(button); + } + + private updateQueryToggle() { + const button = this.queryToggleButton; + if (!button) return; + const hidden = this.isQueryHidden(); + const label = hidden ? "Show query" : "Hide query"; + button.innerHTML = `${icon(hidden ? "chevronDown" : "chevronUp")}${label}`; + button.title = hidden ? "Show the query editor" : "Hide the query editor and show only the results"; + button.setAttribute("aria-label", label); + button.setAttribute("aria-expanded", hidden ? "false" : "true"); + } + + /** Whether the query editor of this tab is hidden, showing only the results */ + public isQueryHidden(): boolean { + return !!this.persistentJson.queryHidden; + } + + /** Hide the query editor so the results take the whole card, or show it again */ + public setQueryHidden(hidden: boolean) { + if (this.isQueryHidden() === hidden) return; + if (hidden) { + this.persistentJson.queryHidden = true; + if (this.yasqe?.getIsFullscreen()) this.yasqe.toggleFullscreen(); + } else { + delete this.persistentJson.queryHidden; + } + if (this.rootEl) { + if (hidden) addClass(this.rootEl, "queryHidden"); + else removeClass(this.rootEl, "queryHidden"); + } + this.updateQueryToggle(); + if (!hidden) { + this.yasqe?.refresh(); + this.yasqe?.focus(); + } else { + this.queryToggleButton?.focus(); + } + this.yasr?.refresh(); + this.emit("change", this, this.persistentJson); + } + + public toggleQueryHidden() { + this.setQueryHidden(!this.isQueryHidden()); } /** The panel of this tab, holding the query card */ @@ -1107,12 +1177,13 @@ export class Tab extends EventEmitter { this.getTabListEl().redrawContextMenu(); } - public getShareableLink(baseURL?: string): string { - return shareLink.createShareLink(baseURL || window.location.href, this); + /** Link that opens this query; `hideQuery` opens it with the query editor hidden */ + public getShareableLink(baseURL?: string, options?: shareLink.ShareLinkOptions): string { + return shareLink.createShareLink(baseURL || window.location.href, this, options); } - public getShareObject() { - return shareLink.createShareConfig(this); + public getShareObject(options?: shareLink.ShareLinkOptions) { + return shareLink.createShareConfig(this, options); } private getTabListEl(): TabListEl { @@ -1423,7 +1494,8 @@ export class Tab extends EventEmitter { editorHeight: this.persistentJson.yasqe.editorHeight ? this.persistentJson.yasqe.editorHeight : undefined, persistenceId: null, //yasgui handles persistent storing consumeShareLink: null, //not handled by this tab, but by parent Matgui instance - createShareableLink: () => this.getShareableLink(), + createShareableLink: (_yasqe, options) => this.getShareableLink(undefined, options), + shareHideQueryOption: () => this.isQueryHidden(), // The snippets bar is off unless enabled (config or settings); the snippets are in the Insert menu showSnippetsBar: this.yasgui.config.showSnippetsBar === true, // Yasgui shows its own Run button in the query bar @@ -2074,6 +2146,7 @@ export class Tab extends EventEmitter { requestConfig: yasgui ? yasgui.config.requestConfig : { ...Yasgui.defaults.requestConfig }, id: getRandomId(), name: yasgui ? yasgui.createTabName() : Yasgui.defaults.tabName, + ...(yasgui?.config.hideQuery ? { queryHidden: true } : {}), }; } } diff --git a/packages/yasgui/src/defaults.ts b/packages/yasgui/src/defaults.ts index acb591ed..28b50fdf 100644 --- a/packages/yasgui/src/defaults.ts +++ b/packages/yasgui/src/defaults.ts @@ -35,6 +35,7 @@ export default function initialize(): Config { showQuickStart: true, quickStartOnFirstVisit: true, orientation: "vertical", + hideQuery: false, showSnippetsBar: false, iriHover: true, iriHoverLovFallback: true, diff --git a/packages/yasgui/src/index.ts b/packages/yasgui/src/index.ts index 8aef69c3..125ce3fe 100644 --- a/packages/yasgui/src/index.ts +++ b/packages/yasgui/src/index.ts @@ -153,6 +153,11 @@ export interface Config { * 'horizontal': YASQE on left, YASR on right */ orientation?: "vertical" | "horizontal"; + /** + * Open new tabs with the query editor hidden, so only the results show (default: false). Users can + * show the query with the "Show query" button. Share links can also set this with `hideQuery=true`. + */ + hideQuery?: boolean; /** * Show the code snippets bar above the editor in all tabs (global setting). * Off by default: the snippets are available from the Insert menu of the query bar. @@ -335,6 +340,9 @@ export class Yasgui extends EventEmitter { if (tabId) { // when a config is already present, const persistentYasr = this.persistentConfig.getTab(tabId).yasr; + const existingTab = this.persistentConfig.getTab(tabId); + if (optionsFromUrl.queryHidden) existingTab.queryHidden = true; + else delete existingTab.queryHidden; this.persistentConfig.getTab(tabId).yasr = { // Override the settings settings: optionsFromUrl.yasr.settings, @@ -481,6 +489,8 @@ export class Yasgui extends EventEmitter { tab.query().catch(() => {}); return; } + // An update or invalid query is not run, so the reader needs to see it + if ((blocker === "update" || blocker === "invalid") && tab.isQueryHidden()) tab.setQueryHidden(false); const canRun = blocker === "credentials" || blocker === "disabled"; showToast(shareLink.sharedQueryBlockedMessage(blocker, tab.getEndpoint()), { host: this.rootEl, diff --git a/packages/yasgui/src/linkUtils.ts b/packages/yasgui/src/linkUtils.ts index 2425c3bd..385201cd 100644 --- a/packages/yasgui/src/linkUtils.ts +++ b/packages/yasgui/src/linkUtils.ts @@ -3,9 +3,12 @@ import JsUri from "jsuri"; import { default as Tab, PersistedJson } from "./Tab"; import Yasr from "@matdata/yasr"; import { PlainRequestConfig } from "@matdata/yasqe"; +import type { ShareLinkOptions } from "@matdata/yasqe"; import { getAsValue } from "@matdata/yasgui-utils"; import { parseJsonParam } from "./shareLinkSafety"; export * from "./shareLinkSafety"; +/** Options of a share link, picked in the share popup */ +export type { ShareLinkOptions } from "@matdata/yasqe"; var getUrlParams = function (_url?: string) { var urlFromWindow = false; if (!_url) { @@ -37,10 +40,10 @@ export function appendArgsToUrl(_url: string, args: RequestArgs): string { } return url.toString(); } -export function createShareLink(forUrl: string, tab: Tab) { +export function createShareLink(forUrl: string, tab: Tab, options?: ShareLinkOptions) { const currentUrl = new JsUri(forUrl); const tmpUrl = new JsUri(); - const configObject = createShareConfig(tab); + const configObject = createShareConfig(tab, options); let key: keyof ShareConfigObject; // Need to specify here because left hand side of a for..in cannot be typed for (key in configObject) { if (!configObject.hasOwnProperty(key)) continue; @@ -85,9 +88,11 @@ export type ShareConfigObject = { defaultGraphs: PlainRequestConfig["defaultGraphs"]; outputFormat?: string; outputSettings: any; + /** Open the link with the query editor hidden */ + hideQuery?: boolean; }; -export function createShareConfig(tab: Tab): ShareConfigObject { +export function createShareConfig(tab: Tab, options?: ShareLinkOptions): ShareConfigObject { const yasgui = tab.yasgui; const requestConfig = tab.getRequestConfig(); const yasrPersistentSetting = tab.getPersistedJson().yasr.settings; @@ -108,6 +113,7 @@ export function createShareConfig(tab: Tab): ShareConfigObject { yasrPersistentSetting.pluginsConfig && yasrPersistentSetting.selectedPlugin ? yasrPersistentSetting.pluginsConfig[yasrPersistentSetting.selectedPlugin] : undefined, + ...(options?.hideQuery ? { hideQuery: true } : {}), }; } @@ -140,6 +146,9 @@ export function getConfigFromUrl(defaults: PersistedJson, _url?: string): Persis options.requestConfig.method = value; } else if (key == "tabTitle") { options.name = value; + } else if (key == "hideQuery") { + if (value === "false" || value === "0") delete options.queryHidden; + else options.queryHidden = true; } else if (key == "namedGraph") { if (!Array.isArray(options.requestConfig.namedGraphs)) options.requestConfig.namedGraphs = []; options.requestConfig.namedGraphs.push(value); diff --git a/packages/yasgui/src/tab.scss b/packages/yasgui/src/tab.scss index ccab4d70..a7977927 100644 --- a/packages/yasgui/src/tab.scss +++ b/packages/yasgui/src/tab.scss @@ -554,7 +554,6 @@ } // Popups open below the toolbar instead of above the floating buttons - .yasqe_sharePopup, .yasqe_hamburgerMenu { position: absolute !important; top: calc(100% + 6px) !important; @@ -1008,3 +1007,103 @@ } } } + +// Query hidden (issue #258): the editor folds to one line with the editor toolbar, so the results +// take the card. Share and save stay in the toolbar; the editing tools are hidden. +.yasgui .tabPanel { + .yasgui-queryHiddenNote { + display: none; + } + + &.queryHidden { + .yasgui-editorArea { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 40px; + padding: 0 0 4px 20px; + border-bottom: 1px solid var(--yasgui-line-soft, #edf0f4); + + > .yasqe { + display: none !important; + } + } + + .yasgui-queryHiddenNote { + order: -1; + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + height: 28px; + padding: 0 8px; + margin-left: -8px; + border: none; + border-radius: var(--yasgui-radius-sm, 6px); + background: transparent; + color: var(--yasgui-ink-subtle, #6a7380); + font: inherit; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + + &:hover { + background: var(--yasgui-surface-sunken, #edf1f6); + color: var(--yasgui-ink, #1c2633); + } + } + + .yasgui-editorToolbar { + position: static; + flex-shrink: 0; + + > :not(.yasgui-editorTools):not(.yasgui-queryToggle) { + display: none; + } + + .yasqe_formatButton, + .yasqe_fullscreenButton { + display: none !important; + } + } + + // Side-by-side layout: the folded editor goes above the results + &.orientation-horizontal { + &.active { + flex-direction: column; + } + + .editorwrapper { + flex: 0 0 auto !important; + width: auto !important; + min-width: 0; + max-width: none; + height: auto; + overflow: visible; + + > div, + > div > .yasgui-editorArea { + flex: 0 0 auto; + overflow: visible; + } + + > div > .yasgui-editorArea { + flex-direction: row; + } + } + + .verticalResizeWrapper { + display: none; + } + + .yasr { + border-radius: 0 0 var(--yasgui-radius-md, 10px) var(--yasgui-radius-md, 10px); + } + + .yasr .yasr_header { + padding-top: 0; + } + } + } +} diff --git a/packages/yasgui/src/themes.scss b/packages/yasgui/src/themes.scss index e11b7372..d33759e1 100644 --- a/packages/yasgui/src/themes.scss +++ b/packages/yasgui/src/themes.scss @@ -315,18 +315,6 @@ } } - .yasqe_sharePopup { - background-color: var(--yasgui-bg-primary) !important; - border-color: var(--yasgui-border-color) !important; - - input, - textarea { - background-color: var(--yasgui-bg-secondary); - color: var(--yasgui-text-primary); - border-color: var(--yasgui-border-color); - } - } - .yasqe_mapPopup { background-color: var(--yasgui-bg-primary); border-color: var(--yasgui-border-color); diff --git a/packages/yasgui/src/whatsNew/releases.ts b/packages/yasgui/src/whatsNew/releases.ts index 09c67319..b9fcc337 100644 --- a/packages/yasgui/src/whatsNew/releases.ts +++ b/packages/yasgui/src/whatsNew/releases.ts @@ -24,6 +24,15 @@ const i = (text: string): ReleaseChange => ({ kind: "improved", text }); const f = (text: string): ReleaseChange => ({ kind: "fixed", text }); export const releases: Release[] = [ + { + version: "6.14.0", + summary: "Show only the results, and a redesigned share popup.", + changes: [ + n("Hide query folds the editor away so the results take the whole card (also with Ctrl+K)"), + n("Share links can open with the query hidden: tick Show only the results in the share popup"), + i("The share popup has the new look, shows the link with a Copy button and confirms each copy"), + ], + }, { version: "6.13.0", date: "2026-11-03", diff --git a/packages/yasqe/src/index.ts b/packages/yasqe/src/index.ts index 9a75dd96..9ac2dd71 100644 --- a/packages/yasqe/src/index.ts +++ b/packages/yasqe/src/index.ts @@ -33,11 +33,8 @@ import { spfmt } from "@matdata/sparql-formatter"; import * as L from "leaflet"; import { coordinatesToWkt, wrapWktLiteral, WktCoordinate, WktGeometryType } from "./mapWidget"; -// Toast notification timing constants -const TOAST_DEFAULT_DURATION = 3000; // 3 seconds -const TOAST_WARNING_DURATION = 4000; // 4 seconds for warnings -const TOAST_WARNING_DELAY = 500; // Delay before showing auth warning -const TOAST_FADEOUT_DURATION = 300; // Fade out animation duration +import { showSharePopup as openSharePopup, ShareLinkOptions } from "./sharePopup"; +export type { ShareLinkOptions } from "./sharePopup"; export interface Yasqe { on(eventName: "query", handler: (instance: Yasqe, req: Request, abortController?: AbortController) => void): void; @@ -397,251 +394,14 @@ export class Yasqe extends EditorFacade { shareLinkWrapper.appendChild(shareIcon); buttons.appendChild(shareLinkWrapper); this.shareBtn = shareLinkWrapper; - shareLinkWrapper.addEventListener("click", (event: MouseEvent) => showSharePopup(event)); - shareLinkWrapper.addEventListener("keydown", (event: KeyboardEvent) => { - if (event.code === "Enter") { - showSharePopup(event); - } - }); - - const showSharePopup = (event: MouseEvent | KeyboardEvent) => { + shareLinkWrapper.type = "button"; + shareLinkWrapper.setAttribute("aria-haspopup", "dialog"); + shareLinkWrapper.setAttribute("aria-expanded", "false"); + // A button also fires click on Enter and Space + shareLinkWrapper.addEventListener("click", (event: MouseEvent) => { event.stopPropagation(); - let popup: HTMLDivElement | undefined = document.createElement("div"); - popup.className = "yasqe_sharePopup"; - buttons.appendChild(popup); - - // Toast notification element for warnings - let toastElement: HTMLDivElement | undefined; - let toastTimeout: number | undefined; - - const showToast = ( - message: string, - duration: number = TOAST_DEFAULT_DURATION, - type: "info" | "warning" = "info", - ) => { - // Remove existing toast if any - if (toastElement) { - toastElement.remove(); - } - // Clear existing timeout - if (toastTimeout !== undefined) { - clearTimeout(toastTimeout); - } - - toastElement = document.createElement("div"); - toastElement.className = type === "warning" ? "yasqe_toast yasqe_toast-warning" : "yasqe_toast"; - - // Add warning icon for warning toasts - if (type === "warning") { - const iconWrapper = document.createElement("span"); - iconWrapper.className = "yasqe_toast-icon"; - const icon = document.createElement("i"); - addClass(icon, "fas"); - addClass(icon, "fa-exclamation-triangle"); - icon.setAttribute("aria-hidden", "true"); - iconWrapper.appendChild(icon); - toastElement.appendChild(iconWrapper); - } - - const messageSpan = document.createElement("span"); - messageSpan.className = "yasqe_toast-message"; - messageSpan.textContent = message; - toastElement.appendChild(messageSpan); - - document.body.appendChild(toastElement); - - // Auto-remove after duration - toastTimeout = window.setTimeout(() => { - if (toastElement) { - toastElement.classList.add("yasqe_toast-fadeout"); - setTimeout(() => { - toastElement?.remove(); - toastElement = undefined; - }, TOAST_FADEOUT_DURATION); - } - }, duration); - }; - - // Create event listener that can be removed - const closePopupHandler = (event: MouseEvent) => { - if (popup && event.target !== popup && !popup.contains(event.target)) { - popup.remove(); - popup = undefined; - // Clean up toast when popup closes - if (toastElement) { - toastElement.remove(); - toastElement = undefined; - } - if (toastTimeout !== undefined) { - clearTimeout(toastTimeout); - } - // Remove this event listener to prevent memory leak - document.body.removeEventListener("click", closePopupHandler, true); - } - }; - - document.body.addEventListener("click", closePopupHandler, true); - - popup.innerHTML = ""; - - // Helper function to copy text to clipboard - const copyToClipboard = async (text: string, buttonText: string, hasAuth: boolean = false) => { - try { - // Check if Clipboard API is available - if (!navigator.clipboard || !navigator.clipboard.writeText) { - // Fallback for older browsers or non-secure contexts - const textArea = document.createElement("textarea"); - textArea.value = text; - textArea.style.position = "fixed"; - textArea.style.left = "-999999px"; - document.body.appendChild(textArea); - textArea.select(); - try { - document.execCommand("copy"); - document.body.removeChild(textArea); - showToast(`${buttonText} copied to clipboard!`); - } catch (err) { - document.body.removeChild(textArea); - throw new Error("Copy command not supported"); - } - } else { - await navigator.clipboard.writeText(text); - showToast(`${buttonText} copied to clipboard!`); - } - - // Show warning if credentials are included - if (hasAuth) { - setTimeout(() => { - showToast( - "Warning: Authentication credentials included in copied content", - TOAST_WARNING_DURATION, - "warning", - ); - }, TOAST_WARNING_DELAY); - } - } catch (err) { - console.error("Failed to copy to clipboard:", err); - showToast("Failed to copy to clipboard. Please copy manually.", 2000); - } - }; - - // Create title - const title = document.createElement("div"); - title.className = "yasqe_sharePopup_title"; - title.textContent = "Share Query"; - popup.appendChild(title); - - // Create button container - const buttonContainer = document.createElement("div"); - buttonContainer.className = "yasqe_sharePopup_buttons"; - popup.appendChild(buttonContainer); - - // URL button - const urlBtn = document.createElement("button"); - urlBtn.innerText = "Copy URL"; - urlBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn"; - buttonContainer.appendChild(urlBtn); - urlBtn.onclick = async () => { - if (!this.config.createShareableLink) return; - const url = this.config.createShareableLink(this); - await copyToClipboard(url, "URL"); - }; - - // URL Shorten button (if configured) - const createShortLink = this.config.createShortLink; - if (createShortLink) { - const shortenBtn = document.createElement("button"); - shortenBtn.innerText = "Shorten URL"; - shortenBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn"; - buttonContainer.appendChild(shortenBtn); - shortenBtn.onclick = async () => { - shortenBtn.disabled = true; - shortenBtn.innerText = "Shortening..."; - try { - if (!this.config.createShareableLink) return; - const longUrl = this.config.createShareableLink(this); - const shortUrl = await createShortLink(this, longUrl); - await copyToClipboard(shortUrl, "Shortened URL"); - shortenBtn.innerText = "Shorten URL"; - shortenBtn.disabled = false; - } catch (err) { - shortenBtn.innerText = "Shorten URL"; - shortenBtn.disabled = false; - let errorMsg = "Failed to shorten URL"; - if (typeof err === "string" && err.length !== 0) { - errorMsg = err; - } else if ((err as any).message && (err as any).message.length !== 0) { - errorMsg = (err as any).message; - } - showToast(errorMsg, 3000); - } - }; - } - - // cURL button - const curlBtn = document.createElement("button"); - curlBtn.innerText = "Copy cURL"; - curlBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn"; - buttonContainer.appendChild(curlBtn); - curlBtn.onclick = async () => { - const curlString = this.getAsCurlString(); - const hasAuth = this.hasAuthenticationCredentials(); - await copyToClipboard(curlString, "cURL command", hasAuth); - }; - - // PowerShell button - const psBtn = document.createElement("button"); - psBtn.innerText = "Copy PowerShell"; - psBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn"; - buttonContainer.appendChild(psBtn); - psBtn.onclick = async () => { - const psString = this.getAsPowerShellString(); - const hasAuth = this.hasAuthenticationCredentials(); - await copyToClipboard(psString, "PowerShell command", hasAuth); - }; - - // wget button - const wgetBtn = document.createElement("button"); - wgetBtn.innerText = "Copy wget"; - wgetBtn.className = "yasqe_btn yasqe_btn-sm yasqe_shareBtn"; - buttonContainer.appendChild(wgetBtn); - wgetBtn.onclick = async () => { - const wgetString = this.getAsWgetString(); - const hasAuth = this.hasAuthenticationCredentials(); - await copyToClipboard(wgetString, "wget command", hasAuth); - }; - - // Position popup after layout is complete - const positionPopup = () => { - if (!popup) return; - const buttonsRect = buttons.getBoundingClientRect(); - const popupHeight = popup.offsetHeight || 300; // Estimated height - const viewportHeight = window.innerHeight; - const spaceAbove = buttonsRect.top; - const spaceBelow = viewportHeight - buttonsRect.bottom; - - // If there's enough space above (with some padding), position above - // Otherwise position below (will use scrolling if needed) - if (spaceAbove >= popupHeight + 20) { - popup.style.bottom = (buttons.clientHeight || 46) + "px"; - popup.style.top = "auto"; - popup.style.right = "0px"; - } else { - // Not enough space above, use fixed positioning at top of viewport - popup.style.position = "fixed"; - popup.style.bottom = "auto"; - popup.style.top = "20px"; - popup.style.right = "20px"; - } - }; - - if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { - window.requestAnimationFrame(positionPopup); - } else { - // Fallback for environments without requestAnimationFrame - setTimeout(positionPopup, 0); - } - }; + openSharePopup(this, buttons, shareLinkWrapper); + }); } /** @@ -2519,7 +2279,13 @@ export interface Config extends Omit { * By default, this feature is enabled, and the only the query value is appended to the link. * ps. This function should return an object which is parseable by jQuery.param (http://api.jquery.com/jQuery.param/) */ - createShareableLink: ((yasqe: Yasqe) => string) | undefined | null; + createShareableLink: ((yasqe: Yasqe, options?: ShareLinkOptions) => string) | undefined | null; + /** + * When set, the share popup offers "Show only the results", which passes `hideQuery` to + * `createShareableLink`. The function gives the initial state of the option (e.g. whether + * the query is hidden now). Undefined or null hides the option. + */ + shareHideQueryOption?: ((yasqe: Yasqe) => boolean) | null; createShortLink: ((yasqe: Yasqe, longLink: string) => Promise) | undefined; consumeShareLink: ((yasqe: Yasqe) => void) | undefined | null; /** diff --git a/packages/yasqe/src/scss/buttons.scss b/packages/yasqe/src/scss/buttons.scss index b5f73212..12cc6ef2 100644 --- a/packages/yasqe/src/scss/buttons.scss +++ b/packages/yasqe/src/scss/buttons.scss @@ -94,82 +94,295 @@ vertical-align: middle; margin-left: 8px; } + // Popup of the Share button: the link with its options, then the query as a command .yasqe_sharePopup { position: absolute; - padding: 12px; - margin-left: 0px; - background-color: var(--yasgui-bg-primary, #fff); - border: 1px solid var(--yasgui-border-color, #e3e3e3); - border-radius: 4px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - width: auto; - min-width: 300px; - max-width: 400px; + top: calc(100% + 6px); + right: 0; + z-index: 10001; + width: 360px; + max-width: calc(100vw - 24px); max-height: calc(100vh - 100px); overflow-y: auto; - height: auto; + box-sizing: border-box; display: flex; flex-direction: column; - gap: 8px; - z-index: 10001; + gap: 14px; + padding: 14px 14px 12px; + border-radius: 12px; + background: var(--yasgui-surface-raised, #ffffff); + box-shadow: var(--yasgui-shadow-overlay, 0 24px 48px rgba(13, 20, 28, 0.18), 0 0 0 1px rgba(13, 20, 28, 0.06)); + color: var(--yasgui-ink, #1c2633); + font-family: var(--yasgui-font-family, inherit); + font-size: 13px; + line-height: 1.4; + text-align: left; + cursor: default; + + &.above { + top: auto; + bottom: calc(100% + 6px); + } + + button { + margin: 0; + font: inherit; + vertical-align: initial; + } + + .yasqe_shareIcon { + flex-shrink: 0; + } + + .yasqe_sharePopup_header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } .yasqe_sharePopup_title { - font-weight: 600; font-size: 14px; - color: var(--yasgui-text-primary, #333); - margin-bottom: 4px; + font-weight: 600; + } + + .yasqe_sharePopup_close { + width: 26px; + height: 26px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--yasgui-radius-sm, 6px); + background: transparent; + color: var(--yasgui-ink-subtle, #6a7380); + cursor: pointer; + + &:hover { + background: var(--yasgui-surface-sunken, #edf0f4); + color: var(--yasgui-ink, #1c2633); + } } - .yasqe_sharePopup_buttons { + .yasqe_sharePopup_section { display: flex; flex-direction: column; + gap: 8px; + } + + .yasqe_sharePopup_label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--yasgui-ink-subtle, #6a7380); + } + + // Link field with its Copy button + .yasqe_sharePopup_linkRow { + display: flex; + align-items: center; gap: 6px; + height: 34px; + padding: 0 3px 0 10px; + border-radius: var(--yasgui-radius-button, 8px); + background: var(--yasgui-surface-sunken, #edf0f4); + color: var(--yasgui-ink-subtle, #6a7380); + + &:has(.yasqe_sharePopup_url:focus) { + box-shadow: 0 0 0 2px var(--yasgui-focus, #044a8c); + } } - .yasqe_shareBtn { - width: 100%; - text-align: left; - justify-content: flex-start; - padding: 8px 12px; - font-size: 13px; + .yasqe_sharePopup_linkIcon { + display: inline-flex; } - .inputWrapper { - flex-grow: 100; + .yasqe_sharePopup_url { + flex: 1; + min-width: 0; + height: 100%; + padding: 0; + border: none; + outline: none; + background: transparent; + color: var(--yasgui-ink, #1c2633); + font-family: var(--yasgui-font-mono, ui-monospace, monospace); + font-size: 12px; + text-overflow: ellipsis; } - input { - float: left; - width: 100%; - border: 0px; - -ms-box-sizing: border-box; - /* ie8 */ - -khtml-box-sizing: border-box; - /* konqueror */ - -webkit-box-sizing: border-box; - /* Safari/Chrome, other WebKit */ - -moz-box-sizing: border-box; - /* Firefox, other Gecko */ - box-sizing: border-box; - /* Opera/IE 8+ */ - box-sizing: border-box; - /* css3 rec */ + .yasqe_sharePopup_copy { + height: 28px; + flex-shrink: 0; + padding: 0 10px; + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + border-radius: var(--yasgui-radius-sm, 6px); + background: var(--yasgui-brand-fill, var(--yasgui-brand, #0563bb)); + color: var(--yasgui-on-brand, #ffffff); + font-weight: 600; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + + &:hover { + background: var(--yasgui-brand-fill-strong, var(--yasgui-brand-strong, #044a8c)); + } + + &.copied { + background: var(--yasgui-success, #067647); + } } - button { - float: right; - margin-left: 5px; + // "Show only the results" + .yasqe_sharePopup_option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 10px; + border-radius: var(--yasgui-radius-button, 8px); + box-shadow: inset 0 0 0 1px var(--yasgui-line, #dfe5ec); + cursor: pointer; + + &:hover { + background: var(--yasgui-surface-sunken, #edf0f4); + } + + input { + width: 15px; + height: 15px; + margin: 2px 0 0; + flex-shrink: 0; + accent-color: var(--yasgui-brand, #0563bb); + cursor: pointer; + } + + > span { + display: flex; + flex-direction: column; + gap: 1px; + } } - textarea { - width: 100%; + .yasqe_sharePopup_optionTitle { + font-weight: 500; } - .shortlinkErr { - color: #d32f2f; + .yasqe_sharePopup_optionHint { font-size: 12px; - padding: 8px; - display: block; + color: var(--yasgui-ink-subtle, #6a7380); + } + + .yasqe_sharePopup_action { + align-self: flex-start; + height: 28px; + padding: 0 8px; + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + border-radius: var(--yasgui-radius-sm, 6px); + background: transparent; + color: var(--yasgui-ink-muted, #56606c); + font-weight: 500; + font-size: 12px; + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--yasgui-surface-sunken, #edf0f4); + color: var(--yasgui-ink, #1c2633); + } + + &:disabled { + cursor: default; + opacity: 0.7; + } + } + + // cURL, PowerShell and wget + .yasqe_sharePopup_commands { + display: flex; + flex-direction: column; + margin: 0 -6px; + } + + .yasqe_sharePopup_command { + height: 32px; + padding: 0 8px; + display: flex; + align-items: center; + gap: 10px; + border: none; + border-radius: var(--yasgui-radius-sm, 6px); + background: transparent; + color: var(--yasgui-ink, #1c2633); + text-align: left; + cursor: pointer; + + > .yasqe_shareIcon { + color: var(--yasgui-ink-subtle, #6a7380); + } + + .yasqe_sharePopup_commandName { + flex: 1; + } + + .yasqe_sharePopup_copyHint { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--yasgui-ink-subtle, #6a7380); + opacity: 0; + transition: opacity 0.1s ease; + } + + &:hover, + &:focus-visible { + outline: none; + background: var(--yasgui-surface-sunken, #edf0f4); + + .yasqe_sharePopup_copyHint { + opacity: 1; + } + } + + &.copied .yasqe_sharePopup_copyHint { + opacity: 1; + color: var(--yasgui-success, #067647); + } + } + + .yasqe_sharePopup_warning, + .yasqe_sharePopup_status { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + border-radius: var(--yasgui-radius-button, 8px); + font-size: 12px; + } + + .yasqe_sharePopup_warning { + background: color-mix(in srgb, var(--yasgui-highlight, #f2b300) 16%, transparent); + color: var(--yasgui-highlight-ink, #6e4d00); + + .yasqe_shareIcon { + margin-top: 1px; + } + } + + .yasqe_sharePopup_status { + background: color-mix(in srgb, var(--yasgui-danger, #b42318) 12%, transparent); + color: var(--yasgui-danger, #b42318); + + &[hidden] { + display: none; + } } } .yasqe_queryButton { @@ -771,72 +984,3 @@ } } } - -// Toast notification styles - scoped with yasqe_ prefix -// These are appended to document.body and must be outside .yasqe selector -// The yasqe_ prefix prevents naming conflicts with other components -.yasqe_toast { - position: fixed; - bottom: 20px; - right: 20px; - background-color: #323232; - color: #fff; - padding: 12px 20px; - border-radius: 4px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - z-index: 10000; - font-size: 14px; - max-width: 400px; - animation: yasqe_toast-fadein 0.3s ease-in; - display: flex; - align-items: center; - gap: 12px; - - &.yasqe_toast-warning { - background-color: #ffc107; - color: #000; - padding: 14px 20px; - box-shadow: 0 3px 10px rgba(255, 193, 7, 0.4); - - .yasqe_toast-icon { - display: flex; - align-items: center; - flex-shrink: 0; - - i { - font-size: 24px; - } - } - } - - .yasqe_toast-message { - flex: 1; - } - - &.yasqe_toast-fadeout { - animation: yasqe_toast-fadeout 0.3s ease-out; - opacity: 0; - } -} - -@keyframes yasqe_toast-fadein { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes yasqe_toast-fadeout { - from { - opacity: 1; - transform: translateY(0); - } - to { - opacity: 0; - transform: translateY(20px); - } -} diff --git a/packages/yasqe/src/sharePopup.ts b/packages/yasqe/src/sharePopup.ts new file mode 100644 index 00000000..aa455cfe --- /dev/null +++ b/packages/yasqe/src/sharePopup.ts @@ -0,0 +1,306 @@ +import type Yasqe from "./"; + +/** Lucide icons (https://lucide.dev, ISC license) used by the share popup */ +const ICONS = { + link: '', + copy: '', + check: '', + terminal: '', + scissors: + '', + alert: + '', + x: '', +}; +type IconName = keyof typeof ICONS; + +function svg(name: IconName, size = 15): string { + return ``; +} + +function el(tag: K, className?: string, text?: string) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +async function writeClipboard(text: string) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + // Older browsers and non-secure contexts + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + document.body.appendChild(textArea); + textArea.select(); + try { + if (!document.execCommand("copy")) throw new Error("Copy command not supported"); + } finally { + textArea.remove(); + } +} + +/** Options of the share link that the popup lets the user pick */ +export interface ShareLinkOptions { + /** Open the link with the query editor hidden, showing only the results */ + hideQuery?: boolean; +} + +let openPopup: { close: () => void; trigger: HTMLElement } | undefined; + +/** + * The popup of the Share button: the link to the query (with a Copy button, an optional + * "query hidden" option and an optional shortener) and the query as a cURL, PowerShell or + * wget command. + */ +export function showSharePopup(yasqe: Yasqe, anchor: HTMLElement, trigger: HTMLElement) { + // A second click on the Share button closes the popup + if (openPopup && openPopup.trigger === trigger) { + openPopup.close(); + return undefined; + } + openPopup?.close(); + const config = yasqe.config; + const createLink = config.createShareableLink; + + const popup = el("div", "yasqe_sharePopup"); + popup.setAttribute("role", "dialog"); + popup.setAttribute("aria-label", "Share query"); + anchor.appendChild(popup); + + const resetTimers: number[] = []; + const close = (returnFocus = false) => { + popup.remove(); + resetTimers.forEach((t) => clearTimeout(t)); + document.removeEventListener("mousedown", onOutside, true); + document.removeEventListener("keydown", onKeydown, true); + trigger.setAttribute("aria-expanded", "false"); + if (openPopup === handle) openPopup = undefined; + if (returnFocus) trigger.focus(); + }; + const handle = { close: () => close(), trigger }; + openPopup = handle; + const onOutside = (event: MouseEvent) => { + const target = event.target as Node | null; + if (target && (popup.contains(target) || trigger.contains(target))) return; + close(); + }; + const onKeydown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + close(true); + }; + document.addEventListener("mousedown", onOutside, true); + document.addEventListener("keydown", onKeydown, true); + trigger.setAttribute("aria-expanded", "true"); + + // Header + const header = el("div", "yasqe_sharePopup_header"); + header.appendChild(el("div", "yasqe_sharePopup_title", "Share query")); + const closeBtn = el("button", "yasqe_sharePopup_close"); + closeBtn.type = "button"; + closeBtn.title = "Close"; + closeBtn.setAttribute("aria-label", "Close"); + closeBtn.innerHTML = svg("x", 16); + closeBtn.addEventListener("click", () => close(true)); + header.appendChild(closeBtn); + popup.appendChild(header); + + const status = el("div", "yasqe_sharePopup_status"); + status.setAttribute("role", "status"); + status.hidden = true; + const showError = (message: string) => { + status.textContent = message; + status.hidden = false; + }; + + /** Copy the text and show "Copied" on the button for a moment */ + const copyFrom = async (button: HTMLButtonElement, text: string) => { + status.hidden = true; + try { + await writeClipboard(text); + } catch (err) { + console.error("Failed to copy to clipboard:", err); + showError("Could not copy to the clipboard. Select the text and copy it yourself."); + return; + } + const label = button.querySelector(".yasqe_sharePopup_copyLabel"); + if (!label) return; + button.classList.add("copied"); + button.querySelector(".yasqe_shareIcon")?.remove(); + button.insertAdjacentHTML("afterbegin", svg("check", 14)); + label.textContent = "Copied"; + resetTimers.push( + window.setTimeout(() => { + button.classList.remove("copied"); + button.querySelector(".yasqe_shareIcon")?.remove(); + button.insertAdjacentHTML("afterbegin", svg("copy", 14)); + label.textContent = "Copy"; + }, 1600), + ); + }; + + const copyButton = (className: string, title: string) => { + const button = el("button", `yasqe_sharePopup_copy ${className}`); + button.type = "button"; + button.title = title; + button.innerHTML = `${svg("copy", 14)}Copy`; + return button; + }; + + // Link + if (createLink) { + const section = el("div", "yasqe_sharePopup_section"); + section.appendChild(el("div", "yasqe_sharePopup_label", "Link")); + + const options: ShareLinkOptions = {}; + const hideQueryDefault = config.shareHideQueryOption; + if (hideQueryDefault) options.hideQuery = !!hideQueryDefault(yasqe); + const linkFor = () => createLink(yasqe, options); + + const row = el("div", "yasqe_sharePopup_linkRow"); + const linkIcon = el("span", "yasqe_sharePopup_linkIcon"); + linkIcon.innerHTML = svg("link", 14); + row.appendChild(linkIcon); + const input = el("input", "yasqe_sharePopup_url"); + input.type = "text"; + input.readOnly = true; + input.value = linkFor(); + input.setAttribute("aria-label", "Link to this query"); + input.addEventListener("focus", () => input.select()); + row.appendChild(input); + const copyLink = copyButton("yasqe_sharePopup_copyLink", "Copy the link"); + copyLink.addEventListener("click", () => copyFrom(copyLink, input.value)); + row.appendChild(copyLink); + section.appendChild(row); + + if (hideQueryDefault) { + const option = el("label", "yasqe_sharePopup_option"); + const checkbox = el("input", "yasqe_sharePopup_hideQuery"); + checkbox.type = "checkbox"; + checkbox.checked = !!options.hideQuery; + checkbox.addEventListener("change", () => { + options.hideQuery = checkbox.checked; + input.value = linkFor(); + }); + option.appendChild(checkbox); + const text = el("span"); + text.appendChild(el("span", "yasqe_sharePopup_optionTitle", "Show only the results")); + text.appendChild(el("span", "yasqe_sharePopup_optionHint", "The link opens with the query hidden")); + option.appendChild(text); + section.appendChild(option); + } + + const createShortLink = config.createShortLink; + if (createShortLink) { + const shorten = el("button", "yasqe_sharePopup_action yasqe_sharePopup_shorten"); + shorten.type = "button"; + shorten.innerHTML = `${svg("scissors", 14)}Shorten link`; + shorten.addEventListener("click", async () => { + const label = shorten.querySelector("span")!; + shorten.disabled = true; + label.textContent = "Shortening…"; + status.hidden = true; + try { + input.value = await createShortLink(yasqe, linkFor()); + label.textContent = "Shortened"; + await copyFrom(copyLink, input.value); + } catch (err) { + label.textContent = "Shorten link"; + shorten.disabled = false; + const message = + typeof err === "string" && err ? err : (err as Error)?.message ? (err as Error).message : undefined; + showError(message || "Failed to shorten the link"); + } + }); + section.appendChild(shorten); + } + popup.appendChild(section); + } + + // Command line + const section = el("div", "yasqe_sharePopup_section"); + section.appendChild(el("div", "yasqe_sharePopup_label", "Command line")); + const commands: Array<[string, string, () => string]> = [ + ["cURL", "curl", () => yasqe.getAsCurlString()], + ["PowerShell", "powershell", () => yasqe.getAsPowerShellString()], + ["wget", "wget", () => yasqe.getAsWgetString()], + ]; + const list = el("div", "yasqe_sharePopup_commands"); + for (const [label, id, build] of commands) { + const button = el("button", `yasqe_sharePopup_command yasqe_sharePopup_${id}`); + button.type = "button"; + button.title = `Copy the query as a ${label} command`; + button.innerHTML = `${svg("terminal", 14)}${svg("copy", 13)}Copy`; + button.querySelector(".yasqe_sharePopup_commandName")!.textContent = label; + const hint = button.querySelector(".yasqe_sharePopup_copyHint")!; + button.addEventListener("click", async () => { + let text: string; + try { + text = build(); + } catch (err) { + showError((err as Error)?.message || `Could not create the ${label} command`); + return; + } + // The check mark and "Copied" go on the hint at the right of the row + status.hidden = true; + try { + await writeClipboard(text); + } catch (err) { + console.error("Failed to copy to clipboard:", err); + showError("Could not copy to the clipboard."); + return; + } + button.classList.add("copied"); + hint.innerHTML = `${svg("check", 13)}Copied`; + resetTimers.push( + window.setTimeout(() => { + button.classList.remove("copied"); + hint.innerHTML = `${svg("copy", 13)}Copy`; + }, 1600), + ); + }); + list.appendChild(button); + } + section.appendChild(list); + if (yasqe.hasAuthenticationCredentials()) { + const note = el("div", "yasqe_sharePopup_warning"); + note.innerHTML = svg("alert", 14); + note.appendChild(el("span", undefined, "The commands include the credentials of this endpoint.")); + section.appendChild(note); + } + popup.appendChild(section); + popup.appendChild(status); + + // Below the button when there is room, otherwise above it + const place = () => { + if (!popup.isConnected) return; + const anchorRect = anchor.getBoundingClientRect(); + const spaceBelow = window.innerHeight - anchorRect.bottom; + const height = popup.offsetHeight; + if (spaceBelow < height + 16 && anchorRect.top > spaceBelow) { + popup.classList.add("above"); + } else { + popup.classList.remove("above"); + } + // On a narrow screen the popup would stick out on the left: span the viewport instead + if (popup.getBoundingClientRect().left < 8) { + popup.classList.remove("above"); + popup.style.position = "fixed"; + popup.style.left = "12px"; + popup.style.right = "12px"; + popup.style.width = "auto"; + popup.style.top = `${Math.max(8, anchorRect.bottom + 6)}px`; + } + }; + if (typeof window.requestAnimationFrame === "function") window.requestAnimationFrame(place); + else setTimeout(place, 0); + + (popup.querySelector(".yasqe_sharePopup_copyLink") || closeBtn).focus(); + return handle; +} diff --git a/test/share-link-browser.ts b/test/share-link-browser.ts index 0a679d12..bda57c2a 100644 --- a/test/share-link-browser.ts +++ b/test/share-link-browser.ts @@ -87,6 +87,35 @@ describe("Share links", function () { expect(endpointRequests).to.be.greaterThan(0); }); + it("opens a link with hideQuery with the query editor hidden", async function () { + await open({ query: "SELECT * WHERE { ?s ?p ?o } LIMIT 1", endpoint: ENDPOINT, hideQuery: "true" }); + expect(await page.evaluate(() => (window as any).yasgui.getTab().isQueryHidden())).to.equal(true); + expect(await page.$(".tabPanel.active.queryHidden")).to.not.equal(null); + // The query still runs, and the link of the share popup keeps the option + expect(endpointRequests).to.be.greaterThan(0); + await page.click(".yasqe_share"); + await page.waitForSelector(".yasqe_sharePopup"); + expect(await page.$eval(".yasqe_sharePopup_hideQuery", (el) => (el as HTMLInputElement).checked)).to.equal(true); + expect(await page.$eval(".yasqe_sharePopup_url", (el) => (el as HTMLInputElement).value)).to.contain( + "hideQuery=true", + ); + // Unticking the option drops it from the link + await page.click(".yasqe_sharePopup_hideQuery"); + expect(await page.$eval(".yasqe_sharePopup_url", (el) => (el as HTMLInputElement).value)).to.not.contain( + "hideQuery", + ); + // Show query brings the editor back + await page.keyboard.press("Escape"); + await page.click(".yasgui-queryToggle"); + expect(await page.evaluate(() => (window as any).yasgui.getTab().isQueryHidden())).to.equal(false); + }); + + it("shows a hidden update query, as it is not run", async function () { + await open({ query: "INSERT DATA { }", endpoint: ENDPOINT, hideQuery: "true" }); + expect(endpointRequests).to.equal(0); + expect(await page.evaluate(() => (window as any).yasgui.getTab().isQueryHidden())).to.equal(false); + }); + it("loads a link with malformed JSON parameters", async function () { await open({ query: "SELECT * WHERE { ?s ?p ?o } LIMIT 1", endpoint: ENDPOINT, headers: '{"X-Broken":' }); const value = await page.evaluate(() => (window as any).yasgui.getTab().getQuery()); From 623043c0cf63e7e3733aab5a169e01a8668bc3c7 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Fri, 6 Nov 2026 23:03:12 +0000 Subject: [PATCH 2/2] docs: hide the query and the redesigned share popup --- docs/developer-guide.md | 26 +++++++++++++++++--------- docs/user-guide.md | 35 +++++++++++++++++++---------------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/docs/developer-guide.md b/docs/developer-guide.md index bac79faa..aac49b54 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -718,6 +718,10 @@ interface Config { // Layout orientation: 'vertical' or 'horizontal' orientation?: 'vertical' | 'horizontal'; // default: 'vertical' + // Open new tabs with the query editor hidden, showing only the results. Users can show it + // with "Show query"; share links can set it with hideQuery=true + hideQuery?: boolean; // default: false + // "Describe endpoint" panel (see Endpoint Describe Configuration) endpointDescribe: EndpointDescribeConfig; @@ -1101,8 +1105,13 @@ Configure how users can share their SPARQL queries with multiple output formats interface YasqeConfig { // ... other config options - // Function to create a shareable link (required to show share button) - createShareableLink?: (yasqe: Yasqe) => string; + // Function to create a shareable link (required to show share button). + // options.hideQuery is set when the user picked "Show only the results" + createShareableLink?: (yasqe: Yasqe, options?: { hideQuery?: boolean }) => string; + + // Offer "Show only the results" in the share popup; returns the initial state of the option. + // Yasgui sets this to whether the query of the tab is hidden + shareHideQueryOption?: ((yasqe: Yasqe) => boolean) | null; // Optional URL shortener function createShortLink?: (yasqe: Yasqe, longUrl: string) => Promise; @@ -1364,11 +1373,10 @@ createShortLink: async (yasqe, longUrl) => { When the share button is enabled (by configuring `createShareableLink`), users can: -1. **Copy URL** - Copies the shareable URL to clipboard -2. **Shorten URL** - Creates and copies a shortened URL (only shown if `createShortLink` is configured) -3. **Copy cURL** - Generates a cURL command with all headers and authentication -4. **Copy PowerShell** - Generates a PowerShell `Invoke-WebRequest` command -5. **Copy wget** - Generates a wget command +1. **Link** - Shows the shareable URL with a **Copy** button +2. **Show only the results** - Adds `hideQuery=true` to the link (only shown if `shareHideQueryOption` is configured, as Yasgui does) +3. **Shorten link** - Creates and copies a shortened URL (only shown if `createShortLink` is configured) +4. **cURL**, **PowerShell** (`Invoke-WebRequest`) and **wget** - Copy the query as a command All command formats include: - Complete SPARQL query @@ -1381,7 +1389,7 @@ All command formats include: **Security Considerations:** -⚠️ When users copy command-line formats (cURL, PowerShell, wget) that include authentication credentials, YASQE displays a warning toast notification. This helps prevent accidental sharing of sensitive credentials. +⚠️ When the request includes authentication credentials, the share popup shows a note that the command-line formats (cURL, PowerShell, wget) include them. This helps prevent accidental sharing of sensitive credentials. **Best Practices:** @@ -1394,7 +1402,7 @@ All command formats include: **Troubleshooting:** - If the share button doesn't appear, ensure `createShareableLink` is configured -- If "Shorten URL" doesn't appear, check that `createShortLink` is configured +- If "Shorten link" doesn't appear, check that `createShortLink` is configured - Check browser console for API errors when shortening fails - Verify API keys and endpoints are correct - Ensure CORS is properly configured for your URL shortener API diff --git a/docs/user-guide.md b/docs/user-guide.md index b5470ab4..04a3c30a 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -38,6 +38,7 @@ - [Features](#features) - [Themes](#themes) - [Layout Orientation](#layout-orientation) + - [Hide the Query](#hide-the-query) - [Query Formatting](#query-formatting) - [CONSTRUCT Query Validation](#construct-query-validation) - [Code Snippets](#code-snippets) @@ -585,6 +586,16 @@ MAtGUI offers two layout options to optimize screen space: - Horizontal layout: drag the line between the editor and the results; double-click it to go back to a 50/50 split - Like the side panel's edge, the handle shows a blue line when you hover or drag it +### Hide the Query + +When you only care about the results, for example to look at a result someone sent you, you can fold the query editor away so the results take the whole card. + +- Click **Hide query** at the right end of the editor toolbar, or search for "Hide query" with `Ctrl+K` +- The editor folds to one line that says "The query is hidden". **Share**, the save buttons and **Run** stay available +- Click **Show query** (or the "The query is hidden" line) to bring the editor back +- Each tab remembers whether its query is hidden +- To share a link that opens with the query hidden, tick **Show only the results** in the share popup (see [Share Queries](#share-queries)) + ### Query Formatting Keep your SPARQL queries clean and readable with automatic formatting. @@ -1677,28 +1688,20 @@ Share your queries with colleagues using multiple formats including URL paramete **Share Button:** -YASQE provides a share button (when `createShareableLink` is configured) that offers multiple sharing options: - -1. **Copy URL** - Copies the shareable URL to your clipboard -2. **Shorten URL** - Creates a shortened URL (if URL shortener is configured) -3. **Copy cURL** - Generates and copies a cURL command for command-line execution -4. **Copy PowerShell** - Generates and copies a PowerShell command using `Invoke-WebRequest` -5. **Copy wget** - Generates and copies a wget command - -**How to Share:** +The share button in the editor toolbar opens the share popup, with two parts: -Matgui supports URL-based query sharing: +- **Link**: the link to the query, with a **Copy** button + - **Show only the results**: the link opens with the query hidden, so the reader sees the results straight away (see [Hide the Query](#hide-the-query)). The option starts ticked when the query of your tab is hidden + - **Shorten link**: creates and copies a short link (only when a URL shortener is configured) +- **Command line**: click **cURL**, **PowerShell** (`Invoke-WebRequest`) or **wget** to copy the query as a command -1. Craft your query in MatGUI -2. Click the share button (sharing icon in the top-right corner) -3. Select your preferred format -4. The content is automatically copied to your clipboard -5. Share with others or execute in your terminal +The button you clicked shows **Copied** for a moment. Press `Esc` or click outside the popup to close it. **URL Parameters:** - `query`: The SPARQL query - `endpoint`: The endpoint URL +- `hideQuery=true`: open the link with the query editor hidden - Other tab configurations **Opening a shared link:** the query of the link opens in a tab and runs straight away, except in these cases, where MatGUI shows a message and waits for you: @@ -1716,7 +1719,7 @@ All command-line formats (cURL, PowerShell, wget) include: - Authentication credentials (if configured) - Proper HTTP method (GET or POST) -⚠️ **Security Notice:** When copying commands with authentication credentials, a warning notification will appear. Be cautious about sharing these commands as they contain sensitive authentication information. +⚠️ **Security Notice:** When the endpoint has authentication credentials, the share popup says that the commands include them. Be cautious about sharing these commands as they contain sensitive authentication information. **Example Formats:**