From bb90d2d757cc4eb226bc78a499280c8a780bb920 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 12:01:39 -0400 Subject: [PATCH] feat: multi-tab query editor Adds a tab strip above the query editor so multiple queries can be drafted side by side. Tab state lives in the query reducer: the existing top-level fields remain the live (active-tab) fields, and switching tabs saves/restores them, so all existing consumers are untouched. Legacy persisted state without tabs hydrates into a single tab containing the current query. Co-Authored-By: Claude Fable 5 --- client/src/actions/query.js | 24 +++ client/src/components/EditorPanel.js | 2 + client/src/components/EditorTabs.js | 101 ++++++++++++ client/src/components/EditorTabs.scss | 98 ++++++++++++ client/src/reducers/query.js | 114 ++++++++++++++ client/src/reducers/query.test.js | 217 ++++++++++++++++++++++++++ 6 files changed, 556 insertions(+) create mode 100644 client/src/components/EditorTabs.js create mode 100644 client/src/components/EditorTabs.scss create mode 100644 client/src/reducers/query.test.js diff --git a/client/src/actions/query.js b/client/src/actions/query.js index 50fbb4af..fe8767ad 100644 --- a/client/src/actions/query.js +++ b/client/src/actions/query.js @@ -9,6 +9,10 @@ export const UPDATE_QUERY_AND_ACTION = 'query/UPDATE_QUERY_AND_ACTION' export const UPDATE_QUERY_VARS = 'query/UPDATE_QUERY_VARS' export const UPDATE_READ_ONLY = 'query/UPDATE_READ_ONLY' export const UPDATE_BEST_EFFORT = 'query/UPDATE_BEST_EFFORT' +export const ADD_TAB = 'query/ADD_TAB' +export const CLOSE_TAB = 'query/CLOSE_TAB' +export const SWITCH_TAB = 'query/SWITCH_TAB' +export const RENAME_TAB = 'query/RENAME_TAB' export function updateQuery(query) { return { @@ -50,3 +54,23 @@ export const updateQueryVars = (newVars) => ({ type: UPDATE_QUERY_VARS, newVars, }) + +export const addTab = () => ({ + type: ADD_TAB, +}) + +export const closeTab = (id) => ({ + type: CLOSE_TAB, + id, +}) + +export const switchTab = (id) => ({ + type: SWITCH_TAB, + id, +}) + +export const renameTab = (id, name) => ({ + type: RENAME_TAB, + id, + name, +}) diff --git a/client/src/components/EditorPanel.js b/client/src/components/EditorPanel.js index ddcb2cee..db085199 100644 --- a/client/src/components/EditorPanel.js +++ b/client/src/components/EditorPanel.js @@ -18,6 +18,7 @@ import { updateReadOnly, } from 'actions/query' +import EditorTabs from 'components/EditorTabs' import QueryVarsEditor from 'components/QueryVarsEditor' import Editor from 'containers/Editor' @@ -92,6 +93,7 @@ export default function EditorPanel() { return (
+
{renderRadioBtn('query', 'Query', action, onUpdateAction)} diff --git a/client/src/components/EditorTabs.js b/client/src/components/EditorTabs.js new file mode 100644 index 00000000..4813f830 --- /dev/null +++ b/client/src/components/EditorTabs.js @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import classnames from 'classnames' +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { addTab, closeTab, renameTab, switchTab } from 'actions/query' + +import './EditorTabs.scss' + +export default function EditorTabs() { + const dispatch = useDispatch() + const { tabs = [], activeTabId } = useSelector((state) => state.query) + const [editingId, setEditingId] = useState(null) + const [draftName, setDraftName] = useState('') + + const startRename = (tab) => { + setEditingId(tab.id) + setDraftName(tab.name) + } + + const commitRename = () => { + if (editingId !== null) { + dispatch(renameTab(editingId, draftName)) + setEditingId(null) + } + } + + const onInputKeyDown = (e) => { + e.stopPropagation() + if (e.key === 'Enter') { + commitRename() + } else if (e.key === 'Escape') { + setEditingId(null) + } + } + + return ( +
+ {tabs.map((tab) => ( +
dispatch(switchTab(tab.id))} + onDoubleClick={() => startRename(tab)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + dispatch(switchTab(tab.id)) + } + }} + > + {editingId === tab.id ? ( + setDraftName(e.target.value)} + onBlur={commitRename} + onKeyDown={onInputKeyDown} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + /> + ) : ( + + {tab.name} + + )} + {tabs.length > 1 && ( + + )} +
+ ))} + +
+ ) +} diff --git a/client/src/components/EditorTabs.scss b/client/src/components/EditorTabs.scss new file mode 100644 index 00000000..49dbfc82 --- /dev/null +++ b/client/src/components/EditorTabs.scss @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.editor-tabs { + display: flex; + align-items: flex-end; + overflow-x: auto; + padding: 3px 4px 0; + background-color: #f3f3f3; + border-bottom: 1px solid #d2d2d2; + + .editor-tab { + display: inline-flex; + align-items: center; + max-width: 180px; + margin-right: 2px; + margin-bottom: -1px; + padding: 3px 8px; + border: 1px solid transparent; + border-bottom: 1px solid #d2d2d2; + border-radius: 3px 3px 0 0; + background: transparent; + color: #8a8a8a; + font-size: 12px; + line-height: 18px; + white-space: nowrap; + cursor: pointer; + user-select: none; + outline: none; + + &:hover { + background: #ececec; + color: #555; + } + + &.active { + background: #fff; + border-color: #d2d2d2; + border-bottom-color: #fff; + color: #333; + } + + .editor-tab-name { + overflow: hidden; + text-overflow: ellipsis; + } + + .editor-tab-close { + visibility: hidden; + margin-left: 6px; + padding: 0 2px; + border: none; + background: transparent; + color: #8a8a8a; + font-size: 12px; + line-height: 1; + cursor: pointer; + + &:hover { + color: #333; + } + } + + &:hover .editor-tab-close, + &.active .editor-tab-close { + visibility: visible; + } + + .editor-tab-rename-input { + width: 100px; + padding: 0 2px; + border: 1px solid #d2d2d2; + border-radius: 2px; + font-size: 12px; + line-height: 16px; + color: #333; + outline: none; + } + } + + .editor-tab-add { + flex: none; + margin-bottom: 1px; + padding: 1px 8px 3px; + border: none; + background: transparent; + color: #8a8a8a; + font-size: 14px; + line-height: 1; + cursor: pointer; + + &:hover { + color: #333; + } + } +} diff --git a/client/src/reducers/query.js b/client/src/reducers/query.js index e5096f58..b3228f37 100644 --- a/client/src/reducers/query.js +++ b/client/src/reducers/query.js @@ -4,6 +4,10 @@ */ import { + ADD_TAB, + CLOSE_TAB, + RENAME_TAB, + SWITCH_TAB, UPDATE_ACTION, UPDATE_BEST_EFFORT, UPDATE_QUERY, @@ -12,6 +16,17 @@ import { UPDATE_READ_ONLY, } from 'actions/query' import produce from 'immer' +import uuid from 'uuid' + +const makeTab = (name, fields = {}) => ({ + id: uuid(), + name, + query: '', + action: 'query', + queryVars: [], + allQueries: { query: '', mutate: '', alter: '' }, + ...fields, +}) const defaultState = { query: '', @@ -20,6 +35,35 @@ const defaultState = { action: 'query', readOnly: false, bestEffort: false, + tabs: [], + activeTabId: null, + tabCounter: 0, +} + +// Saves the live top-level editor fields into the currently active tab. +const saveActiveTab = (draft) => { + const tab = draft.tabs.find((t) => t.id === draft.activeTabId) + if (!tab) { + return + } + tab.query = draft.query + tab.action = draft.action + tab.queryVars = draft.queryVars + tab.allQueries = draft.allQueries +} + +// Loads a tab's fields into the live top-level editor fields. +const loadTab = (draft, tab) => { + draft.activeTabId = tab.id + draft.query = tab.query || '' + draft.action = tab.action || 'query' + draft.queryVars = tab.queryVars || [] + draft.allQueries = tab.allQueries || { + query: '', + mutate: '', + alter: '', + [draft.action]: draft.query, + } } export default (state = defaultState, action) => @@ -28,6 +72,24 @@ export default (state = defaultState, action) => draft.action = draft.action || 'query' draft.allQueries[draft.action] = draft.query || '' + // Migration: legacy persisted state (or a fresh store) has no tabs. + // Hydrate a single tab from the current top-level query fields. + if (!Array.isArray(draft.tabs) || draft.tabs.length === 0) { + draft.tabCounter = 1 + draft.tabs = [ + makeTab('Query 1', { + query: draft.query || '', + action: draft.action, + queryVars: draft.queryVars || [], + allQueries: draft.allQueries, + }), + ] + draft.activeTabId = draft.tabs[0].id + } else if (!draft.tabs.some((t) => t.id === draft.activeTabId)) { + draft.activeTabId = draft.tabs[0].id + } + draft.tabCounter = draft.tabCounter || draft.tabs.length + switch (action.type) { case UPDATE_QUERY: draft.query = action.query @@ -56,9 +118,61 @@ export default (state = defaultState, action) => draft.queryVars = action.newVars break + case ADD_TAB: { + saveActiveTab(draft) + draft.tabCounter += 1 + const tab = makeTab(`Query ${draft.tabCounter}`) + draft.tabs.push(tab) + loadTab(draft, tab) + break + } + + case SWITCH_TAB: { + const target = draft.tabs.find((t) => t.id === action.id) + if (target && target.id !== draft.activeTabId) { + saveActiveTab(draft) + loadTab(draft, target) + } + break + } + + case CLOSE_TAB: { + const index = draft.tabs.findIndex((t) => t.id === action.id) + if (index === -1) { + break + } + if (draft.tabs.length === 1) { + // Closing the last tab resets to a single empty tab. + draft.tabCounter = 1 + const tab = makeTab('Query 1') + draft.tabs = [tab] + loadTab(draft, tab) + break + } + const wasActive = action.id === draft.activeTabId + draft.tabs.splice(index, 1) + if (wasActive) { + const neighbor = draft.tabs[Math.min(index, draft.tabs.length - 1)] + loadTab(draft, neighbor) + } + break + } + + case RENAME_TAB: { + const tab = draft.tabs.find((t) => t.id === action.id) + const name = (action.name || '').trim() + if (tab && name) { + tab.name = name + } + break + } + default: break } draft.allQueries[draft.action] = draft.query + // Keep the active tab's record in sync with the live fields so that + // persisted tabs are always accurate. + saveActiveTab(draft) }) diff --git a/client/src/reducers/query.test.js b/client/src/reducers/query.test.js new file mode 100644 index 00000000..f9d01604 --- /dev/null +++ b/client/src/reducers/query.test.js @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + addTab, + closeTab, + renameTab, + switchTab, + updateQuery, + updateQueryAndAction, + updateQueryVars, +} from 'actions/query' +import reducer from 'reducers/query' + +const init = (state = undefined) => reducer(state, { type: '@@INIT' }) + +const activeTab = (state) => state.tabs.find((t) => t.id === state.activeTabId) + +describe('query reducer tabs', () => { + it('initializes with a single empty tab', () => { + const state = init() + + expect(state.tabs).toHaveLength(1) + expect(state.tabs[0].name).toBe('Query 1') + expect(state.activeTabId).toBe(state.tabs[0].id) + expect(state.query).toBe('') + expect(state.action).toBe('query') + }) + + it('migrates legacy persisted state without tabs', () => { + const legacy = { + query: '{ legacy(func: has(name)) { name } }', + queryVars: [{ name: 'var1', value: 'val1' }], + allQueries: { query: '{ legacy(func: has(name)) { name } }' }, + action: 'query', + readOnly: false, + bestEffort: false, + } + const state = init(legacy) + + expect(state.tabs).toHaveLength(1) + expect(state.activeTabId).toBe(state.tabs[0].id) + expect(state.tabs[0].query).toBe(legacy.query) + expect(state.tabs[0].action).toBe('query') + expect(state.tabs[0].queryVars).toEqual(legacy.queryVars) + // Top-level fields are untouched by the migration. + expect(state.query).toBe(legacy.query) + }) + + it('migrates legacy mutate state without crashing', () => { + const state = reducer( + { query: 'mutation text', action: 'mutate' }, + updateQuery('mutation text 2'), + ) + + expect(state.tabs).toHaveLength(1) + expect(state.query).toBe('mutation text 2') + expect(state.tabs[0].query).toBe('mutation text 2') + expect(state.tabs[0].action).toBe('mutate') + }) + + it('adds a new empty tab and switches to it', () => { + let state = init() + state = reducer(state, updateQuery('{ first }')) + state = reducer(state, addTab()) + + expect(state.tabs).toHaveLength(2) + expect(state.tabs[1].name).toBe('Query 2') + expect(state.activeTabId).toBe(state.tabs[1].id) + expect(state.query).toBe('') + expect(state.action).toBe('query') + // The tab that was left keeps its content. + expect(state.tabs[0].query).toBe('{ first }') + }) + + it('switching tabs preserves edits in the tab being left', () => { + let state = init() + state = reducer(state, updateQuery('{ first }')) + state = reducer(state, addTab()) + state = reducer(state, updateQuery('{ second }')) + state = reducer(state, updateQueryVars([{ name: 'v', value: '1' }])) + + const [tab1, tab2] = state.tabs + state = reducer(state, switchTab(tab1.id)) + + expect(state.activeTabId).toBe(tab1.id) + expect(state.query).toBe('{ first }') + expect(state.queryVars).toEqual([]) + expect(state.tabs.find((t) => t.id === tab2.id).query).toBe('{ second }') + expect(state.tabs.find((t) => t.id === tab2.id).queryVars).toEqual([ + { name: 'v', value: '1' }, + ]) + + // And switching back restores the second tab's edits. + state = reducer(state, switchTab(tab2.id)) + expect(state.query).toBe('{ second }') + expect(state.queryVars).toEqual([{ name: 'v', value: '1' }]) + }) + + it('ignores switching to an unknown tab id', () => { + let state = init() + state = reducer(state, updateQuery('{ keep }')) + const before = state + state = reducer(state, switchTab('no-such-id')) + + expect(state.activeTabId).toBe(before.activeTabId) + expect(state.query).toBe('{ keep }') + }) + + it('closes an inactive tab without changing the active one', () => { + let state = init() + state = reducer(state, updateQuery('{ first }')) + state = reducer(state, addTab()) + state = reducer(state, updateQuery('{ second }')) + + const [tab1, tab2] = state.tabs + state = reducer(state, closeTab(tab1.id)) + + expect(state.tabs).toHaveLength(1) + expect(state.activeTabId).toBe(tab2.id) + expect(state.query).toBe('{ second }') + }) + + it('closing the active tab activates its neighbor', () => { + let state = init() + state = reducer(state, updateQuery('{ first }')) + state = reducer(state, addTab()) + state = reducer(state, updateQuery('{ second }')) + state = reducer(state, addTab()) + state = reducer(state, updateQuery('{ third }')) + + // Make the middle tab active, then close it. + const [, tab2, tab3] = state.tabs + state = reducer(state, switchTab(tab2.id)) + state = reducer(state, closeTab(tab2.id)) + + expect(state.tabs).toHaveLength(2) + expect(state.activeTabId).toBe(tab3.id) + expect(state.query).toBe('{ third }') + + // Closing the active last tab falls back to the previous neighbor. + state = reducer(state, closeTab(tab3.id)) + expect(state.tabs).toHaveLength(1) + expect(state.query).toBe('{ first }') + }) + + it('closing the last remaining tab resets to a single empty tab', () => { + let state = init() + state = reducer(state, updateQuery('{ first }')) + state = reducer(state, updateQueryVars([{ name: 'v', value: '1' }])) + const oldId = state.activeTabId + state = reducer(state, closeTab(oldId)) + + expect(state.tabs).toHaveLength(1) + expect(state.tabs[0].id).not.toBe(oldId) + expect(state.tabs[0].name).toBe('Query 1') + expect(state.activeTabId).toBe(state.tabs[0].id) + expect(state.query).toBe('') + expect(state.queryVars).toEqual([]) + }) + + it('ignores closing an unknown tab id', () => { + let state = init() + state = reducer(state, closeTab('no-such-id')) + + expect(state.tabs).toHaveLength(1) + }) + + it('renames a tab', () => { + let state = init() + state = reducer(state, addTab()) + const [tab1] = state.tabs + state = reducer(state, renameTab(tab1.id, ' My Tab ')) + + expect(state.tabs[0].name).toBe('My Tab') + // The other tab is untouched. + expect(state.tabs[1].name).toBe('Query 2') + }) + + it('ignores renaming to an empty name', () => { + let state = init() + state = reducer(state, renameTab(state.tabs[0].id, ' ')) + + expect(state.tabs[0].name).toBe('Query 1') + }) + + it('updateQueryAndAction updates the active tab', () => { + let state = init() + state = reducer(state, addTab()) + state = reducer(state, updateQueryAndAction('{ fromHistory }', 'mutate')) + + expect(state.query).toBe('{ fromHistory }') + expect(state.action).toBe('mutate') + expect(activeTab(state).query).toBe('{ fromHistory }') + expect(activeTab(state).action).toBe('mutate') + // The inactive tab is untouched. + expect(state.tabs[0].query).toBe('') + }) + + it('keeps the active tab record in sync with live edits', () => { + let state = init() + state = reducer(state, updateQuery('{ live }')) + + expect(activeTab(state).query).toBe('{ live }') + }) + + it('numbers new tabs with an increasing counter', () => { + let state = init() + state = reducer(state, addTab()) + state = reducer(state, closeTab(state.tabs[1].id)) + state = reducer(state, addTab()) + + expect(state.tabs[1].name).toBe('Query 3') + }) +})