diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 11e749c..d2a5e05 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -2241,6 +2241,38 @@ const plugins = yasr.getPlugins(); console.log('Available plugins:', Object.keys(plugins)); ``` +##### `getAvailablePlugins(): { name: string; label: string; priority: number }[]` + +Get list of all available plugins with their labels and priorities (excludes hidden plugins). + +```javascript +const availablePlugins = yasr.getAvailablePlugins(); +console.log('Available plugins:', availablePlugins); +// Output: [{ name: 'table', label: 'Table', priority: 10 }, ...] +``` + +##### `getPluginOrder(): { select?: string[]; construct?: string[] } | undefined` + +Get the current plugin order preferences for SELECT/ASK and CONSTRUCT/DESCRIBE queries. + +```javascript +const pluginOrder = yasr.getPluginOrder(); +console.log('SELECT order:', pluginOrder?.select); +console.log('CONSTRUCT order:', pluginOrder?.construct); +``` + +##### `setPluginOrder(order: { select?: string[]; construct?: string[] }): void` + +Set plugin order preferences to customize which plugins are preferred for different query types. + +```javascript +// Set preferred order for SELECT queries +yasr.setPluginOrder({ + select: ['response', 'table'], + construct: ['graph', 'response'] +}); +``` + ##### `download(filename?: string): void` Download results using current plugin's download method. diff --git a/docs/user-guide.md b/docs/user-guide.md index 771d446..a326431 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1142,12 +1142,37 @@ YASGUI automatically selects the most appropriate plugin based on: - Query type (SELECT, CONSTRUCT, ASK, DESCRIBE) - Response content type - Data structure +- Your configured plugin order preferences (see below) **Manual Selection:** - Use the plugin selector buttons at the top of the results area - Available plugins depend on the query results - Your selection is saved per tab +**Configuring Plugin Order Preferences:** + +You can customize the order in which plugins are preferred when displaying query results. This allows you to prioritize your favorite visualization methods. + +1. **Open Settings**: Click the gear icon (⚙️) in the control bar +2. **Navigate to Output Preferences**: Select the "Output Preferences" tab in the settings sidebar +3. **Configure Preferences**: You'll see two separate lists: + - **SELECT / ASK Query Results**: For tabular query results + - **CONSTRUCT / DESCRIBE Query Results**: For graph query results +4. **Reorder Plugins**: Drag plugins using the handle (☰) to reorder them + - Plugins at the top of the list will be preferred when displaying results + - The first compatible plugin in your list will be automatically selected +5. **Save Changes**: Your preferences are saved automatically as you reorder + +**Example Use Cases:** +- If you prefer viewing raw JSON responses, move the "Response" plugin to the top of the SELECT list +- For graph visualizations, prioritize the "Graph" plugin over the "Response" plugin in the CONSTRUCT list +- Customize separately for different query types based on your workflow + +**Notes:** +- Plugin order preferences are persistent and apply across all tabs +- The actual plugin used depends on both your preference order and plugin compatibility with the current results +- If your preferred plugin cannot handle the current results, the next compatible plugin in your list will be used automatically + --- ## Keyboard Shortcuts diff --git a/packages/yasgui/src/TabSettingsModal.scss b/packages/yasgui/src/TabSettingsModal.scss index e5eac58..0adcba1 100644 --- a/packages/yasgui/src/TabSettingsModal.scss +++ b/packages/yasgui/src/TabSettingsModal.scss @@ -1085,3 +1085,80 @@ transform: translateY(1px); } } + +// Plugin Order Settings +.pluginOrderSection { + margin-bottom: 30px; +} + +.pluginOrderList { + list-style: none; + padding: 0; + margin: 10px 0; + border: 1px solid var(--yasgui-border-color, #ddd); + border-radius: 4px; + background: var(--yasgui-bg-secondary, #f9f9f9); + max-width: 500px; +} + +.pluginOrderItem { + display: flex; + align-items: center; + padding: 12px 16px; + background: var(--yasgui-bg-primary, white); + border-bottom: 1px solid var(--yasgui-border-color, #ddd); + cursor: move; + user-select: none; + transition: + background-color 0.2s, + box-shadow 0.2s; + + &:last-child { + border-bottom: none; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } + + &:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + &:hover { + background: var(--yasgui-bg-hover, #f5f5f5); + } +} + +.pluginOrderDragHandle { + margin-right: 12px; + font-size: 18px; + color: var(--yasgui-text-muted, #666); + cursor: grab; + user-select: none; + + &:active { + cursor: grabbing; + } +} + +.pluginOrderLabel { + flex: 1; + font-size: 14px; + font-weight: 500; + color: var(--yasgui-text-primary, #333); +} + +.pluginOrderGhost { + opacity: 0.4; + background: var(--yasgui-bg-hover, #f5f5f5); +} + +.pluginOrderChosen { + background: var(--yasgui-accent-light, #e6f2ff); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.pluginOrderDrag { + opacity: 0.8; + transform: rotate(2deg); +} diff --git a/packages/yasgui/src/TabSettingsModal.ts b/packages/yasgui/src/TabSettingsModal.ts index 4603b71..f215669 100644 --- a/packages/yasgui/src/TabSettingsModal.ts +++ b/packages/yasgui/src/TabSettingsModal.ts @@ -5,6 +5,7 @@ import * as ConfigExportImport from "./ConfigExportImport"; import { VERSION } from "./version"; import * as OAuth2Utils from "./OAuth2Utils"; import PersistentConfig from "./PersistentConfig"; +import sortablejs from "sortablejs"; // Theme toggle icons const MOON_ICON = ` @@ -142,6 +143,11 @@ export default class TabSettingsModal { addClass(prefixTab, "modalNavButton"); prefixTab.onclick = () => this.switchTab("prefix"); + const outputTab = document.createElement("button"); + outputTab.textContent = "Output Preferences"; + addClass(outputTab, "modalNavButton"); + outputTab.onclick = () => this.switchTab("output"); + const editorTab = document.createElement("button"); editorTab.textContent = "Editor"; addClass(editorTab, "modalNavButton"); @@ -165,6 +171,7 @@ export default class TabSettingsModal { sidebar.appendChild(requestTab); sidebar.appendChild(endpointsTab); sidebar.appendChild(prefixTab); + sidebar.appendChild(outputTab); sidebar.appendChild(editorTab); sidebar.appendChild(importExportTab); sidebar.appendChild(shortcutsTab); @@ -193,6 +200,11 @@ export default class TabSettingsModal { prefixContent.id = "prefix-content"; this.drawPrefixSettings(prefixContent); + const outputContent = document.createElement("div"); + addClass(outputContent, "modalTabContent"); + outputContent.id = "output-content"; + this.drawOutputSettings(outputContent); + const editorContent = document.createElement("div"); addClass(editorContent, "modalTabContent"); editorContent.id = "editor-content"; @@ -216,6 +228,7 @@ export default class TabSettingsModal { contentArea.appendChild(requestContent); contentArea.appendChild(endpointsContent); contentArea.appendChild(prefixContent); + contentArea.appendChild(outputContent); contentArea.appendChild(editorContent); contentArea.appendChild(importExportContent); contentArea.appendChild(shortcutsContent); @@ -327,6 +340,173 @@ export default class TabSettingsModal { container.appendChild(section); } + private drawOutputSettings(container: HTMLElement) { + const yasr = this.tab.getYasr(); + if (!yasr) { + const notice = document.createElement("p"); + addClass(notice, "settingsHelp"); + notice.textContent = "Output settings will be available after running a query."; + container.appendChild(notice); + return; + } + + // Introduction + const intro = document.createElement("p"); + addClass(intro, "settingsHelp"); + intro.textContent = + "Configure the preferred order of output plugins for different query types. Plugins at the top of the list will be preferred when displaying results."; + container.appendChild(intro); + + // Get available plugins + const availablePlugins = yasr.getAvailablePlugins(); + const currentOrder = yasr.getPluginOrder() || { select: [], construct: [] }; + + // Initialize with priority-based order if empty + if (!currentOrder.select || currentOrder.select.length === 0) { + currentOrder.select = availablePlugins.map((p) => p.name); + } + if (!currentOrder.construct || currentOrder.construct.length === 0) { + currentOrder.construct = availablePlugins.map((p) => p.name); + } + + // SELECT/ASK queries section + const selectSection = document.createElement("div"); + addClass(selectSection, "settingsSection", "pluginOrderSection"); + + const selectLabel = document.createElement("h3"); + addClass(selectLabel, "settingsLabel"); + selectLabel.textContent = "SELECT / ASK Query Results"; + + const selectHelp = document.createElement("p"); + addClass(selectHelp, "settingsHelp"); + selectHelp.textContent = "Preferred plugin order for tabular query results (SELECT, ASK)."; + + const selectList = document.createElement("ul"); + addClass(selectList, "pluginOrderList"); + selectList.id = "select-plugin-order"; + + // Populate SELECT list + currentOrder.select.forEach((pluginName) => { + const plugin = availablePlugins.find((p) => p.name === pluginName); + if (plugin) { + const item = this.createPluginOrderItem(plugin.name, plugin.label); + selectList.appendChild(item); + } + }); + + // Add any plugins not in the current order + availablePlugins.forEach((plugin) => { + if (!currentOrder.select!.includes(plugin.name)) { + const item = this.createPluginOrderItem(plugin.name, plugin.label); + selectList.appendChild(item); + } + }); + + selectSection.appendChild(selectLabel); + selectSection.appendChild(selectHelp); + selectSection.appendChild(selectList); + + // CONSTRUCT/DESCRIBE queries section + const constructSection = document.createElement("div"); + addClass(constructSection, "settingsSection", "pluginOrderSection"); + + const constructLabel = document.createElement("h3"); + addClass(constructLabel, "settingsLabel"); + constructLabel.textContent = "CONSTRUCT / DESCRIBE Query Results"; + + const constructHelp = document.createElement("p"); + addClass(constructHelp, "settingsHelp"); + constructHelp.textContent = "Preferred plugin order for graph query results (CONSTRUCT, DESCRIBE)."; + + const constructList = document.createElement("ul"); + addClass(constructList, "pluginOrderList"); + constructList.id = "construct-plugin-order"; + + // Populate CONSTRUCT list + currentOrder.construct.forEach((pluginName) => { + const plugin = availablePlugins.find((p) => p.name === pluginName); + if (plugin) { + const item = this.createPluginOrderItem(plugin.name, plugin.label); + constructList.appendChild(item); + } + }); + + // Add any plugins not in the current order + availablePlugins.forEach((plugin) => { + if (!currentOrder.construct!.includes(plugin.name)) { + const item = this.createPluginOrderItem(plugin.name, plugin.label); + constructList.appendChild(item); + } + }); + + constructSection.appendChild(constructLabel); + constructSection.appendChild(constructHelp); + constructSection.appendChild(constructList); + + container.appendChild(selectSection); + container.appendChild(constructSection); + + // Initialize drag and drop + this.initializePluginOrderDragDrop(selectList, "select", yasr); + this.initializePluginOrderDragDrop(constructList, "construct", yasr); + } + + private createPluginOrderItem(pluginName: string, pluginLabel: string): HTMLLIElement { + const item = document.createElement("li"); + addClass(item, "pluginOrderItem"); + item.setAttribute("data-plugin-name", pluginName); + + const dragHandle = document.createElement("span"); + addClass(dragHandle, "pluginOrderDragHandle"); + dragHandle.innerHTML = "☰"; // Hamburger menu icon + dragHandle.title = "Drag to reorder"; + + const label = document.createElement("span"); + addClass(label, "pluginOrderLabel"); + label.textContent = pluginLabel; + + item.appendChild(dragHandle); + item.appendChild(label); + + return item; + } + + private initializePluginOrderDragDrop(listEl: HTMLElement, queryType: "select" | "construct", yasr: any) { + sortablejs.create(listEl, { + animation: 150, + handle: ".pluginOrderDragHandle", + ghostClass: "pluginOrderGhost", + chosenClass: "pluginOrderChosen", + dragClass: "pluginOrderDrag", + onEnd: () => { + // Get the new order from the list + const items = listEl.querySelectorAll("li[data-plugin-name]"); + const newOrder: string[] = []; + items.forEach((item) => { + const pluginName = item.getAttribute("data-plugin-name"); + if (pluginName) { + newOrder.push(pluginName); + } + }); + + // Save the new order + const currentOrder = yasr.getPluginOrder() || { select: [], construct: [] }; + if (queryType === "select") { + currentOrder.select = newOrder; + } else { + currentOrder.construct = newOrder; + } + yasr.setPluginOrder(currentOrder); + + // Show feedback + this.showNotification( + `${queryType === "select" ? "SELECT/ASK" : "CONSTRUCT/DESCRIBE"} plugin order updated`, + "success", + ); + }, + }); + } + private drawEditorSettings(container: HTMLElement) { const yasqe = this.tab.getYasqe(); if (!yasqe) { diff --git a/packages/yasr/src/index.ts b/packages/yasr/src/index.ts index cd8a9b4..972e640 100644 --- a/packages/yasr/src/index.ts +++ b/packages/yasr/src/index.ts @@ -21,6 +21,10 @@ import "./main.scss"; export interface PersistentConfig { selectedPlugin?: string; pluginsConfig?: { [pluginName: string]: any }; + pluginOrder?: { + select?: string[]; // Preferred order for SELECT/ASK queries + construct?: string[]; // Preferred order for CONSTRUCT/DESCRIBE queries + }; } export interface Yasr { @@ -170,6 +174,22 @@ export class Yasr extends EventEmitter { } } + /** + * Determines if current results are from a SELECT/ASK query or a CONSTRUCT/DESCRIBE query + * @returns 'select' for SELECT/ASK queries, 'construct' for CONSTRUCT/DESCRIBE queries + */ + private getQueryResultType(): "select" | "construct" { + if (!this.results) return "select"; + const type = this.results.getType(); + // CONSTRUCT and DESCRIBE return graph data (turtle, trig, rdf+json, etc.) + // SELECT and ASK return tabular data (json, xml, csv, tsv) + if (type === "json" || type === "xml" || type === "csv" || type === "tsv") { + return "select"; + } + // turtle, trig, etc. are graph formats + return "construct"; + } + private getCompatiblePlugins(): string[] { if (!this.results) return Object.keys( @@ -182,6 +202,42 @@ export class Yasr extends EventEmitter { supportedPlugins.push({ name: pluginName, priority: this.plugins[pluginName].priority }); } } + + // Get the user's preferred order based on query result type + const queryType = this.getQueryResultType(); + const storageId = this.getStorageId(this.config.persistenceLabelConfig); + let userPreferredOrder: string[] | undefined; + + if (storageId) { + const persistentConfig: PersistentConfig | undefined = this.storage.get(storageId); + if (persistentConfig?.pluginOrder) { + userPreferredOrder = + queryType === "select" ? persistentConfig.pluginOrder.select : persistentConfig.pluginOrder.construct; + } + } + + if (userPreferredOrder && userPreferredOrder.length > 0) { + // Use user-defined order, fallback to priority-based order for plugins not in the list + const orderedPlugins: string[] = []; + const supportedPluginNames = supportedPlugins.map((p) => p.name); + + // First, add plugins in user's preferred order that are supported + for (const pluginName of userPreferredOrder) { + if (supportedPluginNames.includes(pluginName)) { + orderedPlugins.push(pluginName); + } + } + + // Then add any remaining supported plugins sorted by priority + const remainingPlugins = supportedPlugins + .filter((p) => !orderedPlugins.includes(p.name)) + .sort((p1, p2) => p2.priority - p1.priority) + .map((p) => p.name); + + return [...orderedPlugins, ...remainingPlugins]; + } + + // Fallback to priority-based order (existing behavior) return supportedPlugins.sort((p1, p2) => p2.priority - p1.priority).map((p) => p.name); } public draw() { @@ -559,11 +615,68 @@ export class Yasr extends EventEmitter { } } public getPersistentConfig(): PersistentConfig { + const storageId = this.getStorageId(this.config.persistenceLabelConfig); + let existingPluginOrder: PersistentConfig["pluginOrder"] = undefined; + + // Retrieve existing plugin order from storage + if (storageId) { + const stored: PersistentConfig | undefined = this.storage.get(storageId); + if (stored?.pluginOrder) { + existingPluginOrder = stored.pluginOrder; + } + } + return { selectedPlugin: this.getSelectedPluginName(), pluginsConfig: mapValues(this.config.plugins, (plugin) => plugin.dynamicConfig), + pluginOrder: existingPluginOrder, }; } + + /** + * Get the current plugin order preferences + */ + public getPluginOrder(): PersistentConfig["pluginOrder"] { + const storageId = this.getStorageId(this.config.persistenceLabelConfig); + if (storageId) { + const persistentConfig: PersistentConfig | undefined = this.storage.get(storageId); + return persistentConfig?.pluginOrder; + } + return undefined; + } + + /** + * Set plugin order preferences for SELECT and/or CONSTRUCT queries + */ + public setPluginOrder(order: { select?: string[]; construct?: string[] }) { + const storageId = this.getStorageId(this.config.persistenceLabelConfig); + if (storageId) { + const persistentConfig: PersistentConfig = this.getPersistentConfig(); + persistentConfig.pluginOrder = { + ...(persistentConfig.pluginOrder || {}), + ...order, + }; + this.storage.set(storageId, persistentConfig, this.config.persistencyExpire, this.handleLocalStorageQuotaFull); + this.emit("change", this); + } + } + + /** + * Get list of all available plugins with their labels + */ + public getAvailablePlugins(): { name: string; label: string; priority: number }[] { + const plugins: { name: string; label: string; priority: number }[] = []; + for (const pluginName in this.plugins) { + const plugin = this.plugins[pluginName]; + if (plugin.hideFromSelection) continue; + plugins.push({ + name: pluginName, + label: plugin.label || pluginName, + priority: plugin.priority, + }); + } + return plugins.sort((a, b) => b.priority - a.priority); + } //This doesnt store the plugin complete config. Only those configs we want persisted public storePluginConfig(pluginName: string, conf: any) { this.config.plugins[pluginName].dynamicConfig = conf;