diff --git a/.changeset/whats-new.md b/.changeset/whats-new.md new file mode 100644 index 00000000..7e3e3000 --- /dev/null +++ b/.changeset/whats-new.md @@ -0,0 +1,5 @@ +--- +"@matdata/yasgui": minor +--- + +**What's new**: the help button of the top bar now opens a menu with **What's new** and the **Documentation**. What's new lists the changes of each version since 5.0.0 as New, Improved or Fixed, with a filter per kind and a link to the full release notes. After an update a dot on the help button points to it until it is opened; first-time users get no dot. Also in the Ctrl+K search. Embedders can switch it off with `showWhatsNew: false` and add their own entries with `yasgui.helpMenu.addItem()`. `helpUrl: false` now only leaves out the documentation entry. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index e5b13c67..4cca9365 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -718,9 +718,14 @@ interface Config { // Look up IRIs the endpoint doesn't describe in LOV (lov.linkeddata.es) iriHoverLovFallback?: boolean; // default: true - // Page opened by the help button of the top bar; false hides the button + // Documentation link in the menu of the help button of the top bar; false leaves it out. + // The help button hides when its menu has no entries left helpUrl?: string | false; // default: 'https://matgui-doc.matdata.eu/docs/user-guide' + // "What's new" in the help menu: the changes per version, with a dot on the help button + // until the user has seen the latest release. Also in the Ctrl+K search + showWhatsNew?: boolean; // default: true + // AI assistant (see AI Assistant Configuration); { enabled: false } removes it ai: Partial; /** @@ -2542,6 +2547,24 @@ The older entry points keep working and now drive the side panel: A pinned describe panel saved by an earlier version is restored as the pinned Endpoint view. +#### Help menu and What's new + +The help button of the top bar opens `yasgui.helpMenu`. Add your own entries, for example a link to your site's documentation; entries are sorted on `order` (built-in: quick start 10, What's new 20, Documentation 90, default 100). With a single entry the button runs it directly, and without entries it is hidden. + +```javascript +yasgui.helpMenu.addItem({ + id: "contact", // an existing id is replaced + label: "Contact support", + icon: "externalLink", // optional, a MatGUI icon name + order: 95, + onClick: () => window.open("https://example.org/support", "_blank"), +}); +yasgui.helpMenu.setBadge("contact", true); // dot on the entry and the help button +yasgui.helpMenu.removeItem("contact"); +``` + +`yasgui.whatsNew` (unless `showWhatsNew: false`) has `open()`, `close()`, `hasUnseen()` and `getLatestVersion()`. The release entries live in `packages/yasgui/src/whatsNew/releases.ts`, newest first; add the next version there when preparing a release. The newest version a user has opened is stored as `whatsNewSeen` in the persisted config. + ### Tab Class Represents a query tab. diff --git a/docs/user-guide.md b/docs/user-guide.md index 12a5cc40..f62d5374 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -25,6 +25,7 @@ - [The Interface](#the-interface) - [Top Bar](#top-bar) - [Search](#search) + - [Help Menu and What's New](#help-menu-and-whats-new) - [Query Card](#query-card) - [Endpoint Picker](#endpoint-picker) - [Run and Stop](#run-and-stop) @@ -352,7 +353,7 @@ From left to right: - **MatGUI**: the name of the application (sites that embed MatGUI can hide it) - **Query tabs**: one tab per query. The active tab is raised. Tabs that hold a saved (managed) query show a small dot before their name; the dot turns amber when the query has unsaved changes. Hover a tab to show its close button (×), click **+** to add a tab - **Search queries and commands** (`Ctrl+K`, or `⌘K` on a Mac): see [Search](#search) -- **Help** (?): opens the MatGUI user guide in a new browser tab +- **Help** (?): opens the help menu with **What's new** and the **Documentation** (see [Help Menu and What's New](#help-menu-and-whats-new)). A blue dot on the button means there is a release you haven't seen yet - **Theme** (sun / moon): switches between the light and dark theme - **Settings** (gear): opens the settings of the active tab @@ -362,11 +363,22 @@ Click **Search queries and commands** in the top bar, or press `Ctrl+K` (`⌘K` - **Queries**: the saved queries of all your workspaces, with the workspace and folder they are in. Pick one to open it in a new tab, or to go to the tab that already has it open - **Snippets**: the code snippets of the [Insert menu](#insert-menu), found by name, group or the code they write (`filter` finds the comparison snippets). Pick one to insert it at the cursor -- **Commands**: Run query, New tab, Insert snippet, Insert PREFIX declarations, Switch endpoint, Explore endpoint, Show saved queries, Toggle side panel, Toggle side-by-side layout, Format, Share, Save, Download, full screen editor, dark mode and the documentation. Commands with a shortcut show it at the right +- **Commands**: Run query, New tab, Insert snippet, Insert PREFIX declarations, Switch endpoint, Explore endpoint, Show saved queries, Toggle side panel, Toggle side-by-side layout, Format, Share, Save, Download, full screen editor, dark mode, What's new and the documentation. Commands with a shortcut show it at the right - **Settings**: every page of the settings (General, SPARQL endpoints, Workspaces, Prefixes, Editor, AI, Import / export, Keyboard shortcuts, About), for example type `prefixes` and pick **Settings: Prefixes** Type `>` first to search commands and settings only. Snippets and settings show up once you type something. Use `↑` and `↓` to move, `Enter` to open and `Esc` to close. +### Help Menu and What's New + +Click **Help** (?) in the top bar to open the help menu: + +- **What's new**: what changed in each version of MatGUI since 5.0.0, newest first. Every change is marked **New**, **Improved** or **Fixed**; use the buttons at the top to show only one kind. Releases you haven't seen yet are marked **New to you**. **Full release notes on GitHub** opens the complete notes, including the changes for developers +- **Documentation**: opens this user guide in a new browser tab + +After MatGUI is updated, a blue dot on the Help button and next to **What's new** tells you there is something new. The dot goes away once you open What's new. On your first visit there is no dot. You can also open it from the [search](#search) (`Ctrl+K`, then type `what's new`). Press `Esc` to close it. + +Sites that embed MatGUI can leave out What's new (`showWhatsNew: false`) or the documentation link (`helpUrl: false`). + ### Query Card The head of the card shows: diff --git a/packages/yasgui/src/CommandPalette.ts b/packages/yasgui/src/CommandPalette.ts index 50d9a952..86661586 100644 --- a/packages/yasgui/src/CommandPalette.ts +++ b/packages/yasgui/src/CommandPalette.ts @@ -386,6 +386,15 @@ export default class CommandPalette { }, }); } + if (yasgui.whatsNew) { + commands.push({ + section: "commands", + label: "What's new", + icon: "sparkles", + keywords: "release notes changelog changes version update", + run: () => yasgui.whatsNew?.open(), + }); + } if (yasgui.config.helpUrl) { const helpUrl = yasgui.config.helpUrl; commands.push({ diff --git a/packages/yasgui/src/HelpMenu.scss b/packages/yasgui/src/HelpMenu.scss new file mode 100644 index 00000000..1000000e --- /dev/null +++ b/packages/yasgui/src/HelpMenu.scss @@ -0,0 +1,84 @@ +// `.yasgui > div` lays out the tab panels as a column; the help menu is not one of them +.yasgui > div.yasgui-helpMenu:not(.tabsList) { + flex-direction: column; + overflow: visible; + min-height: 0; + + &[hidden] { + display: none; + } +} + +.yasgui .yasgui-helpButton { + position: relative; +} + +.yasgui-helpButton__badge { + position: absolute; + top: 6px; + right: 6px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--yasgui-brand, #0563bb); + box-shadow: 0 0 0 2px var(--yasgui-surface, #ffffff); + pointer-events: none; +} + +// Dropdown of the help button +.yasgui-helpMenu { + position: fixed; + z-index: 10040; + display: flex; + min-width: 200px; + padding: 4px; + box-sizing: border-box; + border-radius: 10px; + 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)); + font-family: var(--yasgui-font-family, inherit); + font-size: 13px; + color: var(--yasgui-ink, #1c2633); + + &[hidden] { + display: none; + } + + &__item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 32px; + padding: 0 10px; + border: none; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + + .yasgui-icon { + flex-shrink: 0; + color: var(--yasgui-ink-subtle, #6a7380); + } + + span:first-of-type { + flex: 1; + } + + &:hover, + &:focus-visible { + outline: none; + background: var(--yasgui-surface-sunken, #edf0f4); + } + } + + &__badge { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--yasgui-brand, #0563bb); + } +} diff --git a/packages/yasgui/src/HelpMenu.ts b/packages/yasgui/src/HelpMenu.ts new file mode 100644 index 00000000..922843a6 --- /dev/null +++ b/packages/yasgui/src/HelpMenu.ts @@ -0,0 +1,196 @@ +import { addClass } from "@matdata/yasgui-utils"; +import { icon, IconName } from "./icons"; +import type Yasgui from "./"; +import "./HelpMenu.scss"; + +export interface HelpMenuItem { + /** Unique id, used to replace, remove or badge the item later */ + id: string; + label: string; + icon?: IconName; + onClick: () => void; + /** Show a dot on the item and on the help button, e.g. for unseen release notes */ + badge?: boolean; + /** Items are sorted on this number (default 100); built-in items use 10, 20 and 90 */ + order?: number; +} + +/** + * Menu of the help button in the top bar: the quick start tour, what's new and the documentation. + * The button only shows when the menu has an item; with a single item it runs that item directly. + */ +export default class HelpMenu { + private yasgui: Yasgui; + private items: HelpMenuItem[] = []; + private buttonEl?: HTMLButtonElement; + private menuEl?: HTMLDivElement; + + constructor(yasgui: Yasgui) { + this.yasgui = yasgui; + const helpUrl = yasgui.config.helpUrl; + if (helpUrl) { + this.addItem({ + id: "documentation", + label: "Documentation", + icon: "externalLink", + order: 90, + onClick: () => window.open(helpUrl, "_blank", "noopener,noreferrer"), + }); + } + } + + /** Add an item, or replace the item with the same id */ + public addItem(item: HelpMenuItem) { + this.items = this.items.filter((existing) => existing.id !== item.id); + this.items.push(item); + this.items.sort((a, b) => (a.order ?? 100) - (b.order ?? 100)); + this.update(); + } + + public removeItem(id: string) { + this.items = this.items.filter((item) => item.id !== id); + this.update(); + } + + public setBadge(id: string, badge: boolean) { + const item = this.items.find((existing) => existing.id === id); + if (!item || !!item.badge === badge) return; + item.badge = badge; + this.update(); + } + + public getItems(): HelpMenuItem[] { + return [...this.items]; + } + + /** The help button of the top bar, kept up to date with the items */ + public getButton(): HTMLButtonElement { + if (this.buttonEl) return this.buttonEl; + const button = document.createElement("button"); + button.type = "button"; + button.className = "yasgui-topBarButton yasgui-helpButton"; + button.setAttribute("aria-haspopup", "menu"); + button.setAttribute("aria-expanded", "false"); + button.addEventListener("click", (event) => { + event.stopPropagation(); + if (this.items.length === 1) { + this.items[0].onClick(); + return; + } + if (this.isOpen()) this.close(); + else this.open(); + }); + this.buttonEl = button; + this.update(); + return button; + } + + public isOpen(): boolean { + return !!this.menuEl && !this.menuEl.hidden; + } + + public open() { + if (!this.buttonEl || !this.items.length) return; + if (!this.menuEl) this.drawMenu(); + const menu = this.menuEl!; + this.renderMenu(); + menu.hidden = false; + this.buttonEl.setAttribute("aria-expanded", "true"); + const rect = this.buttonEl.getBoundingClientRect(); + menu.style.top = `${rect.bottom + 6}px`; + menu.style.right = `${Math.max(8, document.documentElement.clientWidth - rect.right)}px`; + (menu.querySelector("button") as HTMLButtonElement | null)?.focus(); + document.addEventListener("mousedown", this.handleOutside, true); + document.addEventListener("keydown", this.handleKeydown, true); + } + + public close(returnFocus = false) { + if (!this.menuEl || this.menuEl.hidden) return; + this.menuEl.hidden = true; + this.buttonEl?.setAttribute("aria-expanded", "false"); + document.removeEventListener("mousedown", this.handleOutside, true); + document.removeEventListener("keydown", this.handleKeydown, true); + if (returnFocus) this.buttonEl?.focus(); + } + + public destroy() { + this.close(); + this.menuEl?.remove(); + this.menuEl = undefined; + } + + private handleOutside = (event: MouseEvent) => { + const target = event.target as Node | null; + if (target && (this.menuEl?.contains(target) || this.buttonEl?.contains(target))) return; + this.close(); + }; + + private handleKeydown = (event: KeyboardEvent) => { + if (!this.menuEl) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.close(true); + return; + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + const buttons = Array.from(this.menuEl.querySelectorAll("button")); + const current = buttons.indexOf(document.activeElement as HTMLButtonElement); + const next = (current + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length; + buttons[next]?.focus(); + }; + + private drawMenu() { + const menu = document.createElement("div"); + addClass(menu, "yasgui-helpMenu"); + menu.setAttribute("role", "menu"); + menu.hidden = true; + // Inside the Yasgui root, so it gets the theme tokens + this.yasgui.rootEl.appendChild(menu); + this.menuEl = menu; + } + + private renderMenu() { + if (!this.menuEl) return; + this.menuEl.innerHTML = ""; + for (const item of this.items) { + const entry = document.createElement("button"); + entry.type = "button"; + entry.setAttribute("role", "menuitem"); + addClass(entry, "yasgui-helpMenu__item"); + entry.innerHTML = `${item.icon ? icon(item.icon, 15) : ""}`; + entry.querySelector("span")!.textContent = item.label; + if (item.badge) { + const dot = document.createElement("span"); + addClass(dot, "yasgui-helpMenu__badge"); + dot.setAttribute("aria-label", "New"); + entry.appendChild(dot); + } + entry.addEventListener("click", () => { + this.close(); + item.onClick(); + }); + this.menuEl.appendChild(entry); + } + } + + private update() { + const button = this.buttonEl; + if (!button) return; + button.hidden = this.items.length === 0; + const single = this.items.length === 1 ? this.items[0] : undefined; + const badge = this.items.some((item) => item.badge); + const label = single?.id === "documentation" ? "Open the documentation" : single ? single.label : "Help"; + button.title = badge ? `${label} (new)` : label; + button.setAttribute("aria-label", badge ? `${label}, new` : label); + button.setAttribute("aria-haspopup", single ? "false" : "menu"); + button.innerHTML = icon("help", 16); + if (badge) { + const dot = document.createElement("span"); + addClass(dot, "yasgui-helpButton__badge"); + button.appendChild(dot); + } + if (this.isOpen()) this.renderMenu(); + } +} diff --git a/packages/yasgui/src/PersistentConfig.ts b/packages/yasgui/src/PersistentConfig.ts index b8dcfb21..d4b4bdc2 100644 --- a/packages/yasgui/src/PersistentConfig.ts +++ b/packages/yasgui/src/PersistentConfig.ts @@ -25,6 +25,7 @@ export interface PersistedJson { sidePanel?: SidePanelState; // Left side panel (Queries / Endpoint / AI) requestSettings?: RequestSettings; // Request settings of Settings → General, shared by all tabs editorSettings?: EditorSettings; // Formatter and checks of Settings → General and Editor, shared by all tabs + whatsNewSeen?: string; // Newest version the user has seen in What's new } /** Settings of the query editor that apply to every tab */ export interface EditorSettings { @@ -67,6 +68,8 @@ export default class PersistentConfig { private storageId: string | undefined; private yasgui: Yasgui; private storage: YStorage; + /** Whether a configuration was stored before this page load (false on a first visit) */ + private hadStoredConfig = false; constructor(yasgui: Yasgui) { this.yasgui = yasgui; this.storageId = this.yasgui.getStorageId(this.yasgui.config.persistenceLabelConfig); @@ -139,7 +142,9 @@ export default class PersistentConfig { ); } private fromStorage(): PersistedJson { - this.persistedJson = this.storage.get(this.storageId) || getDefaults(); + const stored = this.storage.get(this.storageId); + this.hadStoredConfig = !!stored; + this.persistedJson = stored || getDefaults(); /** * Modify some settings for backwards compatability */ @@ -154,6 +159,18 @@ export default class PersistentConfig { PersistentConfig.clear(); } + /** False on a first visit, before anything was stored */ + public hasStoredConfig(): boolean { + return this.hadStoredConfig; + } + public getWhatsNewSeen(): string | undefined { + return this.persistedJson.whatsNewSeen; + } + public setWhatsNewSeen(version: string) { + this.persistedJson.whatsNewSeen = version; + this.toStorage(); + } + public getTabs() { return this.persistedJson.tabs; } diff --git a/packages/yasgui/src/TabElements.ts b/packages/yasgui/src/TabElements.ts index 85c72119..2ab94ba2 100644 --- a/packages/yasgui/src/TabElements.ts +++ b/packages/yasgui/src/TabElements.ts @@ -432,16 +432,8 @@ export class TabList { actions.appendChild(search); } - const helpUrl = this.yasgui.config.helpUrl; - if (helpUrl) { - const help = iconButton("help", "Help", { - className: "yasgui-topBarButton yasgui-helpButton", - title: "Open the documentation", - size: 16, - }); - help.addEventListener("click", () => window.open(helpUrl, "_blank", "noopener,noreferrer")); - actions.appendChild(help); - } + // Quick start, what's new and the documentation; hidden while the menu has no items + actions.appendChild(this.yasgui.helpMenu.getButton()); if (this.yasgui.config.showThemeToggle) { const theme = document.createElement("button"); diff --git a/packages/yasgui/src/defaults.ts b/packages/yasgui/src/defaults.ts index e1506917..6c10af6b 100644 --- a/packages/yasgui/src/defaults.ts +++ b/packages/yasgui/src/defaults.ts @@ -37,6 +37,7 @@ export default function initialize(): Config { iriHover: true, iriHoverLovFallback: true, helpUrl: "https://matgui-doc.matdata.eu/docs/user-guide", + showWhatsNew: true, endpointDescribe: { enabled: true, timeoutMs: 60000, diff --git a/packages/yasgui/src/index.ts b/packages/yasgui/src/index.ts index 4cd875ba..56c53d9c 100644 --- a/packages/yasgui/src/index.ts +++ b/packages/yasgui/src/index.ts @@ -26,6 +26,8 @@ import EndpointDescribePanel, { EndpointDescribeConfig } from "./endpointDescrib import SparqlAnythingSupport from "./sparqlAnything/SparqlAnythingSupport"; import SidePanel from "./sidePanel/SidePanel"; import CommandPalette from "./CommandPalette"; +import HelpMenu from "./HelpMenu"; +import WhatsNew from "./whatsNew/WhatsNew"; import AiAssistant from "./ai/AiAssistant"; import type { AiConfig } from "./ai/types"; import "./index.scss"; @@ -130,9 +132,14 @@ export interface Config { */ showSnippetsBar?: boolean; /** - * Link opened by the help button in the top bar. Set to false to hide the button. + * Documentation link in the menu of the help button in the top bar. Set to false to leave it out (the button + * hides when its menu has no other entries, such as the quick start tour or what's new). */ helpUrl?: string | false; + /** + * Show "What's new" in the help menu, with a dot on the help button after an update (default: true) + */ + showWhatsNew?: boolean; /** * Show the label, description and type of an IRI when hovering it in the query editor, fetched from the * endpoint of the tab (default: true). Users can switch it off in the editor settings. @@ -212,6 +219,10 @@ export class Yasgui extends EventEmitter { public sidePanel: SidePanel; /** Ctrl+K search for saved queries, commands and settings (unless `showCommandPalette` is false) */ public commandPalette: CommandPalette | undefined; + /** Menu of the help button in the top bar; add entries with `helpMenu.addItem()` */ + public helpMenu: HelpMenu; + /** What's new dialog (help menu and Ctrl+K search), unless `showWhatsNew` is false */ + public whatsNew: WhatsNew | undefined; /** AI assistant (the "AI" view of the side panel), unless `ai.enabled` is false */ public aiAssistant: AiAssistant | undefined; /** SPARQL Anything support (the "Source" view of the side panel), unless `sparqlAnything` is false */ @@ -249,6 +260,8 @@ export class Yasgui extends EventEmitter { this.config.orientation = persistedOrientation; } + this.helpMenu = new HelpMenu(this); + if (this.config.showWhatsNew) this.whatsNew = new WhatsNew(this); this.tabElements = new TabElements(this); this.tabPanelsEl = document.createElement("div"); addClass(this.tabPanelsEl, "yasgui-tabPanels"); @@ -600,6 +613,8 @@ export class Yasgui extends EventEmitter { this.sparqlAnything?.destroy(); this.aiAssistant?.destroy(); this.commandPalette?.destroy(); + this.whatsNew?.destroy(); + this.helpMenu.destroy(); this.sidePanel.destroy(); this.tabElements.destroy(); for (const tabId in this._tabs) { diff --git a/packages/yasgui/src/whatsNew/WhatsNew.scss b/packages/yasgui/src/whatsNew/WhatsNew.scss new file mode 100644 index 00000000..fa4baed1 --- /dev/null +++ b/packages/yasgui/src/whatsNew/WhatsNew.scss @@ -0,0 +1,257 @@ +// `.yasgui > div` lays out the tab panels as a column; the dialog is not one of them +.yasgui > div.yasgui-whatsNew:not(.tabsList) { + flex-direction: row; + overflow: visible; + min-height: 0; + + &[hidden] { + display: none; + } +} + +// What's new: the changes per version, from the help menu or the Ctrl+K search +.yasgui-whatsNew { + position: fixed; + inset: 0; + z-index: 10050; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 56px 16px 16px; + box-sizing: border-box; + background: var(--yasgui-scrim, rgba(13, 20, 28, 0.32)); + font-family: var(--yasgui-font-family, inherit); + font-size: 13px; + color: var(--yasgui-ink, #1c2633); + + &[hidden] { + display: none; + } + + &__dialog { + width: 620px; + max-width: 100%; + max-height: min(720px, calc(100vh - 80px)); + display: flex; + flex-direction: column; + overflow: hidden; + 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)); + } + + &__header { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 14px 0 20px; + + h2 { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; + + .yasgui-icon { + color: var(--yasgui-brand, #0563bb); + } + } + } + + &__close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: var(--yasgui-radius-button, 8px); + background: transparent; + color: var(--yasgui-ink-muted, #56606c); + cursor: pointer; + + &:hover { + background: var(--yasgui-surface-sunken, #edf0f4); + color: var(--yasgui-ink, #1c2633); + } + + &:focus-visible { + outline: 2px solid var(--yasgui-focus, #044a8c); + outline-offset: 1px; + } + } + + // Segmented choice: All / New / Improved / Fixed + &__filters { + flex-shrink: 0; + display: inline-flex; + align-self: flex-start; + gap: 2px; + margin: 12px 20px 8px; + padding: 2px; + border-radius: 8px; + background: var(--yasgui-surface-sunken, #edf1f6); + + button { + height: 26px; + padding: 0 12px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--yasgui-ink-muted, #56606c); + font: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + + &:hover { + color: var(--yasgui-ink, #1c2633); + } + + &[aria-checked="true"] { + background: var(--yasgui-surface-raised, #ffffff); + color: var(--yasgui-ink, #1c2633); + box-shadow: var(--yasgui-shadow-raise, 0 0 0 1px rgba(28, 38, 51, 0.07), 0 1px 2px rgba(28, 38, 51, 0.06)); + } + + &:focus-visible { + outline: 2px solid var(--yasgui-focus, #044a8c); + outline-offset: 1px; + } + } + } + + &__list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 20px 8px; + } + + &__release { + padding: 14px 0; + border-top: 1px solid var(--yasgui-line-soft, #edf0f4); + + &:first-child { + border-top: none; + } + + ul { + margin: 8px 0 0; + padding: 0; + list-style: none; + } + + li { + display: flex; + align-items: baseline; + gap: 10px; + padding: 3px 0; + line-height: 1.5; + color: var(--yasgui-ink-body, #3a4552); + } + } + + &__releaseHeader { + display: flex; + align-items: baseline; + gap: 8px; + + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--yasgui-ink, #1c2633); + } + } + + &__unseen { + padding: 1px 7px; + border-radius: 999px; + background: var(--yasgui-brand-soft, #e3eefa); + color: var(--yasgui-brand, #0563bb); + font-size: 11px; + font-weight: 500; + } + + &__date { + margin-left: auto; + color: var(--yasgui-ink-subtle, #6a7380); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + + &__summary { + margin: 4px 0 0; + color: var(--yasgui-ink-muted, #56606c); + } + + // NEW / IMPROVED / FIXED label in front of each change + &__kind { + flex-shrink: 0; + width: 64px; + box-sizing: border-box; + padding: 1px 0; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; + line-height: 16px; + text-align: center; + text-transform: uppercase; + background: color-mix(in srgb, currentColor 12%, transparent); + + &--new { + color: var(--yasgui-brand, #0563bb); + } + + &--improved { + color: var(--yasgui-success, #067647); + } + + &--fixed { + color: var(--yasgui-highlight-ink, #6e4d00); + } + } + + &__footer { + flex-shrink: 0; + display: flex; + justify-content: flex-end; + padding: 10px 20px; + border-top: 1px solid var(--yasgui-line-soft, #edf0f4); + + a { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--yasgui-ink-muted, #56606c); + font-size: 12px; + text-decoration: none; + + &:hover { + color: var(--yasgui-brand, #0563bb); + } + } + } +} + +@media (max-width: 600px) { + .yasgui-whatsNew { + padding: 8px; + + &__dialog { + max-height: calc(100vh - 16px); + } + + &__kind { + width: 58px; + } + } +} diff --git a/packages/yasgui/src/whatsNew/WhatsNew.ts b/packages/yasgui/src/whatsNew/WhatsNew.ts new file mode 100644 index 00000000..6695d9ff --- /dev/null +++ b/packages/yasgui/src/whatsNew/WhatsNew.ts @@ -0,0 +1,242 @@ +import { addClass } from "@matdata/yasgui-utils"; +import { icon } from "../icons"; +import type Yasgui from "../"; +import { releases as defaultReleases, compareVersions, ChangeKind, Release } from "./releases"; +import "./WhatsNew.scss"; + +const KIND_LABELS: { [kind in ChangeKind]: string } = { + new: "New", + improved: "Improved", + fixed: "Fixed", +}; +const RELEASES_URL = "https://github.com/Matdata-eu/MatGUI/releases"; + +type Filter = "all" | ChangeKind; + +/** + * "What's new" dialog, opened from the help menu or the Ctrl+K search: what changed per version, as + * New / Improved / Fixed. Remembers the newest version the user has seen, and badges the help button + * until a newer release has been looked at. + */ +export default class WhatsNew { + private yasgui: Yasgui; + private releases: Release[]; + private overlayEl?: HTMLDivElement; + private listEl?: HTMLDivElement; + private filter: Filter = "all"; + /** Newest version seen before this dialog was opened, so the unseen releases stay marked while it is open */ + private seenBefore: string | undefined; + private returnFocusEl?: HTMLElement; + + constructor(yasgui: Yasgui, releases: Release[] = defaultReleases) { + this.yasgui = yasgui; + this.releases = releases; + const persistent = yasgui.persistentConfig; + // First visit: nothing is new to someone who never used an older version + if (persistent.getWhatsNewSeen() === undefined && !persistent.hasStoredConfig()) this.markSeen(); + yasgui.helpMenu.addItem({ + id: "whatsNew", + label: "What's new", + icon: "sparkles", + order: 20, + badge: this.hasUnseen(), + onClick: () => this.open(), + }); + } + + public getLatestVersion(): string | undefined { + return this.releases[0]?.version; + } + + /** True when there is a release the user hasn't seen in this dialog yet */ + public hasUnseen(): boolean { + const latest = this.getLatestVersion(); + if (!latest) return false; + const seen = this.yasgui.persistentConfig.getWhatsNewSeen(); + return seen === undefined || compareVersions(latest, seen) > 0; + } + + public isOpen(): boolean { + return !!this.overlayEl && !this.overlayEl.hidden; + } + + public open() { + if (!this.overlayEl) this.draw(); + if (!this.overlayEl) return; + if (document.activeElement instanceof HTMLElement) this.returnFocusEl = document.activeElement; + this.seenBefore = this.yasgui.persistentConfig.getWhatsNewSeen(); + this.filter = "all"; + this.render(); + this.overlayEl.hidden = false; + (this.overlayEl.querySelector(".yasgui-whatsNew__close") as HTMLButtonElement | null)?.focus(); + this.listEl?.scrollTo?.({ top: 0 }); + document.addEventListener("keydown", this.handleKeydown, true); + this.markSeen(); + } + + public close() { + if (!this.overlayEl || this.overlayEl.hidden) return; + this.overlayEl.hidden = true; + document.removeEventListener("keydown", this.handleKeydown, true); + if (this.returnFocusEl?.isConnected) this.returnFocusEl.focus(); + this.returnFocusEl = undefined; + } + + public destroy() { + this.close(); + this.overlayEl?.remove(); + this.overlayEl = undefined; + } + + private markSeen() { + const latest = this.getLatestVersion(); + if (!latest) return; + const seen = this.yasgui.persistentConfig.getWhatsNewSeen(); + if (seen === undefined || compareVersions(latest, seen) > 0) this.yasgui.persistentConfig.setWhatsNewSeen(latest); + this.yasgui.helpMenu.setBadge("whatsNew", false); + } + + private handleKeydown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + this.close(); + }; + + private draw() { + const overlay = document.createElement("div"); + addClass(overlay, "yasgui-whatsNew"); + overlay.hidden = true; + overlay.addEventListener("mousedown", (event) => { + if (event.target === overlay) this.close(); + }); + + const dialog = document.createElement("div"); + addClass(dialog, "yasgui-whatsNew__dialog"); + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-labelledby", "yasgui-whatsNew-title"); + + const header = document.createElement("div"); + addClass(header, "yasgui-whatsNew__header"); + const title = document.createElement("h2"); + title.id = "yasgui-whatsNew-title"; + title.innerHTML = `${icon("sparkles", 18)}What's new`; + header.appendChild(title); + const close = document.createElement("button"); + close.type = "button"; + addClass(close, "yasgui-whatsNew__close"); + close.setAttribute("aria-label", "Close"); + close.title = "Close (Esc)"; + close.innerHTML = icon("x", 16); + close.addEventListener("click", () => this.close()); + header.appendChild(close); + dialog.appendChild(header); + + const filters = document.createElement("div"); + addClass(filters, "yasgui-whatsNew__filters"); + filters.setAttribute("role", "radiogroup"); + filters.setAttribute("aria-label", "Show"); + const options: Array<[Filter, string]> = [ + ["all", "All"], + ["new", KIND_LABELS.new], + ["improved", KIND_LABELS.improved], + ["fixed", KIND_LABELS.fixed], + ]; + for (const [value, label] of options) { + const option = document.createElement("button"); + option.type = "button"; + option.textContent = label; + option.dataset.filter = value; + option.setAttribute("role", "radio"); + option.addEventListener("click", () => { + this.filter = value; + this.render(); + }); + filters.appendChild(option); + } + dialog.appendChild(filters); + + const list = document.createElement("div"); + addClass(list, "yasgui-whatsNew__list"); + dialog.appendChild(list); + this.listEl = list; + + const footer = document.createElement("div"); + addClass(footer, "yasgui-whatsNew__footer"); + const link = document.createElement("a"); + link.href = RELEASES_URL; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.innerHTML = `Full release notes on GitHub${icon("externalLink", 13)}`; + footer.appendChild(link); + dialog.appendChild(footer); + + overlay.appendChild(dialog); + // Inside the Yasgui root, so it gets the theme tokens + this.yasgui.rootEl.appendChild(overlay); + this.overlayEl = overlay; + } + + private render() { + if (!this.overlayEl || !this.listEl) return; + for (const option of Array.from(this.overlayEl.querySelectorAll("[data-filter]"))) { + option.setAttribute("aria-checked", String(option.dataset.filter === this.filter)); + } + const list = this.listEl; + list.innerHTML = ""; + for (const release of this.releases) { + const changes = release.changes.filter((change) => this.filter === "all" || change.kind === this.filter); + if (!changes.length) continue; + const section = document.createElement("section"); + addClass(section, "yasgui-whatsNew__release"); + + const heading = document.createElement("div"); + addClass(heading, "yasgui-whatsNew__releaseHeader"); + const version = document.createElement("h3"); + version.textContent = `v${release.version}`; + heading.appendChild(version); + if (this.seenBefore === undefined || compareVersions(release.version, this.seenBefore) > 0) { + const unseen = document.createElement("span"); + addClass(unseen, "yasgui-whatsNew__unseen"); + unseen.textContent = "New to you"; + heading.appendChild(unseen); + } + if (release.date) { + const date = document.createElement("span"); + addClass(date, "yasgui-whatsNew__date"); + date.textContent = formatDate(release.date); + heading.appendChild(date); + } + section.appendChild(heading); + + if (release.summary && this.filter === "all") { + const summary = document.createElement("p"); + addClass(summary, "yasgui-whatsNew__summary"); + summary.textContent = release.summary; + section.appendChild(summary); + } + + const items = document.createElement("ul"); + for (const change of changes) { + const item = document.createElement("li"); + const kind = document.createElement("span"); + addClass(kind, "yasgui-whatsNew__kind", `yasgui-whatsNew__kind--${change.kind}`); + kind.textContent = KIND_LABELS[change.kind]; + item.appendChild(kind); + const text = document.createElement("span"); + text.textContent = change.text; + item.appendChild(text); + items.appendChild(item); + } + section.appendChild(items); + list.appendChild(section); + } + } +} + +function formatDate(date: string): string { + const parsed = new Date(`${date}T12:00:00Z`); + if (isNaN(parsed.getTime())) return date; + return parsed.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} diff --git a/packages/yasgui/src/whatsNew/releases.ts b/packages/yasgui/src/whatsNew/releases.ts new file mode 100644 index 00000000..13a79a3c --- /dev/null +++ b/packages/yasgui/src/whatsNew/releases.ts @@ -0,0 +1,353 @@ +/** + * Release notes shown in "What's new" (help menu of the top bar), newest first. + * Keep the entries short and written for users; developer-only changes stay in the GitHub release notes. + * Add the next release at the top when it is prepared. + */ +export type ChangeKind = "new" | "improved" | "fixed"; + +export interface ReleaseChange { + kind: ChangeKind; + text: string; +} + +export interface Release { + version: string; + /** Release date, YYYY-MM-DD */ + date?: string; + /** One line summary of the release */ + summary?: string; + changes: ReleaseChange[]; +} + +const n = (text: string): ReleaseChange => ({ kind: "new", text }); +const i = (text: string): ReleaseChange => ({ kind: "improved", text }); +const f = (text: string): ReleaseChange => ({ kind: "fixed", text }); + +export const releases: Release[] = [ + { + version: "6.12.0", + summary: "Redesigned settings, and this What's new.", + changes: [ + n("What's new in the help menu, with a dot on the help button when there is a release you haven't seen"), + i("Redesigned settings: grouped pages, one row per setting, and changes apply at once"), + n("General settings page with the theme (Light, Dark or System), request settings and checks before running"), + i("Request method, Accept headers and custom HTTP headers now apply to every tab"), + i("AI services: pick a provider, fetch its models and search them, with help when the browser can't reach it"), + i("SPARQL Anything: Load structure opens the Source view, and Build query has an All checkbox and a compact bar"), + ], + }, + { + version: "6.11.0", + date: "2026-10-27", + summary: "Query CSV, JSON and XML files with SPARQL Anything without opening them first.", + changes: [ + n("SPARQL Anything sources: Load structure on a SERVICE block reads the file through your endpoint"), + n("Autocompletion suggests the columns, keys and XML elements of a loaded source"), + n("Source view in the side panel with the structure, a preview and a query builder"), + n("The xyz:, fx: and fxe: vocabularies and the fx: functions are suggested in autocompletion"), + ], + }, + { + version: "6.10.0", + date: "2026-10-24", + summary: "An AI assistant, and the execution plan of your query.", + changes: [ + n("AI assistant: write a query from a question, explain or fix the query, and explain the results"), + n("Fix with AI and Copy for AI on query errors, and Why no results? on empty results"), + n("AI services from any OpenAI-compatible provider in Settings > AI; without one, copy a ready-made prompt"), + n("Explain view: the execution plan of QLever, GraphDB, Stardog, Blazegraph and Neptune, and the SPARQL algebra"), + n("railML 3 and RailTopoModel terms in autocompletion"), + i("The Response view has an icon matching the other views"), + ], + }, + { + version: "6.9.0", + date: "2026-10-20", + summary: "The editor now knows your endpoint.", + changes: [ + n("Autocompletion suggests the classes and properties your endpoint uses, most used first"), + n("Hover an IRI in the editor to see its label, description, type, domain and range"), + n("A #defaultView: comment opens the results in that view"), + i("Linked Open Vocabularies suggestions work again, from a bundled list that includes the ERA ontology"), + f("Even spacing around the icon bar of the side panel"), + f("Selected text in the default light editor theme is visible"), + ], + }, + { + version: "6.8.0", + date: "2026-10-18", + summary: "A lighter, calmer MatGUI with a search for everything.", + changes: [ + n("Search with Ctrl+K: saved queries, snippets, commands and settings"), + n("One card per query, with its title, breadcrumbs and saved status"), + n("Editor toolbar with Prefixes, Insert, Share, Format, Save, Download and full screen"), + n("Describe panel can be resized, floated as a window or maximized"), + i("Side panel keeps an icon bar on the left; Ctrl+B shows or hides it"), + i("New look: pill tabs, the Geist font and quieter colours, in light and dark"), + f("Full screen results stay below the top bar"), + ], + }, + { + version: "6.7.0", + date: "2026-10-17", + summary: "A new look in the MatData style.", + changes: [ + n("Top bar with the tabs, help, theme and settings"), + n("Query bar with an endpoint picker and Run next to it; Run turns into Stop while a query runs"), + n("One side panel with the saved queries and the endpoint overview"), + n("Shared describe panel for all viewers, with back and forward"), + i("Code snippets in one Insert menu with search"), + i("All viewers put their controls in the same toolbar under the results"), + ], + }, + { + version: "6.6.0", + date: "2026-10-07", + changes: [ + n("Describe in the table: Ctrl+click an IRI in the result to hop to it"), + n("Back and forward (Alt+← / Alt+→) through describe hops in the table and the map"), + ], + }, + { + version: "6.5.0", + date: "2026-10-06", + changes: [ + n("Workspaces can be read-only"), + f("Map labels and hover colour no longer stick on geometry collections"), + ], + }, + { + version: "6.4.1", + date: "2026-10-06", + changes: [f("A font was missing from the package")], + }, + { + version: "6.4.0", + date: "2026-10-06", + changes: [ + n("Map: Ctrl+click to describe a feature, hover highlighting and labels"), + n("Map: no-basemap option, keyless background maps and the OpenRailwayMap overlay"), + f("Map popups keep long IRIs and geometries inside the popup"), + f("Table: the right-click IRI menu works on shortened IRIs"), + ], + }, + { + version: "6.3.0", + date: "2026-09-30", + changes: [ + n("Endpoint overview (F8): service description, statistics, classes, properties, graphs and more"), + n("SPARQL 1.2 syntax in the editor: triple terms, reifiers, annotations and the new functions"), + i("Formatting with @matdata/sparql-formatter, which supports SPARQL 1.2 and keeps comments"), + ], + }, + { + version: "6.2.0", + date: "2026-09-29", + changes: [f("Several small fixes in the table and graph viewers")], + }, + { + version: "6.1.0", + date: "2026-09-13", + changes: [ + n("Table: fixed number of decimal places for decimal and floating point values"), + n("Table: Ctrl+click or right-click an IRI to describe it"), + f("Map: empty geometries, mixed coordinate systems and zooming after a reload"), + ], + }, + { + version: "6.0.0", + date: "2026-09-12", + summary: "Yasgui is now MatGUI, with a new editor.", + changes: [ + n("Rebrand to MatGUI; nothing changes for your saved queries and settings"), + n("New query editor built on CodeMirror 6"), + n("Ctrl+click an IRI to see its triples as subject, Ctrl+Shift+click as object"), + f("The results no longer collapse to zero height and scroll for large results"), + f("The layout mode is kept after a reload and across tabs"), + ], + }, + { + version: "5.20.0", + date: "2026-05-21", + changes: [ + n("New map viewer with more features"), + f("Prefix completion no longer depends on prefix.cc being reachable"), + ], + }, + { + version: "5.19.0", + date: "2026-05-13", + changes: [ + n("Map widget to draw a WKT point, line or polygon"), + f("The map in the WKT widget has a height again (5.19.1)"), + ], + }, + { + version: "5.18.0", + date: "2026-05-11", + changes: [ + n("Switch between tabs in most recently used order with a shortcut"), + n("Version history of managed queries"), + n("Copy URI in the saved query browser"), + i("Endpoints in the settings are sorted alphabetically"), + f("Persisted prefixes are no longer duplicated"), + f("Ctrl+click describe works in update mode"), + ], + }, + { + version: "5.17.0", + date: "2026-02-28", + changes: [ + n("Graph: double-click a resource to expand the graph"), + f("Background queries no longer overwrite the results"), + ], + }, + { + version: "5.16.0", + date: "2026-02-28", + changes: [ + n("Move a managed query to another folder"), + n("Save a query as a local .rq file"), + n("Share a query as a PowerShell here-string"), + i("Better on mobile; Copy tab is now Duplicate tab"), + f("Tabs can be dragged in Vivaldi"), + ], + }, + { + version: "5.15.0", + date: "2026-02-07", + changes: [ + i("Editor buttons moved to the bottom of the editor and work better on small screens"), + n("Pick an existing workspace IRI when creating a SPARQL workspace"), + f("Windows no longer close when you release the mouse outside them"), + f("Relative endpoint URLs and quick describe of prefixed IRIs"), + ], + }, + { + version: "5.14.0", + date: "2026-01-12", + changes: [f("Custom headers can be added to the request again")], + }, + { + version: "5.13.0", + date: "2026-01-12", + changes: [ + n("Rainbow brackets and a GitHub dark editor theme in dark mode"), + i("Clear feedback when a workspace is not configured correctly"), + i("Git workspaces save queries as .rq files"), + ], + }, + { + version: "5.12.0", + date: "2026-01-06", + changes: [i("Better dark theme"), n("Choose the editor theme for light and dark mode")], + }, + { + version: "5.11.0", + date: "2026-01-01", + summary: "Save your queries to a SPARQL endpoint or a Git repository.", + changes: [n("Workspaces and managed queries"), i("New icons and buttons")], + }, + { + version: "5.10.0", + date: "2025-12-21", + changes: [ + n("Share as PowerShell or wget, and shorten the share link"), + n("A warning when a shared command contains credentials"), + f("The table switches between short and full IRIs again (5.10.1)"), + ], + }, + { + version: "5.9.0", + date: "2025-12-19", + changes: [ + n("Turn off endpoint buttons configured by the site"), + i("Endpoint buttons that don't fit go into a dropdown"), + f("Renaming tabs: arrow keys, selecting text and Delete behave as expected"), + f("Tab context menu size and editor border in dark mode"), + ], + }, + { + version: "5.8.0", + date: "2025-12-18", + changes: [ + n("Resizer between the editor and results in side-by-side layout"), + i("Table and graph fill the available space"), + f("Long queries scroll in side-by-side layout"), + ], + }, + { + version: "5.7.0", + date: "2025-12-16", + changes: [ + i("New table viewer built on Tabulator"), + n("OAuth 2.0, bearer token and API key authentication"), + n("Clear the stored data"), + i("Clearer messages for CORS errors"), + f("Commenting with the keyboard works with the formatter"), + ], + }, + { + version: "5.5.0", + date: "2025-12-11", + changes: [n("HTTP basic authentication per endpoint"), i("Settings with a navigation on the left")], + }, + { + version: "5.4.0", + date: "2025-12-11", + changes: [ + n("Code snippets in the editor"), + n("Keyboard shortcuts and About in the settings"), + i("Table: IRI and datatype toggles, copy as Markdown"), + f("The layout mode applies to all tabs"), + ], + }, + { + version: "5.3.0", + date: "2025-12-10", + changes: [ + n("Export and import your configuration"), + n("Query formatter, optionally formatting before each run"), + n("Undeclared variables in CONSTRUCT templates are highlighted"), + n("User and developer guide"), + ], + }, + { + version: "5.2.0", + date: "2025-12-08", + changes: [ + n("Side-by-side layout of the editor and results"), + n("Quick switch buttons for endpoints"), + f("Dark mode for the tab list and the control bar"), + f("ASK results"), + ], + }, + { + version: "5.1.0", + date: "2025-12-07", + changes: [n("Dark mode with a theme toggle"), n("A hint about quick describe when the cursor is on an IRI")], + }, + { + version: "5.0.0", + date: "2025-12-06", + changes: [ + n("Graph viewer for RDF results"), + n("Map viewer for geographical data"), + n("Ctrl+click an IRI to describe it"), + n("Prefixes are added and remembered automatically"), + n("Full screen editor and results"), + i("Turtle is the default Accept header for CONSTRUCT and DESCRIBE"), + ], + }, +]; + +/** Compare two versions like "6.10.0"; negative when a is older than b */ +export function compareVersions(a: string, b: string): number { + const pa = a.split(/[.-]/).map((part) => parseInt(part, 10) || 0); + const pb = b.split(/[.-]/).map((part) => parseInt(part, 10) || 0); + for (let k = 0; k < Math.max(pa.length, pb.length); k++) { + const diff = (pa[k] || 0) - (pb[k] || 0); + if (diff) return diff; + } + return 0; +} diff --git a/test/run.ts b/test/run.ts index 91e3494e..26b3b50f 100644 --- a/test/run.ts +++ b/test/run.ts @@ -13,6 +13,7 @@ import "./endpoint-describe-browser.js"; import "./iri-hover-browser.js"; import "./ai-assistant-browser.js"; import "./sparql-anything-browser.js"; +import "./whats-new-browser.js"; declare var window: Window & { Yasqe: typeof Yasqe; diff --git a/test/whats-new-browser.ts b/test/whats-new-browser.ts new file mode 100644 index 00000000..28c6249b --- /dev/null +++ b/test/whats-new-browser.ts @@ -0,0 +1,85 @@ +import * as path from "path"; +import * as http from "http"; +import * as chai from "chai"; +import * as puppeteer from "puppeteer"; +import { it, describe, before, beforeEach, after, afterEach } from "mocha"; +import { setup, destroy, closePage, getPage } from "./utils.js"; + +const expect = chai.expect; + +describe("Help menu and What's new", function () { + let browser: puppeteer.Browser; + let page: puppeteer.Page; + let server: http.Server | undefined; + + before(async function () { + const refs = await setup(this, path.resolve("./build")); + browser = refs.browser; + server = refs.server; + }); + + beforeEach(async function () { + this.timeout(30000); + page = await getPage(browser, "yasgui.html"); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: "networkidle2" }); + await page.waitForSelector(".yasgui-helpButton"); + }); + + afterEach(async () => { + await closePage(this, page); + }); + + after(async function () { + return destroy(browser, server); + }); + + it("doesn't badge the help button on a first visit", async function () { + expect(await page.$(".yasgui-helpButton__badge")).to.equal(null); + const seen = await page.evaluate(() => (window as any).yasgui.persistentConfig.getWhatsNewSeen()); + const latest = await page.evaluate(() => (window as any).yasgui.whatsNew.getLatestVersion()); + expect(seen).to.equal(latest); + }); + + it("badges the help button after an update, until What's new is opened", async function () { + await page.evaluate(() => (window as any).yasgui.persistentConfig.setWhatsNewSeen("6.0.0")); + await page.reload({ waitUntil: "networkidle2" }); + await page.waitForSelector(".yasgui-helpButton__badge"); + + await page.click(".yasgui-helpButton"); + await page.waitForSelector(".yasgui-helpMenu:not([hidden])"); + const labels = await page.$$eval(".yasgui-helpMenu__item", (items) => items.map((el) => el.textContent?.trim())); + expect(labels).to.include("What's new"); + expect(labels).to.include("Documentation"); + expect(labels.indexOf("What's new")).to.be.lessThan(labels.indexOf("Documentation")); + + const items = await page.$$(".yasgui-helpMenu__item"); + await items[labels.indexOf("What's new")].click(); + await page.waitForSelector(".yasgui-whatsNew:not([hidden])"); + expect(await page.$(".yasgui-helpButton__badge")).to.equal(null); + + const versions = await page.$$eval(".yasgui-whatsNew__release h3", (els) => els.map((el) => el.textContent)); + expect(versions).to.include("v6.0.0"); + expect(versions).to.include("v5.0.0"); + // Releases after the last seen one are marked + const unseen = await page.$$eval(".yasgui-whatsNew__release", (sections) => + sections + .filter((s) => s.querySelector(".yasgui-whatsNew__unseen")) + .map((s) => s.querySelector("h3")?.textContent), + ); + expect(unseen).to.not.include("v6.0.0"); + expect(unseen).to.include("v6.1.0"); + }); + + it("filters the changes on New, Improved and Fixed", async function () { + await page.evaluate(() => (window as any).yasgui.whatsNew.open()); + await page.waitForSelector(".yasgui-whatsNew:not([hidden])"); + await page.click('.yasgui-whatsNew__filters [data-filter="fixed"]'); + const kinds = await page.$$eval(".yasgui-whatsNew__kind", (els) => els.map((el) => el.textContent)); + expect(kinds.length).to.be.greaterThan(0); + expect(kinds.every((kind) => kind === "Fixed")).to.equal(true); + + await page.keyboard.press("Escape"); + expect(await page.$eval(".yasgui-whatsNew", (el) => (el as HTMLElement).hidden)).to.equal(true); + }); +});