diff --git a/src/app/core/components/switch-button.tsx b/src/app/core/components/switch-button.tsx new file mode 100644 index 0000000000..77ce80f441 --- /dev/null +++ b/src/app/core/components/switch-button.tsx @@ -0,0 +1,108 @@ +import React from "react" +import styled from "styled-components" + +const Underline = styled.div` + position: absolute; + bottom: 0; + left: -4px; + right: -4px; + height: 2px; + background: var(--primary-color); + border-radius: 1px; + opacity: 0; +` + +const Button = styled.button` + background-color: var(--button-background); + border: none; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + min-width: 60px; + padding: 0 16px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + + span { + display: flex; + height: 100%; + align-items: center; + opacity: 0.5; + position: relative; + } + + &[aria-pressed="true"] { + span { + opacity: 1; + } + ${Underline} { + opacity: 1; + } + } + + &:hover:not([aria-pressed="true"]) { + background: var(--button-background-hover); + span { + opacity: 0.7; + } + } + + &:active:not([aria-pressed="true"]) { + background: var(--button-background-active); + } + + &:first-child { + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + } + &:last-child { + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + } +` + +const BG = styled.div<{minWidth: number}>` + user-select: none; + border-radius: 6px; + height: 22px; + display: flex; + gap: 1px; + + ${Button} { + min-width: ${(props) => props.minWidth}px; + } +` + +type Option = { + label: string + click: React.MouseEventHandler + active: boolean +} + +function Option(props: Option) { + return ( + + ) +} + +type Props = { + options: Option[] + minWidth?: number +} + +export function SwitchButton(props: Props) { + return ( + + {props.options.map((props, i) => ( + + ) +} diff --git a/src/app/core/hooks/use-auto-select.ts b/src/app/core/hooks/use-auto-select.ts new file mode 100644 index 0000000000..e0bc2dc898 --- /dev/null +++ b/src/app/core/hooks/use-auto-select.ts @@ -0,0 +1,9 @@ +import {RefObject, useLayoutEffect} from "react" + +export function useAutoSelect(ref: RefObject) { + useLayoutEffect(() => { + if (ref.current) { + ref.current.select() + } + }, []) +} diff --git a/src/app/core/hooks/use-select.ts b/src/app/core/hooks/use-select.ts index 9daa4d5cb4..a95833d64f 100644 --- a/src/app/core/hooks/use-select.ts +++ b/src/app/core/hooks/use-select.ts @@ -1,5 +1,5 @@ -import {useCallback} from "react" import {useStore} from "react-redux" +import {State} from "src/js/state/types" /** * This is useful when you need to select from the state @@ -8,10 +8,9 @@ import {useStore} from "react-redux" * needs to grab it once in the event handler. */ export default function useSelect() { - const store = useStore() - const select = useCallback( - (selector: any) => selector(store.getState()), - [store] - ) + const store = useStore() + function select any>(selector: T): ReturnType { + return selector(store.getState()) + } return select } diff --git a/src/app/core/icons/check.tsx b/src/app/core/icons/check.tsx new file mode 100644 index 0000000000..a4e60d3c68 --- /dev/null +++ b/src/app/core/icons/check.tsx @@ -0,0 +1,12 @@ +import React from "react" + +export default function Check(props: any) { + return ( + + + + ) +} diff --git a/src/app/core/icons/chevron-down.tsx b/src/app/core/icons/chevron-down.tsx index 7c42d773ec..53c9422394 100644 --- a/src/app/core/icons/chevron-down.tsx +++ b/src/app/core/icons/chevron-down.tsx @@ -1,15 +1,8 @@ import React from "react" export default function ChevronDown(props: any) { return ( - + diff --git a/src/app/core/icons/detach.tsx b/src/app/core/icons/detach.tsx new file mode 100644 index 0000000000..e03b3c00a3 --- /dev/null +++ b/src/app/core/icons/detach.tsx @@ -0,0 +1,11 @@ +import React from "react" +export default function Detach(props: any) { + return ( + + + + ) +} diff --git a/src/app/core/icons/index.ts b/src/app/core/icons/index.ts index 8c38f7bda9..a75a4f5039 100644 --- a/src/app/core/icons/index.ts +++ b/src/app/core/icons/index.ts @@ -30,9 +30,18 @@ import lock from "./lock" import close from "./close" import plus from "./plus" import zui from "./zui" +import LeftArrow from "./left-arrow" +import RightArrow from "./right-arrow" import sidebarToggle from "./sidebar-toggle" +import check from "./check" +import update from "./update" +import detach from "./detach" +import threeDotsStacked from "./three-dots-stacked" export default { + check, + update, + detach, braces, expand, collapse, @@ -66,4 +75,7 @@ export default { close, plus, zui, + "left-arrow": LeftArrow, + "right-arrow": RightArrow, + "three-dots-stacked": threeDotsStacked, } diff --git a/src/app/core/icons/left-arrow.tsx b/src/app/core/icons/left-arrow.tsx new file mode 100644 index 0000000000..58e9ce4ada --- /dev/null +++ b/src/app/core/icons/left-arrow.tsx @@ -0,0 +1,12 @@ +import React from "react" + +export default function LeftArrow(props: any) { + return ( + + + + ) +} diff --git a/src/app/core/icons/right-arrow.tsx b/src/app/core/icons/right-arrow.tsx new file mode 100644 index 0000000000..5be05bd335 --- /dev/null +++ b/src/app/core/icons/right-arrow.tsx @@ -0,0 +1,12 @@ +import React from "react" + +export default function RightArrow(props: any) { + return ( + + + + ) +} diff --git a/src/app/core/icons/three-dots-stacked.tsx b/src/app/core/icons/three-dots-stacked.tsx new file mode 100644 index 0000000000..0f3af3a6ca --- /dev/null +++ b/src/app/core/icons/three-dots-stacked.tsx @@ -0,0 +1,11 @@ +import React from "react" + +export default function ThreeDots(props: any) { + return ( + + + + + + ) +} diff --git a/src/app/core/icons/three-dots.tsx b/src/app/core/icons/three-dots.tsx index dfdf11b526..07b1979297 100644 --- a/src/app/core/icons/three-dots.tsx +++ b/src/app/core/icons/three-dots.tsx @@ -1,17 +1,10 @@ import React from "react" export default function ThreeDots(props: any) { return ( - - + + + + ) } diff --git a/src/app/core/icons/update.tsx b/src/app/core/icons/update.tsx new file mode 100644 index 0000000000..7b6e0cbc86 --- /dev/null +++ b/src/app/core/icons/update.tsx @@ -0,0 +1,11 @@ +import React from "react" +export default function Update(props: any) { + return ( + + + + ) +} diff --git a/src/app/features/right-pane/detail-section.tsx b/src/app/features/right-pane/detail-section.tsx index 6ac3d82feb..20b2ef49fd 100644 --- a/src/app/features/right-pane/detail-section.tsx +++ b/src/app/features/right-pane/detail-section.tsx @@ -3,7 +3,6 @@ import DetailPane from "src/app/detail/Pane" import {useSelector} from "react-redux" import {openLogDetailsWindow} from "src/js/flows/openLogDetailsWindow" import ExpandWindow from "src/js/icons/ExpandWindow" -import Current from "src/js/state/Current" import LogDetails from "src/js/state/LogDetails" import HistoryButtons from "src/js/components/common/HistoryButtons" import {useDispatch} from "../../core/state" @@ -15,29 +14,26 @@ const DetailSection = () => { const prevExists = useSelector(LogDetails.getHistory).canGoBack() const nextExists = useSelector(LogDetails.getHistory).canGoForward() const currentLog = useSelector(LogDetails.build) - const pool = useSelector(Current.getQueryPool) - if (!pool) return + if (!currentLog) return return ( <> - {currentLog && ( - - - dispatch(LogDetails.back())} - forwardFunc={() => dispatch(LogDetails.forward())} - /> - - - dispatch(openLogDetailsWindow())} - className="panel-button" - /> - - - )} + + + dispatch(LogDetails.back())} + forwardFunc={() => dispatch(LogDetails.forward())} + /> + + + dispatch(openLogDetailsWindow())} + className="panel-button" + /> + + diff --git a/src/app/features/right-pane/history/history-item.tsx b/src/app/features/right-pane/history/history-item.tsx new file mode 100644 index 0000000000..ee53c4af3d --- /dev/null +++ b/src/app/features/right-pane/history/history-item.tsx @@ -0,0 +1,139 @@ +import {formatDistanceToNowStrict} from "date-fns" +import React from "react" +import {useSelector} from "react-redux" +import {useBrimApi} from "src/app/core/context" +import {ActiveQuery} from "src/app/query-home/title-bar/active-query" +import Current from "src/js/state/Current" +import QueryVersions from "src/js/state/QueryVersions" +import styled from "styled-components" +import {Timeline} from "./timeline" +import {useEntryMenu} from "./use-entry-menu" + +const Wrap = styled.div` + height: 28px; + padding: 0 10px; + cursor: default; +` +const BG = styled.div` + user-select: none; + height: 100%; + border-radius: 6px; + display: flex; + align-items: center; + padding-left: 20px; + padding-right: 6px; + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + &:active { + background-color: rgba(0, 0, 0, 0.06); + } + &.deleted { + cursor: not-allowed; + } +` + +const Text = styled.p` + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 12px; + flex: 1; + .anonymous &, + .modified &, + .deleted & { + opacity: 0.65; + font-weight: 400; + } +` + +const Timestamp = styled.p` + font-size: 10px; + opacity: 0.4; +` + +export type EntryType = + | "outdated" + | "latest" + | "anonymous" + | "deleted" + | "modified" + +function getColor(type: EntryType) { + switch (type) { + case "latest": + return "var(--primary-color)" + case "outdated": + return "var(--yellow)" + default: + return "var(--border-color)" + } +} + +type Props = { + version: string + queryId: string + index: number +} + +function getType(active: ActiveQuery): EntryType { + if (active.isDeleted()) return "deleted" + if (active.isLatest()) return "latest" + if (active.isOutdated()) return "outdated" + if (active.isModified()) return "modified" + return "anonymous" +} + +function getValue(active: ActiveQuery) { + if (active.isDeleted()) { + return "(Deleted)" + } else if (active.isAnonymous() || active.isModified()) { + return active.value() || "(Empty)" + } else { + return active.name() + } +} + +function getTimestamp(active: ActiveQuery) { + if (active.isDeleted()) return "-" + const isoString = active.ts() + try { + let text = formatDistanceToNowStrict(new Date(isoString)) + if (/second/.test(text)) return "now" + else return text.replace("second", "sec").replace("minute", "min") + } catch (e) { + console.error(e) + return "" + } +} + +export function HistoryItem({version, queryId, index}: Props) { + const api = useBrimApi() + const onContextMenu = useEntryMenu(index) + const sessionId = useSelector(Current.getSessionId) + const session = useSelector(Current.getQueryById(sessionId)) + const query = useSelector(Current.getQueryById(queryId)) + const sVersion = useSelector(QueryVersions.getByVersion(sessionId, version)) + const qVersion = useSelector(QueryVersions.getByVersion(queryId, version)) + const versionObj = sVersion || qVersion + const active = new ActiveQuery(session, query, versionObj) + const onClick = () => { + if (active.isDeleted()) return + api.queries.open(active.id(), {version: active.versionId(), history: false}) + } + const type = getType(active) + const color = getColor(type) + const value = getValue(active) + const timestamp = getTimestamp(active) + + return ( + + + + {value} + {timestamp} + + + ) +} diff --git a/src/app/features/right-pane/history/section.tsx b/src/app/features/right-pane/history/section.tsx new file mode 100644 index 0000000000..b3c84f4e18 --- /dev/null +++ b/src/app/features/right-pane/history/section.tsx @@ -0,0 +1,28 @@ +import React, {useMemo} from "react" +import styled from "styled-components" +import Current from "src/js/state/Current" +import {useSelector} from "react-redux" +import {HistoryItem} from "./history-item" + +const BG = styled.div` + padding: 6px 0; + overflow-y: auto; +` + +export function HistorySection() { + const sessionHistory = useSelector(Current.getSessionHistory) || [] + const history = useMemo(() => [...sessionHistory].reverse(), [sessionHistory]) + + return ( + + {history.map(({version, queryId}, index) => ( + + ))} + + ) +} diff --git a/src/app/features/right-pane/history/timeline.tsx b/src/app/features/right-pane/history/timeline.tsx new file mode 100644 index 0000000000..7f2b7c8a79 --- /dev/null +++ b/src/app/features/right-pane/history/timeline.tsx @@ -0,0 +1,52 @@ +import React from "react" +import styled from "styled-components" + +const Wrap = styled.div` + width: 6px; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; +` +const Circle = styled.div<{color: string}>` + width: 6px; + height: 6px; + border: 1px solid ${(p) => p.color}; + position: relative; + margin: 3px 0; + border-radius: 50%; + &:after { + background: ${(p) => p.color}; + content: ""; + width: 6px; + height: 6px; + position: absolute; + top: -1px; + left: -1px; + opacity: 0.3; + border-radius: 50%; + } +` + +const Line = styled.div` + width: 2px; + flex: 1; + background: #f6f6f7; + + &:first-child { + border-radius: 0 0 1px 1px; + } + &:last-child { + border-radius: 1px 1px 0 0; + } +` + +export function Timeline(props: {color: string}) { + return ( + + + + + + ) +} diff --git a/src/app/features/right-pane/history/use-entry-menu.ts b/src/app/features/right-pane/history/use-entry-menu.ts new file mode 100644 index 0000000000..f97506b2f9 --- /dev/null +++ b/src/app/features/right-pane/history/use-entry-menu.ts @@ -0,0 +1,24 @@ +import {useDispatch} from "react-redux" +import useSelect from "src/app/core/hooks/use-select" +import {showContextMenu} from "src/js/lib/System" +import Current from "src/js/state/Current" +import SessionHistories from "src/js/state/SessionHistories" + +export function useEntryMenu(index: number) { + const dispatch = useDispatch() + const select = useSelect() + + function onContextMenu() { + const sessionId = select(Current.getSessionId) + showContextMenu([ + { + label: "Remove", + click: () => { + dispatch(SessionHistories.deleteEntry({sessionId, index})) + }, + }, + ]) + } + + return onContextMenu +} diff --git a/src/app/features/right-pane/index.tsx b/src/app/features/right-pane/index.tsx index 4e0cf06b7d..3de3a4654d 100644 --- a/src/app/features/right-pane/index.tsx +++ b/src/app/features/right-pane/index.tsx @@ -8,6 +8,7 @@ import Layout from "../../../js/state/Layout" import {DraggablePane} from "src/js/components/draggable-pane" import VersionsSection from "./versions-section" import AppErrorBoundary from "src/js/components/AppErrorBoundary" +import {HistorySection} from "./history/section" const Pane = styled(DraggablePane)` display: flex; @@ -20,8 +21,8 @@ const BG = styled.div` display: flex; padding: 0 6px; align-items: center; - box-shadow: 0 1px 0px var(--cloudy); - height: 28px; + border-bottom: 1px solid var(--border-color); + height: 31px; flex-shrink: 0; user-select: none; position: relative; @@ -30,10 +31,10 @@ const BG = styled.div` background: none; border: none; display: flex; - border-radius: 5px; padding: 0 6px; text-transform: uppercase; + height: 100%; span { height: 100%; @@ -74,6 +75,8 @@ const PaneContentSwitch = ({paneName}) => { return case "versions": return + case "history": + return default: return null } @@ -85,6 +88,12 @@ export function Menu() { const onClick = (name) => () => dispatch(Layout.setCurrentPaneName(name)) return ( + History + + + + ) +} diff --git a/src/app/query-home/title-bar/heading-menu.tsx b/src/app/query-home/title-bar/heading-menu.tsx new file mode 100644 index 0000000000..33d943c656 --- /dev/null +++ b/src/app/query-home/title-bar/heading-menu.tsx @@ -0,0 +1,49 @@ +import styled from "styled-components" +import {MenuItemConstructorOptions} from "electron" +import React, {useRef} from "react" +import {useBrimApi} from "src/app/core/context" +import {useDispatch} from "src/app/core/state" +import {showContextMenu} from "src/js/lib/System" +import Layout from "src/js/state/Layout" +import popupPosition from "../search-area/popup-position" +import getQueryHeaderMenu from "../toolbar/flows/get-query-header-menu" +import getQueryListMenu from "../toolbar/flows/get-query-list-menu" +import {IconButton} from "./icon-button" +import {useActiveQuery} from "./context" + +const MenuButton = styled(IconButton)` + height: 22px; + width: 22px; +` + +export function HeadingMenu() { + const active = useActiveQuery() + const dispatch = useDispatch() + const api = useBrimApi() + const ref = useRef() + + const onClick = () => { + const savedQueries = dispatch(getQueryListMenu()) + const queryMenu = dispatch( + getQueryHeaderMenu({ + handleRename: () => dispatch(Layout.showTitleForm("update")), + }) + ) + const editOptions = [ + { + label: "Go to Latest Version", + click: () => api.queries.open(active.query.id), + visible: active.isOutdated(), + }, + ] as MenuItemConstructorOptions[] + const menu = [ + ...queryMenu, + ...editOptions, + {type: "separator"}, + {label: "Switch Query", submenu: savedQueries}, + ] as MenuItemConstructorOptions[] + showContextMenu(menu, popupPosition(ref.current)) + } + + return +} diff --git a/src/app/query-home/title-bar/heading-saved.tsx b/src/app/query-home/title-bar/heading-saved.tsx new file mode 100644 index 0000000000..126d9578a0 --- /dev/null +++ b/src/app/query-home/title-bar/heading-saved.tsx @@ -0,0 +1,52 @@ +import classNames from "classnames" +import React from "react" +import {useDispatch} from "src/app/core/state" +import Layout from "src/js/state/Layout" +import styled from "styled-components" +import {useActiveQuery} from "./context" +import {DetatchButton} from "./detatch-button" +import {HeadingButton} from "./heading-button" +import {HeadingMenu} from "./heading-menu" +import {OrangeTag} from "./orange-tag" + +const BG = styled.div` + display: flex; + min-width: 120px; + justify-content: center; + align-items: center; +` + +const Title = styled.h2` + font-size: 14px; + font-weight: 700; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.modified { + font-style: italic; + } +` + +export function HeadingSaved() { + const dispatch = useDispatch() + const active = useActiveQuery() + + function onClick() { + dispatch(Layout.showTitleForm("create")) + } + + return ( + + + + + {active.name()} {active.isModified() && "*"} + + {active.isOutdated() && Outdated} + + + + ) +} diff --git a/src/app/query-home/title-bar/heading.tsx b/src/app/query-home/title-bar/heading.tsx new file mode 100644 index 0000000000..87e3e65199 --- /dev/null +++ b/src/app/query-home/title-bar/heading.tsx @@ -0,0 +1,18 @@ +import React from "react" +import {useSelector} from "react-redux" +import Layout from "src/js/state/Layout" +import {useActiveQuery} from "./context" +import HeadingForm from "./heading-form" +import {HeadingSaved} from "./heading-saved" + +export function Heading() { + const isEditing = useSelector(Layout.getIsEditingTitle) + const active = useActiveQuery() + if (isEditing) { + return + } else if (active.isSaved()) { + return + } else { + return null + } +} diff --git a/src/app/query-home/title-bar/icon-button.tsx b/src/app/query-home/title-bar/icon-button.tsx new file mode 100644 index 0000000000..c87ef77265 --- /dev/null +++ b/src/app/query-home/title-bar/icon-button.tsx @@ -0,0 +1,41 @@ +import React, {ButtonHTMLAttributes} from "react" +import Icon, {IconName} from "src/app/core/icon-temp" +import styled from "styled-components" + +const Button = styled.button` + background: white; + border: none; + height: 22px; + width: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + + &:hover { + background: var(--button-background); + } + &:active { + background: var(--button-background-active); + } + + &:disabled { + opacity: 0.2; + } +` + +type Props = { + icon: IconName + size?: number +} & ButtonHTMLAttributes + +export const IconButton = React.forwardRef( + function IconButton({icon, size, ...rest}: Props, ref) { + return ( + + ) + } +) diff --git a/src/app/query-home/title-bar/nav-actions.tsx b/src/app/query-home/title-bar/nav-actions.tsx new file mode 100644 index 0000000000..d4ee6eaeee --- /dev/null +++ b/src/app/query-home/title-bar/nav-actions.tsx @@ -0,0 +1,50 @@ +import React from "react" +import {useSelector} from "react-redux" +import {useDispatch} from "src/app/core/state" +import TabHistory from "src/app/router/tab-history" +import Current from "src/js/state/Current" +import Layout from "src/js/state/Layout" +import styled from "styled-components" +import {IconButton} from "./icon-button" + +const Actions = styled.div` + display: flex; + gap: 10px; +` + +const Nav = styled.div` + display: flex; + gap: 2px; +` + +export function NavActions() { + const dispatch = useDispatch() + const isEditing = useSelector(Layout.getIsEditingTitle) + const history = useSelector(Current.getHistory) + + if (isEditing) return null + return ( + + + { + dispatch(Layout.showDetailPane()) + dispatch(Layout.setCurrentPaneName("history")) + }} + /> + + ) +} diff --git a/src/app/query-home/title-bar/orange-tag.tsx b/src/app/query-home/title-bar/orange-tag.tsx new file mode 100644 index 0000000000..aa39365946 --- /dev/null +++ b/src/app/query-home/title-bar/orange-tag.tsx @@ -0,0 +1,16 @@ +import styled from "styled-components" + +export const OrangeTag = styled.div` + font-size: 10px; + line-height: 10px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + font-weight: 700; + text-transform: uppercase; + background: #ffe5cb; + color: #8a4500; + border-radius: 3px; +` diff --git a/src/app/query-home/title-bar/plus-one.test.ts b/src/app/query-home/title-bar/plus-one.test.ts new file mode 100644 index 0000000000..6d6df3ffe3 --- /dev/null +++ b/src/app/query-home/title-bar/plus-one.test.ts @@ -0,0 +1,17 @@ +import {plusOne} from "./plus-one" + +test("plus one", () => { + expect(plusOne("James")).toBe("James 2") +}) + +test("James 2", () => { + expect(plusOne("James 2")).toBe("James 3") +}) + +test("James 2.2", () => { + expect(plusOne("James 2.2")).toBe("James 2.3") +}) + +test("empty", () => { + expect(plusOne("")).toBe("") +}) diff --git a/src/app/query-home/title-bar/plus-one.ts b/src/app/query-home/title-bar/plus-one.ts new file mode 100644 index 0000000000..5e2cce20c7 --- /dev/null +++ b/src/app/query-home/title-bar/plus-one.ts @@ -0,0 +1,14 @@ +export function plusOne(string: string) { + if (string.trim() == "") return "" + + const lastDigit = /(\d+)\s*$/ + const match = lastDigit.exec(string) + + if (!match) { + return string + " 2" + } else { + const digits = match[1] + const int = parseInt(digits) + 1 + return string.slice(0, match.index) + int.toString() + } +} diff --git a/src/app/query-home/title-bar/query-actions.tsx b/src/app/query-home/title-bar/query-actions.tsx new file mode 100644 index 0000000000..3360c4f0f9 --- /dev/null +++ b/src/app/query-home/title-bar/query-actions.tsx @@ -0,0 +1,61 @@ +import React from "react" +import {useSelector} from "react-redux" +import {useBrimApi} from "src/app/core/context" +import useSelect from "src/app/core/hooks/use-select" +import {useDispatch} from "src/app/core/state" +import Editor from "src/js/state/Editor" +import Layout from "src/js/state/Layout" +import styled from "styled-components" +import {Button} from "./button" +import {useActiveQuery} from "./context" + +const Actions = styled.div` + display: flex; + gap: 10px; +` + +export function QueryActions() { + const active = useActiveQuery() + const isEditing = useSelector(Layout.getIsEditingTitle) + if (isEditing) return null + return ( + + {active.isModified() && } + + + ) +} + +function Create() { + const dispatch = useDispatch() + const active = useActiveQuery() + const text = active.isAnonymous() ? "Save" : "Save As" + const isEmpty = useSelector(Editor.isEmpty) + function onClick() { + dispatch(Layout.showTitleForm("create")) + } + return ( + + ) +} + +function Update() { + const active = useActiveQuery() + const api = useBrimApi() + const select = useSelect() + + function onClick() { + const snapshot = select(Editor.getSnapshot) + const id = active.query.id + api.queries.addVersion(id, snapshot) + api.queries.open(id, {history: "replace"}) + } + + return ( + + ) +} diff --git a/src/app/query-home/title-bar/title-bar.tsx b/src/app/query-home/title-bar/title-bar.tsx new file mode 100644 index 0000000000..aa6a9df51a --- /dev/null +++ b/src/app/query-home/title-bar/title-bar.tsx @@ -0,0 +1,39 @@ +import React from "react" +import styled from "styled-components" +import {useSelector} from "react-redux" +import Current from "src/js/state/Current" +import {NavActions} from "./nav-actions" +import {Heading} from "./heading" +import {ActiveQuery} from "./active-query" +import {useTabId} from "src/app/core/hooks/use-tab-id" +import {QueryActions} from "./query-actions" +import {TitleBarProvider} from "./context" + +const BG = styled.header.attrs({className: "title-bar"})` + flex-shrink: 0; + height: 31px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + gap: 10px; +` + +export function TitleBar() { + const tabId = useTabId() + const query = useSelector(Current.getQuery) + const session = useSelector(Current.getQueryById(tabId)) + const version = useSelector(Current.getVersion) + const active = new ActiveQuery(session, query, version) + + return ( + + + + + + + + ) +} diff --git a/src/app/query-home/title-bar/use-heading-form.ts b/src/app/query-home/title-bar/use-heading-form.ts new file mode 100644 index 0000000000..0acb27f566 --- /dev/null +++ b/src/app/query-home/title-bar/use-heading-form.ts @@ -0,0 +1,63 @@ +import {FormEvent} from "react" +import {useSelector} from "react-redux" +import {useBrimApi} from "src/app/core/context" +import {useDispatch} from "src/app/core/state" +import Layout from "src/js/state/Layout" +import {useActiveQuery} from "./context" +import {plusOne} from "./plus-one" + +export function useHeadingForm() { + const active = useActiveQuery() + const api = useBrimApi() + const dispatch = useDispatch() + const action = useSelector(Layout.getTitleFormAction) + + function createNewQuery(name: string) { + const q = api.queries.create(name) + api.queries.open(q.id) + } + + function renameQuery(name: string) { + api.queries.rename(active.query.id, name) + } + + function getInput(e: FormEvent) { + return e.currentTarget.elements.namedItem("query-name") as HTMLInputElement + } + + function getDefaultValue() { + if (action === "create" && active.name()) { + return plusOne(active.name()) + } else { + return active.name() + } + } + + function getButtonText() { + return action === "create" ? "Create" : "Update" + } + + return { + onSubmit: (e: FormEvent) => { + e.preventDefault() + const input = getInput(e) + if (!input) { + dispatch(Layout.hideTitleForm()) + return + } + if (action === "update" && input.value === getDefaultValue()) { + dispatch(Layout.hideTitleForm()) + return + } + if (active.isAnonymous() || action === "create") { + createNewQuery(input.value) + } else { + renameQuery(input.value) + } + dispatch(Layout.hideTitleForm()) + }, + onReset: () => dispatch(Layout.hideTitleForm()), + defaultValue: getDefaultValue(), + buttonText: getButtonText(), + } +} diff --git a/src/app/query-home/toolbar/action-button.tsx b/src/app/query-home/toolbar/actions/action-button.tsx similarity index 100% rename from src/app/query-home/toolbar/action-button.tsx rename to src/app/query-home/toolbar/actions/action-button.tsx diff --git a/src/app/query-home/toolbar/action-buttons.tsx b/src/app/query-home/toolbar/actions/action-buttons.tsx similarity index 97% rename from src/app/query-home/toolbar/action-buttons.tsx rename to src/app/query-home/toolbar/actions/action-buttons.tsx index 2e294602be..2de289e64e 100644 --- a/src/app/query-home/toolbar/action-buttons.tsx +++ b/src/app/query-home/toolbar/actions/action-buttons.tsx @@ -10,7 +10,6 @@ const BG = styled.div<{size: number}>` overflow: hidden; justify-content: flex-end; width: ${(p) => p.size}px; - padding-right: 8px; & > * { margin-right: ${GUTTER}px; diff --git a/src/app/query-home/toolbar/action-menu.tsx b/src/app/query-home/toolbar/actions/action-menu.tsx similarity index 100% rename from src/app/query-home/toolbar/action-menu.tsx rename to src/app/query-home/toolbar/actions/action-menu.tsx diff --git a/src/app/query-home/toolbar/actions.tsx b/src/app/query-home/toolbar/actions/actions.tsx similarity index 92% rename from src/app/query-home/toolbar/actions.tsx rename to src/app/query-home/toolbar/actions/actions.tsx index f2dbf92d60..a9490e0397 100644 --- a/src/app/query-home/toolbar/actions.tsx +++ b/src/app/query-home/toolbar/actions/actions.tsx @@ -2,7 +2,7 @@ import MeasureLayer from "src/app/core/MeasureLayer" import React from "react" import ActionButtons from "./action-buttons" import ActionMenu from "./action-menu" -import useVisibleActions from "./hooks/use-visible-actions" +import useVisibleActions from "../hooks/use-visible-actions" /** * We must measure how big this component will be if all the actions are visible diff --git a/src/app/query-home/toolbar/button.tsx b/src/app/query-home/toolbar/actions/button.tsx similarity index 100% rename from src/app/query-home/toolbar/button.tsx rename to src/app/query-home/toolbar/actions/button.tsx diff --git a/src/app/query-home/toolbar/label.ts b/src/app/query-home/toolbar/actions/label.ts similarity index 100% rename from src/app/query-home/toolbar/label.ts rename to src/app/query-home/toolbar/actions/label.ts diff --git a/src/app/query-home/toolbar/flows/get-query-header-menu.ts b/src/app/query-home/toolbar/flows/get-query-header-menu.ts index 81ee254edc..87fc2add97 100644 --- a/src/app/query-home/toolbar/flows/get-query-header-menu.ts +++ b/src/app/query-home/toolbar/flows/get-query-header-menu.ts @@ -11,6 +11,7 @@ import Current from "src/js/state/Current" import {Query} from "src/js/state/Queries/types" import {getQuerySource} from "src/js/state/Queries/flows/get-query-source" import QueryVersions from "src/js/state/QueryVersions" +import {last} from "lodash" const getQueryHeaderMenu = ({handleRename}: {handleRename: () => void}) => @@ -94,12 +95,20 @@ const getQueryHeaderMenu = if (querySource === "local") { dispatch(Queries.addItem(q, "root")) dispatch(QueryVersions.set({queryId: q.id, versions: versionsCopy})) - dispatch(Tabs.create(lakeQueryPath(q.id, lakeId))) + dispatch( + Tabs.create( + lakeQueryPath(q.id, lakeId, last(versionsCopy).version) + ) + ) } if (querySource === "remote") { const queriesCopy = versionsCopy.map((v) => ({...q, ...v})) dispatch(setRemoteQueries(queriesCopy)).then(() => { - dispatch(Tabs.create(lakeQueryPath(q.id, lakeId))) + dispatch( + Tabs.create( + lakeQueryPath(q.id, lakeId, last(queriesCopy).version) + ) + ) }) } }, diff --git a/src/app/query-home/toolbar/flows/get-query-list-menu.ts b/src/app/query-home/toolbar/flows/get-query-list-menu.ts new file mode 100644 index 0000000000..bad9e553f3 --- /dev/null +++ b/src/app/query-home/toolbar/flows/get-query-list-menu.ts @@ -0,0 +1,29 @@ +import {MenuItemConstructorOptions} from "electron" +import Queries from "src/js/state/Queries" + +const getQueryListMenu = + () => + (dispatch, getState, {api}) => { + const state = getState() + const queries = Queries.raw(state) + + function createMenuItems(items) { + return items.map((query) => { + if ("items" in query) { + return { + label: query.name, + submenu: createMenuItems(query.items), + } as MenuItemConstructorOptions + } else { + return { + label: query.name, + click: () => api.queries.open(query.id), + } + } + }) + } + + return createMenuItems(queries.items) + } + +export default getQueryListMenu diff --git a/src/app/query-home/toolbar/hooks/use-columns.ts b/src/app/query-home/toolbar/hooks/use-columns.ts index 9e1311e554..943f38cd88 100644 --- a/src/app/query-home/toolbar/hooks/use-columns.ts +++ b/src/app/query-home/toolbar/hooks/use-columns.ts @@ -1,6 +1,6 @@ import {useDispatch} from "react-redux" import Modal from "src/js/state/Modal" -import {ActionButtonProps} from "../action-button" +import {ActionButtonProps} from "../actions/action-button" export default function useColumns(): ActionButtonProps { const dispatch = useDispatch() diff --git a/src/app/query-home/toolbar/hooks/use-export.ts b/src/app/query-home/toolbar/hooks/use-export.ts index 52317ff954..3b47470089 100644 --- a/src/app/query-home/toolbar/hooks/use-export.ts +++ b/src/app/query-home/toolbar/hooks/use-export.ts @@ -1,6 +1,6 @@ import {useDispatch} from "react-redux" import Modal from "src/js/state/Modal" -import {ActionButtonProps} from "../action-button" +import {ActionButtonProps} from "../actions/action-button" export default function useExport(): ActionButtonProps { const dispatch = useDispatch() diff --git a/src/app/query-home/toolbar/hooks/use-inspector.ts b/src/app/query-home/toolbar/hooks/use-inspector.ts new file mode 100644 index 0000000000..c2f0ca9cbb --- /dev/null +++ b/src/app/query-home/toolbar/hooks/use-inspector.ts @@ -0,0 +1,27 @@ +import {useSelector} from "react-redux" +import Layout from "src/js/state/Layout" +import {useExpandState} from "../../results/expand-hook" +import {ActionButtonProps} from "../actions/action-button" + +export const useInspectorButtons = (): ActionButtonProps[] => { + const {expandAll, collapseAll} = useExpandState() + const view = useSelector(Layout.getResultsView) + + const disabled = view !== "INSPECTOR" + return [ + { + label: "Expand", + title: "Expand all inspector view entries", + icon: "expand", + disabled, + click: () => expandAll(), + }, + { + label: "Collapse", + title: "Collapse all inspector view entries", + icon: "collapse", + disabled, + click: () => collapseAll(), + }, + ] +} diff --git a/src/app/query-home/toolbar/hooks/use-pins.tsx b/src/app/query-home/toolbar/hooks/use-pins.tsx index 222c202716..1f6aa0b571 100644 --- a/src/app/query-home/toolbar/hooks/use-pins.tsx +++ b/src/app/query-home/toolbar/hooks/use-pins.tsx @@ -3,7 +3,7 @@ import {showContextMenu} from "src/js/lib/System" import Editor from "src/js/state/Editor" import submitSearch from "../../flows/submit-search" import popupPosition from "../../search-area/popup-position" -import {ActionButtonProps} from "../action-button" +import {ActionButtonProps} from "../actions/action-button" const showPinsMenu = (anchor) => (dispatch, getState) => { const pins = Editor.getPins(getState()) diff --git a/src/app/query-home/toolbar/hooks/use-plugin-toolbar-items.ts b/src/app/query-home/toolbar/hooks/use-plugin-toolbar-items.ts index da21b95a29..48e124a007 100644 --- a/src/app/query-home/toolbar/hooks/use-plugin-toolbar-items.ts +++ b/src/app/query-home/toolbar/hooks/use-plugin-toolbar-items.ts @@ -2,7 +2,7 @@ import {useSelector} from "react-redux" import {useDispatch} from "src/app/core/state" import Toolbars from "src/js/state/Toolbars" import {IconName} from "src/app/core/icon-temp" -import {ActionButtonProps} from "../action-button" +import {ActionButtonProps} from "../actions/action-button" import {executeCommand} from "src/js/flows/executeCommand" const usePluginToolbarItems = (toolbarId: string): ActionButtonProps[] => { diff --git a/src/app/query-home/toolbar/hooks/use-run.ts b/src/app/query-home/toolbar/hooks/use-run.ts index dace9a4897..9fa1b61017 100644 --- a/src/app/query-home/toolbar/hooks/use-run.ts +++ b/src/app/query-home/toolbar/hooks/use-run.ts @@ -1,6 +1,6 @@ import submitSearch from "src/app/query-home/flows/submit-search" import {useDispatch} from "src/app/core/state" -import {ActionButtonProps} from "../action-button" +import {ActionButtonProps} from "../actions/action-button" export default function useRun(): ActionButtonProps { const dispatch = useDispatch() diff --git a/src/app/query-home/toolbar/hooks/use-view.tsx b/src/app/query-home/toolbar/hooks/use-view.tsx index 1b627bdf98..05a1dc7f30 100644 --- a/src/app/query-home/toolbar/hooks/use-view.tsx +++ b/src/app/query-home/toolbar/hooks/use-view.tsx @@ -3,7 +3,7 @@ import {useDispatch, useSelector} from "react-redux" import {showContextMenu} from "src/js/lib/System" import Appearance from "src/js/state/Appearance" import Layout from "src/js/state/Layout" -import {ActionButtonProps} from "../action-button" +import {ActionButtonProps} from "../actions/action-button" export default function useView(): ActionButtonProps { const dispatch = useDispatch() diff --git a/src/app/query-home/toolbar/hooks/use-visible-actions.ts b/src/app/query-home/toolbar/hooks/use-visible-actions.ts index b9cb71165d..6c02b3dc27 100644 --- a/src/app/query-home/toolbar/hooks/use-visible-actions.ts +++ b/src/app/query-home/toolbar/hooks/use-visible-actions.ts @@ -1,7 +1,7 @@ import useContentRect from "src/app/core/hooks/useContentRect" import {useLayoutEffect, useState} from "react" import useCallbackRef from "src/js/components/hooks/useCallbackRef" -import {GUTTER} from "../action-buttons" +import {GUTTER} from "../actions/action-buttons" const useVisibleActions = (actions) => { const [visible, setVisible] = useState([]) diff --git a/src/app/query-home/toolbar/index.tsx b/src/app/query-home/toolbar/index.tsx deleted file mode 100644 index e50f0c960d..0000000000 --- a/src/app/query-home/toolbar/index.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react" -import styled from "styled-components" -import {ActionButtonProps} from "./action-button" -import {GUTTER} from "./action-buttons" -import Actions from "./actions" -import QueryHeader from "./query-header" - -const Wrap = styled.div` - padding: 18px 24px 12px 24px; - box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.11); -` - -const Row = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -` - -const Left = styled.div` - min-width: 200px; - flex: 1 1 0; - margin-right: 12px; - align-items: center; -` - -const Right = styled.div` - overflow: hidden; - padding-top: 2px; // for the outline state to not get clipped - display: flex; - flex: 0 1 auto; - width: min-content; - - & > * { - margin-right: ${GUTTER}px; - &:last-child { - margin-right: 0; - } - } -` - -type Props = { - actions: ActionButtonProps[] -} - -const Toolbar = ({actions}: Props) => { - return ( - - - - - - - - - - - ) -} - -export default Toolbar diff --git a/src/app/query-home/toolbar/query-header.tsx b/src/app/query-home/toolbar/query-header.tsx deleted file mode 100644 index a2c30172b2..0000000000 --- a/src/app/query-home/toolbar/query-header.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import React, {useEffect, useLayoutEffect, useRef, useState} from "react" -import {useDispatch, useSelector} from "react-redux" -import styled from "styled-components" -import Current from "src/js/state/Current" -import {getQuerySource} from "../../../js/state/Queries/flows/get-query-source" -import {updateQuery} from "../../../js/state/Queries/flows/update-query" -import useEnterKey from "../../../js/components/hooks/useEnterKey" -import {AppDispatch} from "../../../js/state/types" -import DraftQueries from "src/js/state/DraftQueries" -import Queries from "src/js/state/Queries" -import useEscapeKey from "../../../js/components/hooks/useEscapeKey" -import {lakeQueryPath} from "../../router/utils/paths" -import tabHistory from "../../router/tab-history" -import Icon from "../../core/icon-temp" -import SearchBar from "src/js/state/SearchBar" -import AutosizeInput from "react-input-autosize" -import {cssVar} from "../../../js/lib/cssVar" -import BrimTooltip from "src/js/components/BrimTooltip" -import {showContextMenu} from "../../../js/lib/System" -import getQueryHeaderMenu from "./flows/get-query-header-menu" - -const Row = styled.div` - display: flex; - align-items: center; -` - -const TitleHeader = styled(Row)` - display: flex; - flex-direction: column; - align-items: flex-start; - margin-bottom: 4px; - width: 100%; -` - -const StyledTitle = styled.h2<{hover: boolean}>` - ${(props) => props.theme.typography.labelBold} - margin: 0 0 3px 0; - max-width: 100%; - font-weight: 700; - font-size: 20px; - line-height: 24px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - padding: 2px 6px; - margin-left: -6px; - margin-top: -2px; - margin-bottom: 1px; - - ${(p) => - p.hover && - ` - &:hover { - border-radius: 3px; - background-color: var(--hover-light-bg); - } - `} -` -const SubTitle = styled.div<{isEnabled: boolean}>` - ${(props) => props.theme.typography.labelNormal} - display: flex; - padding: 1px 6px; - margin-left: -6px; - margin-top: -1px; - color: rgba(0, 0, 0, 0.5); - text-transform: capitalize; - ${({isEnabled}) => - isEnabled && - ` - &:hover { - border-radius: 3px; - background-color: var(--hover-light-bg); - } - `} -` - -const StyledAnchor = styled.a<{isPrimary?: boolean; isIndented?: boolean}>` - ${(props) => props.theme.typography.labelNormal} - text-decoration: underline; - color: ${(p) => (p.isPrimary ? "var(--havelock)" : "var(--slate)")}; - margin-left: ${(p) => (p.isIndented ? "10px" : "0")}; -` - -const StyledTitleWrapper = styled.div` - display: flex; - justify-content: flex-start; - width: 100%; -` - -const IconChevronDown = styled(Icon).attrs({name: "chevron-down"})` - width: 12px; - height: 12px; -` - -const StyledStatus = styled.span` - display: flex; - align-items: center; - svg { - width: 9px; - height: 9px; - opacity: 0.5; - margin-right: 3px; - } -` - -const TitleInput = ({onCancel, onSubmit}) => { - const inputRef = useRef(null) - const query = useSelector(Current.getQuery) - const [queryTitle, setQueryTitle] = useState(query?.name) - - const handleEdit = () => { - if (!queryTitle) onCancel() - else onSubmit(queryTitle) - } - - useEnterKey(() => { - handleEdit() - }) - useEscapeKey(() => { - onCancel() - }) - useEffect(() => { - setQueryTitle(query?.name) - }, [query]) - useLayoutEffect(() => { - inputRef.current && inputRef.current.select() - }, []) - - return ( - - setQueryTitle(e.target.value)} - value={queryTitle || ""} - style={{ - overflow: "hidden", - background: cssVar("--input-background"), - minWidth: "120px", - borderRadius: "3px", - padding: "2px 6px", - margin: "-2px 0 1px -6px", - }} - inputStyle={{ - background: "transparent", - margin: 0, - padding: 0, - fontWeight: 700, - fontSize: "20px", - letterSpacing: 0, - lineHeight: "24px", - display: "block", - outline: "none", - border: "none", - }} - /> - - Save - - - Cancel - - - ) -} - -const QueryHeader = () => { - const dispatch = useDispatch() - const query = useSelector(Current.getQuery) - const lakeId = useSelector(Current.getLakeId) - const searchBar = useSelector(SearchBar.getSearchBar) - const querySource = dispatch(getQuerySource(query?.id)) - const [isEditing, setIsEditing] = useState(false) - const [isModified, setIsModified] = useState(false) - - useEffect(() => setIsEditing(false), [query?.id]) - useEffect(() => { - if (query?.value !== searchBar.current) setIsModified(true) - else setIsModified(false) - }, [query, searchBar]) - - const onSubmit = (newTitle) => { - setIsEditing(false) - const newQuery = {...query.serialize(), name: newTitle} - if (newTitle !== "") { - if (querySource === "draft") { - dispatch(Queries.addItem(newQuery, "root")) - dispatch(DraftQueries.remove({id: query.id})) - dispatch(tabHistory.replace(lakeQueryPath(query.id, lakeId))) - } else { - dispatch(updateQuery(query, newQuery)) - } - } - } - - const renderQueryStatus = () => { - if (querySource === "draft" && !isEditing) - return ( - setIsEditing(true)}> - Save - - ) - let status = "Saved" - if (isModified) status = "Modified" - if (isEditing) status = "Renaming" - if (query.isReadOnly) status = "Readonly" - - return ( - <> - - {query.isReadOnly && } - {status} - - {!isEditing && } - - ) - } - - const openMenu = () => { - if (isEditing || querySource === "draft") return - showContextMenu( - dispatch(getQueryHeaderMenu({handleRename: () => setIsEditing(true)})) - ) - } - - return ( - - {isEditing ? ( - setIsEditing(false)} /> - ) : ( - <> - !query.isReadOnly && setIsEditing(true)} - hover={!query.isReadOnly} - > - {query?.name} - - - {query?.name} - - - )} - - {querySource} — {renderQueryStatus()} - - - ) -} - -export default QueryHeader diff --git a/src/app/query-home/toolbar/results-actions.tsx b/src/app/query-home/toolbar/results-actions.tsx new file mode 100644 index 0000000000..1bff6ff2e5 --- /dev/null +++ b/src/app/query-home/toolbar/results-actions.tsx @@ -0,0 +1,25 @@ +import React from "react" +import Actions from "./actions/actions" +import useColumns from "./hooks/use-columns" +import useExport from "./hooks/use-export" +import {useInspectorButtons} from "./hooks/use-inspector" +import usePins from "./hooks/use-pins" +import usePluginToolbarItems from "./hooks/use-plugin-toolbar-items" + +export function ResultsActions() { + const exportAction = useExport() + const columns = useColumns() + const pin = usePins() + const pluginButtons = usePluginToolbarItems("search") + const [expandButton, collapseButton] = useInspectorButtons() + const actions = [ + ...pluginButtons, + expandButton, + collapseButton, + exportAction, + columns, + pin, + ] + + return +} diff --git a/src/app/query-home/toolbar/results-toolbar.tsx b/src/app/query-home/toolbar/results-toolbar.tsx new file mode 100644 index 0000000000..a159802b09 --- /dev/null +++ b/src/app/query-home/toolbar/results-toolbar.tsx @@ -0,0 +1,20 @@ +import React from "react" +import styled from "styled-components" +import {ResultsActions} from "./results-actions" +import {ResultsViewSwitch} from "./results-view-switch" + +const BG = styled.section` + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 6px 20px; +` + +export function ResultsToolbar() { + return ( + + + + + ) +} diff --git a/src/app/query-home/toolbar/results-view-switch.tsx b/src/app/query-home/toolbar/results-view-switch.tsx new file mode 100644 index 0000000000..2bddae94fa --- /dev/null +++ b/src/app/query-home/toolbar/results-view-switch.tsx @@ -0,0 +1,27 @@ +import React from "react" +import {SwitchButton} from "src/app/core/components/switch-button" +import styled from "styled-components" +import {useResultsView} from "../results/view-hook" + +const BG = styled.div` + margin-top: 8px; +` + +export function ResultsViewSwitch() { + const view = useResultsView() + return ( + + + + ) +} diff --git a/src/app/query-home/toolbar/switch-button-option.tsx b/src/app/query-home/toolbar/switch-button-option.tsx deleted file mode 100644 index 4202a6cc5c..0000000000 --- a/src/app/query-home/toolbar/switch-button-option.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import styled from "styled-components" - -const Option = styled.button` - border: transparent; - background: none; - height: 100%; - white-space: nowrap; - padding: 0 12px; - min-width: min-content; - border-radius: 4px; - transition: background 100ms; - - &:active { - transition: none; - background: rgba(0, 0, 0, 0.05); - } -` - -export default Option diff --git a/src/app/query-home/toolbar/switch-button.tsx b/src/app/query-home/toolbar/switch-button.tsx deleted file mode 100644 index a471eb5b05..0000000000 --- a/src/app/query-home/toolbar/switch-button.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import useLayoutUpdate from "src/app/core/hooks/useLayoutUpdate" -import React, {useLayoutEffect, useRef, useState} from "react" -import styled from "styled-components" - -const PADDING = 2 - -type SpotlightProps = { - width: number - x: number - height: number - animate: boolean -} - -const Spotlight = styled.div` - z-index: -1; - position: absolute; - background: white; - width: ${(p) => p.width}px; - height: ${(p) => p.height}px; - transform: translateX(${(p) => p.x}px); - transition: ${(p) => (p.animate ? "all 300ms" : "none")}; - border-radius: 4px; -` - -function useSpotlight(ref): [SpotlightProps, (i: number, a: boolean) => void] { - const init = {x: 0, width: 0, height: 0, animate: false} - const [props, setProps] = useState(init) - - const moveTo = (index: number, animate: boolean) => { - const node = ref.current - if (node) { - const {x: parentX} = node.getBoundingClientRect() - const child = node.querySelectorAll("button")[index] - if (child) { - const {x: childX, width, height} = child.getBoundingClientRect() - const x = childX - parentX - PADDING - setProps({x, width, height, animate}) - } - } - } - - return [props, moveTo] -} - -const BG = styled.div` - display: inline-flex; - background: rgba(0, 0, 0, 0.03); - box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.25); - border-radius: 4px; - height: 22px; - position: relative; - padding: ${PADDING}px; -` - -type Props = { - children: JSX.Element[] - value: string - onChange -} - -export default function SwitchButton({value, onChange, children}: Props) { - const ref = useRef(null) - const index = children.findIndex((c) => c.props.value === value) - const [props, moveTo] = useSpotlight(ref) - - useLayoutEffect(() => moveTo(index, false), []) - useLayoutUpdate(() => moveTo(index, true), [value, children]) - - return ( - - - {React.Children.map(children, (child) => { - return React.cloneElement(child, { - onClick: () => { - child.props.value !== value && onChange(child.props.value) - }, - }) - })} - - ) -} diff --git a/src/app/query-home/utils/brim-query.ts b/src/app/query-home/utils/brim-query.ts index b27893f251..c1d01b3df5 100644 --- a/src/app/query-home/utils/brim-query.ts +++ b/src/app/query-home/utils/brim-query.ts @@ -1,13 +1,11 @@ import {Query} from "src/js/state/Queries/types" -import {isEmpty, isNumber, last} from "lodash" +import {isEmpty, last} from "lodash" import {QueryPin, QueryPinInterface} from "../../../js/state/Editor/types" import {nanoid} from "@reduxjs/toolkit" import {parseAst} from "@brimdata/zealot" import buildPin from "src/js/state/Editor/models/build-pin" import {QueryVersion} from "src/js/state/QueryVersions/types" import brim from "src/js/brim" -export type PinType = "from" | "filter" -export const DRAFT_QUERY_NAME = "Draft Query" export class BrimQuery implements Query { id: string @@ -17,7 +15,6 @@ export class BrimQuery implements Query { isReadOnly?: boolean current: QueryVersion versions: QueryVersion[] - head?: number constructor(raw: Query, versions: QueryVersion[], current?: string) { this.id = raw.id @@ -39,6 +36,10 @@ export class BrimQuery implements Query { return this.current?.pins ?? [] } + hasVersion(version: string): boolean { + return !!this.versions?.map((v) => v.version).includes(version) + } + newVersion(value?: string, pins?: QueryPin[]) { const newV: QueryVersion = { value: value ?? "", @@ -63,6 +64,10 @@ export class BrimQuery implements Query { return last(this.versions) } + latestVersionId(): string { + return this.latestVersion().version + } + getPoolName() { return this.getFromPin() } @@ -78,13 +83,7 @@ export class BrimQuery implements Query { } checkSyntax() { - let error = null - try { - parseAst(this.toString()) - } catch (e) { - error = e - } - return error + return BrimQuery.checkSyntax(this.current) } serialize(): Query { @@ -97,22 +96,36 @@ export class BrimQuery implements Query { } } - toString(): string { + static checkSyntax(version: QueryVersion) { + const zed = this.versionToZed(version) + let error = null + try { + parseAst(zed) + } catch (e) { + error = e + } + return error + } + + static versionToZed(version: QueryVersion): string { let pinS = [] - if (!isEmpty(this.current?.pins)) - pinS = this.current.pins + if (!isEmpty(version?.pins)) + pinS = version.pins .filter((p) => !p.disabled) .map(buildPin) .map((p) => p.toZed()) let s = pinS - .concat(this.current?.value ?? "") + .concat(version?.value ?? "") .filter((s) => s.trim() !== "") .join(" | ") .trim() if (isEmpty(s)) s = "*" - if (isNumber(this.head)) s += ` | head ${this.head}` return s } + + toString(): string { + return BrimQuery.versionToZed(this.current) + } } diff --git a/src/app/router/routes.ts b/src/app/router/routes.ts index caa3a1007c..5961842123 100644 --- a/src/app/router/routes.ts +++ b/src/app/router/routes.ts @@ -38,7 +38,7 @@ export const query = { } export const queryVersion = { title: "", - path: `${lakeShow.path}/queries/:queryId/versions/:version`, + path: `${query.path}/versions/:version`, icon: "query", } export const lakeReleaseNotes: Route = { diff --git a/src/app/router/tab-history.ts b/src/app/router/tab-history.ts index b5f5b04cd3..b4d2f82a67 100644 --- a/src/app/router/tab-history.ts +++ b/src/app/router/tab-history.ts @@ -23,4 +23,9 @@ export default { goForward: () => (dispatch, getState) => { Current.getHistory(getState()).goForward() }, + + reload: () => (dispatch, getState) => { + const history = Current.getHistory(getState()) + history.replace(history.location.pathname) + }, } diff --git a/src/app/router/utils/paths.ts b/src/app/router/utils/paths.ts index 13aaa177d8..b5d6976ece 100644 --- a/src/app/router/utils/paths.ts +++ b/src/app/router/utils/paths.ts @@ -17,10 +17,9 @@ export const lakePoolPath = (poolId: string, lakeId: string) => { export function lakeQueryPath( queryId: string, lakeId: string, - version?: string + version: string ) { - const path = `${lakePath(lakeId)}/queries/${queryId}` - return version ? `${path}/versions/${version}` : path + return `${lakePath(lakeId)}/queries/${queryId}/versions/${version}` } export function releaseNotesPath(lakeId) { diff --git a/src/app/routes/app-wrapper/main-area.tsx b/src/app/routes/app-wrapper/main-area.tsx index f3ed66c3ac..816688076a 100644 --- a/src/app/routes/app-wrapper/main-area.tsx +++ b/src/app/routes/app-wrapper/main-area.tsx @@ -5,6 +5,7 @@ import styled from "styled-components" const BG = styled.main` min-height: 0; + min-width: 0; height: 100%; display: flex; flex-direction: column; diff --git a/src/css/_tab.scss b/src/css/_tab.scss index e51e8a82dd..d3186a759e 100644 --- a/src/css/_tab.scss +++ b/src/css/_tab.scss @@ -38,7 +38,7 @@ $tab-transition-duration: 200ms; .title { font-family: system-ui; font-size: 14px; - line-height: 1; + line-height: 16px; margin: 0; font-weight: 400; color: $tab-inactive-color; diff --git a/src/css/settings/_colors.scss b/src/css/settings/_colors.scss index b386559069..650a02d588 100644 --- a/src/css/settings/_colors.scss +++ b/src/css/settings/_colors.scss @@ -4,7 +4,7 @@ --green: #399c81; --green-bright: #4ef567; --orange: #ea6842; - --yellow: #ffdd66; + --yellow: #ffc933; --azure: #2f629c; --cello: hsl(212, 100%, 14%); --cello-transparent: hsla(212, 100%, 14%, 0.92); diff --git a/src/js/api/index.ts b/src/js/api/index.ts index 8b3a3648d3..9064b9ab6d 100644 --- a/src/js/api/index.ts +++ b/src/js/api/index.ts @@ -37,7 +37,7 @@ export default class BrimApi { this.dispatch = d this.toolbar = new ToolbarsApi(d, gs) this.configs = new ConfigurationsApi(d, gs) - this.queries = new QueriesApi(d) + this.queries = new QueriesApi(d, gs) this.pools = new PoolsApi(d) this.current = new CurrentApi(gs) this.correlations = new CorrelationsApi(d) diff --git a/src/js/api/queries/queries-api.ts b/src/js/api/queries/queries-api.ts index acee9a8bda..ad0e998825 100644 --- a/src/js/api/queries/queries-api.ts +++ b/src/js/api/queries/queries-api.ts @@ -1,10 +1,22 @@ +import {nanoid} from "@reduxjs/toolkit" +import tabHistory from "src/app/router/tab-history" +import {lakeQueryPath} from "src/app/router/utils/paths" +import Current from "src/js/state/Current" +import Editor from "src/js/state/Editor" +import Queries from "src/js/state/Queries" +import {updateQuery} from "src/js/state/Queries/flows/update-query" +import QueryVersions from "src/js/state/QueryVersions" +import {QueryVersion} from "src/js/state/QueryVersions/types" +import SessionHistories from "src/js/state/SessionHistories" +import Tabs from "src/js/state/Tabs" import {JSONGroup} from "../../state/Queries/parsers" -import {AppDispatch} from "../../state/types" +import {AppDispatch, GetState} from "../../state/types" import {queriesExport} from "./export" import {queriesImport} from "./import" +import {OpenQueryOptions, QueryParams} from "./types" export class QueriesApi { - constructor(private dispatch: AppDispatch) {} + constructor(private dispatch: AppDispatch, private getState: GetState) {} import(file: File) { return this.dispatch(queriesImport(file)) @@ -13,4 +25,92 @@ export class QueriesApi { export(groupId: string): JSONGroup { return this.dispatch(queriesExport(groupId)) } + + create(name: string) { + const attrs = Editor.getSnapshot(this.getState()) + return this.dispatch(Queries.create({name, ...attrs})) + } + + rename(id: string, name: string) { + const query = Current.getQueryById(id)(this.getState()) + if (query) { + this.dispatch(updateQuery(query, {name})) + } else { + console.error("Could not find query with id: " + id) + } + } + + addVersion(queryId: string, params: QueryVersion | QueryParams) { + const ts = new Date().toISOString() + const id = nanoid() + const version = {ts, version: id, ...params} + this.dispatch(QueryVersions.add({queryId, version})) + return version + } + + /** + * When you open a query, find the nearest query session tab. + * If one doesn't exist, create it. Next, check the history + * location.pathname for that tab. If it's the same + * as the url you are about to open, reload it. Don't push + * to the session history or the tab history. + * + * If it's not the same, push that url to the tab history + * and optionally to the session history. + * This is a candidate for a refactor + */ + open(id: string | QueryParams, options: Partial = {}) { + const opts = openQueryOptions(options) + const lakeId = this.select(Current.getLakeId) + const tab = this.select(Tabs.findFirstQuerySession) + const tabId = tab ? tab.id : nanoid() + + let queryId: string, versionId: string + if (typeof id === "string") { + const q = this.select(Current.getQueryById(id)) + queryId = id + versionId = opts.version || q.latestVersionId() + } else { + queryId = tabId + versionId = nanoid() + this.addVersion(queryId, { + ...id, + version: versionId, + ts: new Date().toISOString(), + }) + } + + const url = lakeQueryPath(queryId, lakeId, versionId) + if (tab) { + this.dispatch(Tabs.activate(tabId)) + } else { + this.dispatch(Tabs.create(url, tabId)) + } + + const history = this.select(Current.getHistory) + if (history.location.pathname === url) { + this.dispatch(tabHistory.reload()) + } else { + if (opts.history === "replace") { + this.dispatch(tabHistory.replace(url)) + this.dispatch(SessionHistories.replace(queryId, versionId)) + } else if (opts.history) { + this.dispatch(tabHistory.push(url)) + this.dispatch(SessionHistories.push(queryId, versionId)) + } else { + this.dispatch(tabHistory.push(url)) + } + } + } + + private select any>(selector: T) { + return selector(this.getState()) + } } + +const openQueryOptions = ( + user: Partial +): OpenQueryOptions => ({ + history: true, + ...user, +}) diff --git a/src/js/api/queries/types.ts b/src/js/api/queries/types.ts new file mode 100644 index 0000000000..b7a128cf52 --- /dev/null +++ b/src/js/api/queries/types.ts @@ -0,0 +1,18 @@ +import {QueryPin} from "src/js/state/Editor/types" + +export type CreateQueryParams = {name: string} + +export type OpenQueryOptions = { + history?: boolean | "replace" + version?: string + tabId?: string +} + +export type QueryParams = { + pins: QueryPin[] + value: string +} + +export type Select = any>( + selector: T +) => ReturnType diff --git a/src/js/components/ConnectionError.tsx b/src/js/components/ConnectionError.tsx index 83ff732580..c65e2aaf65 100644 --- a/src/js/components/ConnectionError.tsx +++ b/src/js/components/ConnectionError.tsx @@ -1,7 +1,7 @@ import React, {useState} from "react" import MacSpinner from "./MacSpinner" import styled from "styled-components" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" import {useDispatch} from "src/app/core/state" import {initCurrentTab} from "../flows/initCurrentTab" import {Lake} from "../state/Lakes/types" diff --git a/src/js/components/CurlModal.tsx b/src/js/components/CurlModal.tsx index 2ddf364b60..0c53804e6b 100644 --- a/src/js/components/CurlModal.tsx +++ b/src/js/components/CurlModal.tsx @@ -13,7 +13,7 @@ import { Scrollable, Title, } from "./ModalDialog/ModalDialog" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" import useEnterKey from "./hooks/useEnterKey" export default function CurlModalBox({onClose}) { diff --git a/src/js/components/DebugModal.tsx b/src/js/components/DebugModal.tsx index a77bda37ed..2866f92b33 100644 --- a/src/js/components/DebugModal.tsx +++ b/src/js/components/DebugModal.tsx @@ -13,7 +13,7 @@ import { Scrollable, Title, } from "./ModalDialog/ModalDialog" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" import useEnterKey from "./hooks/useEnterKey" export function DebugModal({onClose}) { diff --git a/src/js/components/ErrorNotice.test.tsx b/src/js/components/ErrorNotice.test.tsx index b67b8e4f30..d2edc49fde 100644 --- a/src/js/components/ErrorNotice.test.tsx +++ b/src/js/components/ErrorNotice.test.tsx @@ -13,7 +13,7 @@ import {act} from "react-dom/test-utils" const brim = setupBrim() beforeEach(() => { - render(, {store: brim.store}) + render(, {store: brim.store, api: brim.api}) }) test("renders Error notice with no details", async () => { diff --git a/src/js/components/ExportModal.tsx b/src/js/components/ExportModal.tsx index 74b1798836..fe6680db28 100644 --- a/src/js/components/ExportModal.tsx +++ b/src/js/components/ExportModal.tsx @@ -5,7 +5,7 @@ import {ChangeEvent, useState} from "react" import {toast} from "react-hot-toast" import {useDispatch} from "react-redux" import styled from "styled-components" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" import exportResults from "../flows/exportResults" import {AppDispatch} from "../state/types" import InputLabel from "./common/forms/InputLabel" diff --git a/src/js/components/IngestWarningsModal.tsx b/src/js/components/IngestWarningsModal.tsx index f0a9f8ae09..4bad84bbf8 100644 --- a/src/js/components/IngestWarningsModal.tsx +++ b/src/js/components/IngestWarningsModal.tsx @@ -1,6 +1,6 @@ import React from "react" import {useDispatch, useSelector} from "react-redux" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" import Current from "../state/Current" import Ingests from "../state/Ingests" import useEnterKey from "./hooks/useEnterKey" diff --git a/src/js/components/LakeModals/LakeForm.tsx b/src/js/components/LakeModals/LakeForm.tsx index 12b916c91b..79ebc4209d 100644 --- a/src/js/components/LakeModals/LakeForm.tsx +++ b/src/js/components/LakeModals/LakeForm.tsx @@ -14,7 +14,7 @@ import TextInput from "../common/forms/TextInput" import useCallbackRef from "../hooks/useCallbackRef" import useEventListener from "../hooks/useEventListener" import MacSpinner from "../MacSpinner" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" import {isDefaultLake} from "../../initializers/initLakeParams" const SignInForm = styled.div` diff --git a/src/js/components/LakeModals/ViewLakeModal.tsx b/src/js/components/LakeModals/ViewLakeModal.tsx index 153e75671d..3a905e1370 100644 --- a/src/js/components/LakeModals/ViewLakeModal.tsx +++ b/src/js/components/LakeModals/ViewLakeModal.tsx @@ -3,7 +3,7 @@ import {Content, Title} from "../ModalDialog/ModalDialog" import {useSelector} from "react-redux" import Current from "../../state/Current" import Pools from "../../state/Pools" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" import styled from "styled-components" import StatusLight from "./StatusLight" import EditLakeModal from "./EditLakeModal" diff --git a/src/js/components/LogDetailsWindow/index.tsx b/src/js/components/LogDetailsWindow/index.tsx index c0a2b3c91d..d0a516fcf2 100644 --- a/src/js/components/LogDetailsWindow/index.tsx +++ b/src/js/components/LogDetailsWindow/index.tsx @@ -5,7 +5,7 @@ import Current from "../../state/Current" import HistoryButtons from "../common/HistoryButtons" import LogDetails from "../../state/LogDetails" import DetailPane from "src/app/detail/Pane" -import ActionButton from "src/app/query-home/toolbar/action-button" +import ActionButton from "src/app/query-home/toolbar/actions/action-button" import usePluginToolbarItems from "src/app/query-home/toolbar/hooks/use-plugin-toolbar-items" import classNames from "classnames" diff --git a/src/js/components/Login.tsx b/src/js/components/Login.tsx index 7652fbb005..12fcb56b0d 100644 --- a/src/js/components/Login.tsx +++ b/src/js/components/Login.tsx @@ -9,7 +9,7 @@ import MacSpinner from "./MacSpinner" import {isString} from "lodash" import {updateStatus} from "../flows/lake/update-status" import {login} from "../flows/lake/login" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" const PageWrap = styled.div` width: 100%; diff --git a/src/js/components/ModalBox/Buttons.tsx b/src/js/components/ModalBox/Buttons.tsx index 23df9071d7..c934a76dc9 100644 --- a/src/js/components/ModalBox/Buttons.tsx +++ b/src/js/components/ModalBox/Buttons.tsx @@ -2,7 +2,7 @@ import React, {MouseEvent} from "react" import {ModalButton} from "./types" import ButtonRow from "../ButtonRow" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" type Props = { template: ModalButton[] diff --git a/src/js/components/Preferences/Preferences.test.tsx b/src/js/components/Preferences/Preferences.test.tsx index e14f56fd84..c1836f8e70 100644 --- a/src/js/components/Preferences/Preferences.test.tsx +++ b/src/js/components/Preferences/Preferences.test.tsx @@ -45,7 +45,7 @@ const $ = { beforeEach(() => { brim.dispatch(Modal.show("settings")) - render(, {store: brim.store}) + render(, {store: brim.store, api: brim.api}) }) test("change time format", async () => { @@ -56,7 +56,7 @@ test("change time format", async () => { await waitForElementToBeRemoved($.modal) const record = createRecord({ts: new Date(2019, 9, 1, 8)}) - render(, {store: brim.store}) + render(, {store: brim.store, api: brim.api}) expect($.dd.textContent).toBe("2019") }) diff --git a/src/js/components/StartupError.tsx b/src/js/components/StartupError.tsx index bdb2153122..d8f77dd988 100644 --- a/src/js/components/StartupError.tsx +++ b/src/js/components/StartupError.tsx @@ -2,7 +2,7 @@ import React from "react" import styled from "styled-components" import * as remote from "@electron/remote" import Link from "./common/Link" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" const Wrap = styled.div` padding: 24px; diff --git a/src/js/components/TabBar/TabBar.tsx b/src/js/components/TabBar/TabBar.tsx index 836b22c275..75aa63fcc8 100644 --- a/src/js/components/TabBar/TabBar.tsx +++ b/src/js/components/TabBar/TabBar.tsx @@ -77,7 +77,7 @@ export default function TabBar() { )} - {ids.map((id) => { + {ids.map((id: string) => { const tab = brim.tab(id, lakes, pools, queryIdNameMap) return ( (dispatch, getState) => { - const state = getState() - const pool = Current.getPool(state) - const poolId = Current.getPoolId(state) - const poolIsDeleted = poolId && !pool - if (poolIsDeleted) dispatch(resetTab()) -} - -export function resetTab(): Thunk { - return (dispatch, getState) => { - const id = Current.getLakeId(getState()) - dispatch(Tabs.clearActive()) - dispatch(tabHistory.push(lakePath(id))) - dispatch(syncPoolsData()) - } -} diff --git a/src/js/flows/openNewSearchWindow.ts b/src/js/flows/openNewSearchWindow.ts index f758d1a471..b694766d1d 100644 --- a/src/js/flows/openNewSearchWindow.ts +++ b/src/js/flows/openNewSearchWindow.ts @@ -17,7 +17,7 @@ export const openNewSearchTab = (): Thunk => { ) invoke( ipc.windows.newSearchTab({ - href: lakeQueryPath(query.id, lakeId), + href: lakeQueryPath(query.id, lakeId, query.latestVersionId()), }) ) } diff --git a/src/js/initializers/initDebugGlobals.ts b/src/js/initializers/initDebugGlobals.ts index 98416516f5..5edf0d7e71 100644 --- a/src/js/initializers/initDebugGlobals.ts +++ b/src/js/initializers/initDebugGlobals.ts @@ -1,9 +1,10 @@ +import BrimApi from "../api" import Current from "../state/Current" import Tabs from "../state/Tabs" import {Store} from "../state/types" export class DevGlobal { - constructor(readonly store: Store) {} + constructor(readonly store: Store, readonly api: BrimApi) {} get url() { return Current.getLocation(this.store.getState()) @@ -18,6 +19,6 @@ export class DevGlobal { } } -export default function (store) { - global.dev = new DevGlobal(store) +export default function (store, api) { + global.dev = new DevGlobal(store, api) } diff --git a/src/js/initializers/initialize.ts b/src/js/initializers/initialize.ts index 47418ad4f4..cc99cb5011 100644 --- a/src/js/initializers/initialize.ts +++ b/src/js/initializers/initialize.ts @@ -22,7 +22,7 @@ export default async function initialize() { initIpcListeners(store, pluginManager) initMenuActionListeners(store) initLakeParams(store) - initDebugGlobals(store) + initDebugGlobals(store, api) return {store, api, pluginManager} } diff --git a/src/js/state/Current/selectors.ts b/src/js/state/Current/selectors.ts index 38641dc4ff..dfcc8e3961 100644 --- a/src/js/state/Current/selectors.ts +++ b/src/js/state/Current/selectors.ts @@ -1,5 +1,4 @@ import {matchPath} from "react-router" -import {createSelector} from "reselect" import brim, {BrimLake} from "../../brim" import Pools from "../Pools" import {PoolsState} from "../Pools/types" @@ -9,12 +8,19 @@ import Lakes from "../Lakes" import {LakesState} from "../Lakes/types" import {MemoryHistory} from "history" import {Pool} from "src/app/core/pools/pool" -import DraftQueries from "../DraftQueries" import RemoteQueries from "../RemoteQueries" import Queries from "../Queries" import {BrimQuery} from "src/app/query-home/utils/brim-query" import QueryVersions from "../QueryVersions" import {query, queryVersion} from "src/app/router/routes" +import SessionQueries from "../SessionQueries" +import SessionHistories from "../SessionHistories" +import {createSelector} from "@reduxjs/toolkit" +import { + SessionHistoriesState, + SessionHistoryEntry, +} from "../SessionHistories/types" +import {QueryVersion} from "../QueryVersions/types" type Id = string | null @@ -41,17 +47,17 @@ export const getQueryLocationData = ( queryVersion.path, query.path, ]) - const queryId = match?.params?.queryId const version = match?.params?.version + + let queryId = match?.params?.queryId return {queryId, version} } export const getQueryById = (id: string, version?: string) => (state: State): BrimQuery | null => { - // query lookup policy is to search drafts first, then local, and finally remote const query = - DraftQueries.getById(id)(state) || + SessionQueries.getById(id)(state) || Queries.getQueryById(id)(state) || RemoteQueries.getQueryById(id)(state) if (!query) return null @@ -66,6 +72,15 @@ export const getQuery = (state: State): BrimQuery | null => { return getQueryById(queryId, version)(state) } +export const getVersion = (state: State): QueryVersion => { + const {queryId, version} = getQueryLocationData(state) + const tabId = getTabId(state) + return ( + QueryVersions.getByVersion(queryId, version)(state) || + QueryVersions.getByVersion(tabId, version)(state) + ) +} + export const getPoolId = (state) => { type Params = {poolId?: string} const match = matchPath(getLocation(state).pathname, [ @@ -74,6 +89,7 @@ export const getPoolId = (state) => { return match?.params?.poolId || null } +// This is weird, we need to get this from the state and not the url. export const getLakeId = (state: State = undefined) => { type Params = {lakeId?: string} const match = matchPath(getLocation(state).pathname, "/lakes/:lakeId") @@ -157,3 +173,12 @@ export const getPools = createSelector(getLake, Pools.raw, (l, pools) => { export const getTabId = (s: State) => { return s.tabs.active } + +export const getSessionHistory = createSelector< + State, + string, + SessionHistoriesState, + SessionHistoryEntry[] +>([getTabId, SessionHistories.raw], (tabId, histories) => histories[tabId]) + +export const getSessionId = getTabId diff --git a/src/js/state/DraftQueries/index.ts b/src/js/state/DraftQueries/index.ts deleted file mode 100644 index cb2a853f73..0000000000 --- a/src/js/state/DraftQueries/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {createSlice} from "@reduxjs/toolkit" -import {Query} from "../Queries/types" - -export type DraftQueriesState = { - [queryId: string]: Query -} - -const slice = createSlice({ - name: "$draftQueries", - initialState: {}, - reducers: { - set(s, a) { - s[a.payload.id] = a.payload - }, - remove(s, a) { - delete s[a.payload.id] - }, - }, -}) - -export default { - reducer: slice.reducer, - ...slice.actions, - raw: (s) => s.draftQueries, - getById: - (id: string) => - (s): Query => - s.draftQueries[id], -} diff --git a/src/js/state/Editor/selectors.ts b/src/js/state/Editor/selectors.ts index 980120776f..eda2bd6a1a 100644 --- a/src/js/state/Editor/selectors.ts +++ b/src/js/state/Editor/selectors.ts @@ -1,3 +1,5 @@ +import {createSelector, nanoid} from "@reduxjs/toolkit" +import {QueryVersion} from "../QueryVersions/types" import activeTabSelect from "../Tab/activeTabSelect" export const getPins = activeTabSelect((tab) => { @@ -23,3 +25,16 @@ export const getPinHoverIndex = activeTabSelect((tab) => { export const getPinCount = activeTabSelect((tab) => { return tab.editor.pins.length }) + +export const getSnapshot = activeTabSelect((tab) => { + return { + value: tab.editor.value, + pins: tab.editor.pins, + version: nanoid(), + ts: new Date().toISOString(), + } as QueryVersion +}) + +export const isEmpty = createSelector(getValue, getPins, (value, pins) => { + return value.trim() === "" && pins.length === 0 +}) diff --git a/src/js/state/Layout/reducer.ts b/src/js/state/Layout/reducer.ts index b8dee8ec8b..4c838b50c8 100644 --- a/src/js/state/Layout/reducer.ts +++ b/src/js/state/Layout/reducer.ts @@ -8,7 +8,9 @@ const slice = createSlice({ rightSidebarWidth: 260, columnHeadersView: "AUTO" as ColumnHeadersViewState, resultsView: "TABLE" as ResultsView, - currentPaneName: "versions" as PaneName, + currentPaneName: "history" as PaneName, + isEditingTitle: false, + titleFormAction: "create" as "create" | "update", }, reducers: { showDetailPane: (s) => { @@ -32,6 +34,17 @@ const slice = createSlice({ setCurrentPaneName(s, action: PayloadAction) { s.currentPaneName = action.payload }, + showTitleForm: { + prepare: (action: "create" | "update") => ({payload: {action}}), + reducer: (s, a: PayloadAction<{action: "create" | "update"}>) => { + s.isEditingTitle = true + s.titleFormAction = a.payload.action + }, + }, + hideTitleForm(s) { + s.isEditingTitle = false + s.titleFormAction = "create" + }, }, }) diff --git a/src/js/state/Layout/selectors.ts b/src/js/state/Layout/selectors.ts index a66038755a..2fcef5165c 100644 --- a/src/js/state/Layout/selectors.ts +++ b/src/js/state/Layout/selectors.ts @@ -10,4 +10,6 @@ export default { getCurrentPaneName: activeTabSelect((state) => state.layout.currentPaneName), getColumnsView: activeTabSelect((state) => state.layout.columnHeadersView), getResultsView: activeTabSelect((s) => s.layout.resultsView), + getIsEditingTitle: activeTabSelect((s) => s.layout.isEditingTitle), + getTitleFormAction: activeTabSelect((s) => s.layout.titleFormAction), } diff --git a/src/js/state/Layout/types.ts b/src/js/state/Layout/types.ts index c5d277e8c5..56ff25ac99 100644 --- a/src/js/state/Layout/types.ts +++ b/src/js/state/Layout/types.ts @@ -1,3 +1,3 @@ export type ResultsView = "INSPECTOR" | "TABLE" export type ColumnHeadersViewState = "AUTO" | "ON" | "OFF" -export type PaneName = "detail" | "versions" +export type PaneName = "detail" | "versions" | "history" diff --git a/src/js/state/Queries/actions.ts b/src/js/state/Queries/actions.ts index 4d8caa97ce..683b1dd3ed 100644 --- a/src/js/state/Queries/actions.ts +++ b/src/js/state/Queries/actions.ts @@ -10,10 +10,12 @@ import { } from "./types" export default { - setAll: (rootGroup: Group): QUERIES_SET_ALL => ({ - type: "$QUERIES_SET_ALL", - rootGroup, - }), + setAll: (rootGroup: Group): QUERIES_SET_ALL => { + return { + type: "$QUERIES_SET_ALL", + rootGroup, + } + }, addItem: (item: Item, parentGroupId = "root"): QUERIES_ADD_ITEM => ({ type: "$QUERIES_ADD_ITEM", item, diff --git a/src/js/state/Queries/flows.ts b/src/js/state/Queries/flows.ts index ca3867a005..143eaeb630 100644 --- a/src/js/state/Queries/flows.ts +++ b/src/js/state/Queries/flows.ts @@ -5,25 +5,31 @@ import QueryVersions from "src/js/state/QueryVersions" import {QueryVersion} from "src/js/state/QueryVersions/types" import actions from "./actions" import Queries from "." -import {flattenQueryTree, getNextQueryCount} from "./helpers" +import {flattenQueryTree, getNextCount} from "./helpers" +import {BrimQuery} from "src/app/query-home/utils/brim-query" -export function create(attrs: Partial = {}): Thunk { +export function create( + attrs: Partial = {} +): Thunk { return (dispatch, getState) => { const queries = flattenQueryTree(Queries.raw(getState()), false).map( (n) => n.model ) + const {name, ...versionAttrs} = attrs const query: Query = { id: nanoid(), - name: `Query #${getNextQueryCount(queries)}`, + name: name || `Query #${getNextCount(queries, "Query")}`, } const version: QueryVersion = { value: "", - ...attrs, + ...versionAttrs, version: nanoid(), ts: new Date().toISOString(), } dispatch(actions.addItem(query)) dispatch(QueryVersions.add({queryId: query.id, version})) - return query + const versions = QueryVersions.getByQueryId(query.id)(getState()) + + return new BrimQuery(query, versions) } } diff --git a/src/js/state/Queries/flows/get-query-source.ts b/src/js/state/Queries/flows/get-query-source.ts index 522e861c7d..5aaf32dd47 100644 --- a/src/js/state/Queries/flows/get-query-source.ts +++ b/src/js/state/Queries/flows/get-query-source.ts @@ -1,12 +1,12 @@ -import DraftQueries from "src/js/state/DraftQueries" import Queries from "src/js/state/Queries/index" import RemoteQueries from "src/js/state/RemoteQueries" +import SessionQueries from "src/js/state/SessionQueries" -export type QuerySource = "local" | "remote" | "draft" +export type QuerySource = "local" | "remote" | "session" export const getQuerySource = (id?: string) => (_d, getState): QuerySource => { - if (DraftQueries.getById(id)(getState())) return "draft" + if (SessionQueries.getById(id)(getState())) return "session" if (Queries.getQueryById(id)(getState())) return "local" if (RemoteQueries.getQueryById(id)(getState())) return "remote" return null diff --git a/src/js/state/Queries/flows/update-query.ts b/src/js/state/Queries/flows/update-query.ts index b109ef7093..888764c357 100644 --- a/src/js/state/Queries/flows/update-query.ts +++ b/src/js/state/Queries/flows/update-query.ts @@ -1,4 +1,3 @@ -import DraftQueries from "src/js/state/DraftQueries" import Queries from "src/js/state/Queries/index" import {getQuerySource} from "./get-query-source" import {setRemoteQueries} from "src/js/state/RemoteQueries/flows/remote-queries" @@ -17,7 +16,5 @@ export const updateQuery = case "remote": await dispatch(setRemoteQueries([{...q, ...query.latestVersion()}])) return - default: - dispatch(DraftQueries.set(q)) } } diff --git a/src/js/state/Queries/helpers.test.ts b/src/js/state/Queries/helpers.test.ts index e59263a77c..1f503bd69f 100644 --- a/src/js/state/Queries/helpers.test.ts +++ b/src/js/state/Queries/helpers.test.ts @@ -2,7 +2,7 @@ * @jest-environment jsdom */ -import {getNextQueryCount} from "./helpers" +import {getNextCount} from "./helpers" import {Query} from "./types" const excludeTestQueries: Query[] = [ @@ -43,17 +43,20 @@ const includeTestQueries: Query[] = [ }, ] -test("getNextQueryCount", () => { - expect(getNextQueryCount([])).toEqual(1) - expect(getNextQueryCount(excludeTestQueries)).toEqual(1) +test("getNextCount", () => { + expect(getNextCount([], "Query")).toEqual(1) + expect(getNextCount(excludeTestQueries, "Query")).toEqual(1) expect( - getNextQueryCount([...excludeTestQueries, ...includeTestQueries]) + getNextCount([...excludeTestQueries, ...includeTestQueries], "Query") ).toEqual(4) expect( - getNextQueryCount([ - {id: "9", name: "Query #10"}, - ...excludeTestQueries, - ...includeTestQueries, - ]) + getNextCount( + [ + {id: "9", name: "Query #10"}, + ...excludeTestQueries, + ...includeTestQueries, + ], + "Query" + ) ).toEqual(11) }) diff --git a/src/js/state/Queries/helpers.ts b/src/js/state/Queries/helpers.ts index 5fbfc67b4d..b87fb59ac5 100644 --- a/src/js/state/Queries/helpers.ts +++ b/src/js/state/Queries/helpers.ts @@ -8,11 +8,15 @@ export const flattenQueryTree = (root: Group, includeFolders = true) => { }) } -export const getNextQueryCount = (queries: Query[]): number => { +export const getNextCount = ( + queries: Query[], + type: "Session" | "Query" +): number => { + const regex = type === "Session" ? /^Session #\d+$/ : /^Query #\d+$/ return ( (last( queries - .filter((q) => /^Query #\d+$/.test(q.name)) + .filter((q) => regex.test(q.name)) .map((q) => parseInt(q.name.split("#")[1])) .sort((a, b) => a - b) ) ?? 0) + 1 diff --git a/src/js/state/Queries/selectors.ts b/src/js/state/Queries/selectors.ts index d5c5d2dd5a..72749a7af4 100644 --- a/src/js/state/Queries/selectors.ts +++ b/src/js/state/Queries/selectors.ts @@ -38,3 +38,7 @@ export const getTags = createSelector( return Object.keys(tagMap) } ) + +export const any = createSelector(getGroupById("root"), (group) => { + return group.items.length > 0 +}) diff --git a/src/js/state/QueryVersions/index.ts b/src/js/state/QueryVersions/index.ts index cb2049c440..054fe078ff 100644 --- a/src/js/state/QueryVersions/index.ts +++ b/src/js/state/QueryVersions/index.ts @@ -1,4 +1,6 @@ +import {isEqual} from "lodash" import {queryVersionsSlice, versionAdapter, versionSlice} from "./reducer" +import {QueryVersion} from "./types" export default { reducer: queryVersionsSlice.reducer, @@ -9,8 +11,13 @@ export default { if (!queryVersions) return [] return versionAdapter.getSelectors().selectAll(queryVersions) }, - getByVersion: (queryId, version) => (state) => - versionAdapter - .getSelectors() - .selectById(state.queryVersions[queryId], version), + getByVersion: (queryId, version) => (state) => { + const versions = state.queryVersions[queryId] + if (!versions) return null + return versionAdapter.getSelectors().selectById(versions, version) + }, + + areEqual(a: QueryVersion, b: QueryVersion) { + return isEqual(a?.pins, b?.pins) && isEqual(a?.value, b?.value) + }, } diff --git a/src/js/state/QueryVersions/reducer.ts b/src/js/state/QueryVersions/reducer.ts index 1c11ad545a..ebc7d9ec07 100644 --- a/src/js/state/QueryVersions/reducer.ts +++ b/src/js/state/QueryVersions/reducer.ts @@ -1,4 +1,5 @@ import {createEntityAdapter, createSlice} from "@reduxjs/toolkit" +import {actions as tabs} from "../Tabs/reducer" import {QueryVersion} from "./types" export const versionAdapter = createEntityAdapter({ @@ -39,5 +40,11 @@ export const queryVersionsSlice = createSlice({ } } ) + builder.addMatcher( + ({type}) => type == tabs.remove.toString(), + (s, a) => { + delete s[a.payload] + } + ) }, }) diff --git a/src/js/state/SessionHistories/flows.ts b/src/js/state/SessionHistories/flows.ts new file mode 100644 index 0000000000..72c056a9f7 --- /dev/null +++ b/src/js/state/SessionHistories/flows.ts @@ -0,0 +1,30 @@ +import {Thunk} from "../types" +import Current from "../Current" +import SessionHistories from "." +import getQueryById from "../Queries/flows/get-query-by-id" + +export const push = + (queryId: string, versionId?: string): Thunk => + (dispatch, getState) => { + const sessionId = Current.getTabId(getState()) + const version = + versionId || dispatch(getQueryById(queryId))?.latestVersionId() || "" + const entry = { + queryId, + version, + } + dispatch(SessionHistories.pushById({sessionId, entry})) + } + +export const replace = + (queryId: string, versionId?: string): Thunk => + (dispatch, getState) => { + const sessionId = Current.getTabId(getState()) + const version = + versionId || dispatch(getQueryById(queryId))?.latestVersionId() || "" + const entry = { + queryId, + version, + } + dispatch(SessionHistories.replaceById({sessionId, entry})) + } diff --git a/src/js/state/SessionHistories/index.ts b/src/js/state/SessionHistories/index.ts new file mode 100644 index 0000000000..8ba615604b --- /dev/null +++ b/src/js/state/SessionHistories/index.ts @@ -0,0 +1,10 @@ +import {actions, reducer} from "./reducer" +import * as flows from "./flows" +import * as selectors from "./selectors" + +export default { + reducer, + ...flows, + ...actions, + ...selectors, +} diff --git a/src/js/state/SessionHistories/reducer.ts b/src/js/state/SessionHistories/reducer.ts new file mode 100644 index 0000000000..04b73a2b8e --- /dev/null +++ b/src/js/state/SessionHistories/reducer.ts @@ -0,0 +1,44 @@ +import {createSlice, PayloadAction} from "@reduxjs/toolkit" +import {SessionHistoriesState, SessionHistoryEntry} from "./types" +import {actions as tabs} from "../Tabs/reducer" + +const slice = createSlice({ + name: "sessionHistories", + initialState: {} as SessionHistoriesState, + reducers: { + replaceById( + s, + a: PayloadAction<{sessionId: string; entry: SessionHistoryEntry}> + ) { + if (!s[a.payload.sessionId]) s[a.payload.sessionId] = [a.payload.entry] + else { + s[a.payload.sessionId].pop() + s[a.payload.sessionId].push(a.payload.entry) + } + }, + pushById( + s, + a: PayloadAction<{sessionId: string; entry: SessionHistoryEntry}> + ) { + if (!s[a.payload.sessionId]) s[a.payload.sessionId] = [a.payload.entry] + else s[a.payload.sessionId].push(a.payload.entry) + }, + deleteById(s, a: PayloadAction<{sessionId: string}>) { + delete s[a.payload.sessionId] + }, + deleteEntry(s, a: PayloadAction<{sessionId: string; index: number}>) { + const session = s[a.payload.sessionId] + if (session) { + session.splice(a.payload.index, 1) + } + }, + }, + extraReducers: { + [tabs.remove.toString()]: (s, a: ReturnType) => { + delete s[a.payload] + }, + }, +}) + +export const reducer = slice.reducer +export const actions = slice.actions diff --git a/src/js/state/SessionHistories/selectors.ts b/src/js/state/SessionHistories/selectors.ts new file mode 100644 index 0000000000..83abe19edc --- /dev/null +++ b/src/js/state/SessionHistories/selectors.ts @@ -0,0 +1,7 @@ +import {SessionHistoriesState, SessionHistoryEntry} from "./types" + +export const raw = (s): SessionHistoriesState => s.sessionHistories +export const getById = + (sessionId: string) => + (s): SessionHistoryEntry[] => + s.sessionHistories[sessionId] diff --git a/src/js/state/SessionHistories/types.ts b/src/js/state/SessionHistories/types.ts new file mode 100644 index 0000000000..c1b961c015 --- /dev/null +++ b/src/js/state/SessionHistories/types.ts @@ -0,0 +1,8 @@ +export type SessionHistoryEntry = { + queryId: string + version: string +} + +export type SessionHistoriesState = { + [sessionId: string]: SessionHistoryEntry[] +} diff --git a/src/js/state/SessionQueries/flows.ts b/src/js/state/SessionQueries/flows.ts new file mode 100644 index 0000000000..ce07f3fde2 --- /dev/null +++ b/src/js/state/SessionQueries/flows.ts @@ -0,0 +1,38 @@ +import {nanoid} from "@reduxjs/toolkit" +import {Query} from "src/js/state/Queries/types" +import {Thunk} from "src/js/state/types" +import QueryVersions from "src/js/state/QueryVersions" +import {QueryVersion} from "src/js/state/QueryVersions/types" +import {BrimQuery} from "src/app/query-home/utils/brim-query" +import SessionQueries from "." +import {getNextCount} from "../Queries/helpers" +import Current from "../Current" + +export const create = + (attrs: Partial = {}): Thunk => + (dispatch, getState) => { + const queryId = Current.getTabId(getState()) + const queries = Object.values(SessionQueries.raw(getState())) + const query: Query = { + id: queryId, + name: `Session #${getNextCount(queries, "Session")}`, + } + const version: QueryVersion = { + value: "", + ...attrs, + ts: new Date().toISOString(), + version: nanoid(), + } + + const exists = queries.find((q) => q.id === queryId) + !exists && dispatch(SessionQueries.set(query)) + + dispatch(QueryVersions.add({queryId: query.id, version})) + const versions = QueryVersions.getByQueryId(query.id)(getState()) + + return new BrimQuery(query, versions) + } + +export const init = (id: string) => (dispatch) => { + dispatch(SessionQueries.set({id, name: "Query Session"})) +} diff --git a/src/js/state/SessionQueries/index.ts b/src/js/state/SessionQueries/index.ts new file mode 100644 index 0000000000..ebee3f4485 --- /dev/null +++ b/src/js/state/SessionQueries/index.ts @@ -0,0 +1,10 @@ +import {reducer, actions} from "./reducer" +import * as flows from "./flows" +import * as selectors from "./selectors" + +export default { + reducer, + ...flows, + ...actions, + ...selectors, +} diff --git a/src/js/state/SessionQueries/reducer.ts b/src/js/state/SessionQueries/reducer.ts new file mode 100644 index 0000000000..75abc242d3 --- /dev/null +++ b/src/js/state/SessionQueries/reducer.ts @@ -0,0 +1,20 @@ +import {createSlice} from "@reduxjs/toolkit" +import {actions as tabs} from "../Tabs/reducer" + +const slice = createSlice({ + name: "sessionQueries", + initialState: {}, + reducers: { + set(s, a) { + s[a.payload.id] = a.payload + }, + }, + extraReducers: { + [tabs.remove.toString()]: (s, a: ReturnType) => { + delete s[a.payload] + }, + }, +}) + +export const reducer = slice.reducer +export const actions = slice.actions diff --git a/src/js/state/SessionQueries/selectors.ts b/src/js/state/SessionQueries/selectors.ts new file mode 100644 index 0000000000..690227a8fc --- /dev/null +++ b/src/js/state/SessionQueries/selectors.ts @@ -0,0 +1,8 @@ +import {SessionQueriesState} from "./types" +import {Query} from "../Queries/types" + +export const raw = (s): SessionQueriesState => s.sessionQueries +export const getById = + (sessionId: string) => + (s): Query => + s.sessionQueries[sessionId] diff --git a/src/js/state/SessionQueries/types.ts b/src/js/state/SessionQueries/types.ts new file mode 100644 index 0000000000..9d417784cd --- /dev/null +++ b/src/js/state/SessionQueries/types.ts @@ -0,0 +1,5 @@ +import {Query} from "../Queries/types" + +export type SessionQueriesState = { + [queryId: string]: Query +} diff --git a/src/js/state/Tab/reducer.ts b/src/js/state/Tab/reducer.ts index 2d4ebec6fd..e7d25e1c8c 100644 --- a/src/js/state/Tab/reducer.ts +++ b/src/js/state/Tab/reducer.ts @@ -15,7 +15,10 @@ const tabReducer = combineReducers({ chart, columns, editor, - id: (state: string = brim.randomHash(), _) => state, + id: (state: string = brim.randomHash(), _): string => state, + lastFocused: (state: string = new Date().toISOString()): string => state, + // eslint-disable-next-line @typescript-eslint/no-inferrable-types + lastLocationKey: (state: string = ""): string => state, inspector, layout, logDetails, diff --git a/src/js/state/Tab/selectors.ts b/src/js/state/Tab/selectors.ts index 7969377ec5..b81390377d 100644 --- a/src/js/state/Tab/selectors.ts +++ b/src/js/state/Tab/selectors.ts @@ -11,6 +11,7 @@ import Url from "../Url" import {SearchParams} from "../Url/selectors" import {createIsEqualSelector} from "../utils" import {TabState} from "./types" +import activeTabSelect from "./activeTabSelect" const lakeUrl = createSelector( Current.getLake, @@ -57,6 +58,8 @@ const getSpanAsDates = createSelector( } ) +const getLastLocationKey = activeTabSelect((t) => t.lastLocationKey) + export default { lakeUrl, getPoolName: (state: State) => { @@ -67,4 +70,5 @@ export default { getSpanAsDates, getSpanArgs, getComputedSpan, + getLastLocationKey, } diff --git a/src/js/state/TabHistories/index.ts b/src/js/state/TabHistories/index.ts index 2aa3e7928c..21f58c7e6c 100644 --- a/src/js/state/TabHistories/index.ts +++ b/src/js/state/TabHistories/index.ts @@ -1,5 +1,5 @@ import {createEntityAdapter, createSlice} from "@reduxjs/toolkit" -import Tabs from "../Tabs" +import {actions as tabs} from "../Tabs/reducer" import {State} from "../types" import {SerializedHistory} from "./types" @@ -12,9 +12,9 @@ const slice = createSlice({ save: adapter.setAll, }, extraReducers: { - [Tabs.remove.toString()]: ( + [tabs.remove.toString()]: ( state, - action: ReturnType + action: ReturnType ) => { global.tabHistories.delete(action.payload) return state diff --git a/src/js/state/Tabs/find.ts b/src/js/state/Tabs/find.ts index 667b930ed1..1e2352f812 100644 --- a/src/js/state/Tabs/find.ts +++ b/src/js/state/Tabs/find.ts @@ -1,18 +1,9 @@ +import {orderBy} from "lodash" import {matchPath} from "react-router" -import {query, queryVersion} from "src/app/router/routes" +import {queryVersion} from "src/app/router/routes" export function findTabByUrl(tabs, url) { - type Params = {queryId?: string; version?: string} - const queryMatch = matchPath(url, [queryVersion.path, query.path]) return tabs.find((tab) => { - if (queryMatch) { - const tabQueryMatch = matchPath( - global.tabHistories.getOrCreate(tab.id).location.pathname, - [queryVersion.path, query.path] - ) - return tabQueryMatch?.params.queryId === queryMatch.params.queryId - } - return global.tabHistories.getOrCreate(tab.id).location.pathname === url }) } @@ -20,3 +11,11 @@ export function findTabByUrl(tabs, url) { export function findTabById(tabs, id) { return tabs.find((tab) => tab.id === id) } + +export function findQuerySessionTab(tabs) { + return orderBy(tabs, ["lastFocused"], ["desc"]).find((tab) => { + const pathname = global.tabHistories.getOrCreate(tab.id).location.pathname + const match = matchPath(pathname, {path: queryVersion.path, exact: true}) + return !!match + }) +} diff --git a/src/js/state/Tabs/flows.ts b/src/js/state/Tabs/flows.ts index 2bb822a894..b2a723ef9a 100644 --- a/src/js/state/Tabs/flows.ts +++ b/src/js/state/Tabs/flows.ts @@ -1,19 +1,29 @@ +import {nanoid} from "@reduxjs/toolkit" import {ipcRenderer} from "electron" -import brim from "../../brim" +import {lakeQueryPath} from "src/app/router/utils/paths" +import Current from "../Current" +import SessionQueries from "../SessionQueries" import {Thunk} from "../types" import Tabs from "./" import {findTabById, findTabByUrl} from "./find" export const create = - (url = "/"): Thunk => + (url = "/", id = nanoid()): Thunk => (dispatch) => { - const id = brim.randomHash() + dispatch(SessionQueries.init(id)) dispatch(Tabs.add(id)) + global.tabHistories.create(id, [{pathname: url}], 0) dispatch(Tabs.activate(id)) - global.tabHistories.create(id).push(url) return id } +export const createQuerySession = (): Thunk => (dispatch, getState) => { + const id = nanoid() + const lakeId = Current.getLakeId(getState()) + const url = lakeQueryPath(id, lakeId, null) + return dispatch(create(url, id)) +} + export const previewUrl = (url: string): Thunk => (dispatch, getState) => { diff --git a/src/js/state/Tabs/index.ts b/src/js/state/Tabs/index.ts index 489a58fec1..ebcb0b5ddf 100644 --- a/src/js/state/Tabs/index.ts +++ b/src/js/state/Tabs/index.ts @@ -1,10 +1,10 @@ import * as flows from "./flows" import * as selectors from "./selectors" -import slice from "./slice" +import {actions, reducer} from "./reducer" export default { ...selectors, ...flows, - ...slice.actions, - reducer: slice.reducer, + ...actions, + reducer, } diff --git a/src/js/state/Tabs/slice.ts b/src/js/state/Tabs/reducer.ts similarity index 90% rename from src/js/state/Tabs/slice.ts rename to src/js/state/Tabs/reducer.ts index 22a4176517..ee97f6cd90 100644 --- a/src/js/state/Tabs/slice.ts +++ b/src/js/state/Tabs/reducer.ts @@ -62,7 +62,11 @@ const slice = createSlice({ if (id === s.preview) s.preview = null }, activate(s, a: PayloadAction) { - if (findTab(s, a.payload)) s.active = a.payload + const tab = findTab(s, a.payload) + if (tab) { + tab.lastFocused = new Date().toISOString() + s.active = a.payload + } }, preview(s, a: PayloadAction) { if (!a.payload) s.preview = null @@ -82,6 +86,10 @@ const slice = createSlice({ type: "@INIT", }) }, + loaded(s, a: PayloadAction) { + const tab = findTab(s, s.active) + tab.lastLocationKey = a.payload + }, }, extraReducers: (builder) => { builder.addMatcher(isTabAction, (s, a) => { @@ -110,4 +118,5 @@ const findTabIndex = (s: Draft, id: string) => { return s.data.findIndex((t) => t.id === id) } -export default slice +export const actions = slice.actions +export const reducer = slice.reducer diff --git a/src/js/state/Tabs/selectors.ts b/src/js/state/Tabs/selectors.ts index fbe66af9f0..cee6ec47fe 100644 --- a/src/js/state/Tabs/selectors.ts +++ b/src/js/state/Tabs/selectors.ts @@ -3,6 +3,7 @@ import {TabState} from "../Tab/types" import {State} from "../types" import {createIsEqualSelector} from "../utils" import {TabsState} from "./types" +import {findQuerySessionTab} from "./find" export const getData = (state: State) => state.tabs.data export const getActive = (state: State) => state.tabs.active @@ -22,4 +23,16 @@ export const _getIds = createSelector(getData, (data) => { return data.map((d) => d.id) }) -export const getIds = createIsEqualSelector(_getIds, (ids) => ids) +export const getIds = createIsEqualSelector( + _getIds, + (ids) => ids +) + +export const findFirstQuerySession = createSelector< + State, + TabState[], + TabState +>((state) => state.tabs.data, findQuerySessionTab) + +export const findById = (tabId: string) => + createSelector(getData, (tabs) => tabs.find((t) => t.id === tabId)) diff --git a/src/js/state/Tabs/test.ts b/src/js/state/Tabs/test.ts index d90c869178..4088dca120 100644 --- a/src/js/state/Tabs/test.ts +++ b/src/js/state/Tabs/test.ts @@ -65,7 +65,7 @@ test("remove middle, active tab", () => { }) test("remove first, active tab", () => { - const first = Tabs.getData(store.getState())[0].id + const first = Tabs.getData(store.getState())[0].id as string const state = store.dispatchAll([ Tabs.add("1"), Tabs.add("2"), @@ -97,7 +97,7 @@ test("remove non-active tab after active tab", () => { }) test("remove tab does nothing if only one tab left", () => { - const first = Tabs.getData(store.getState())[0].id + const first = Tabs.getData(store.getState())[0].id as string const state = store.dispatchAll([Tabs.remove(first)]) expect(Tabs.getCount(state)).toBe(1) diff --git a/src/js/state/Tabs/types.ts b/src/js/state/Tabs/types.ts index e54cbb3b97..4ac01f2b84 100644 --- a/src/js/state/Tabs/types.ts +++ b/src/js/state/Tabs/types.ts @@ -1,3 +1,3 @@ -import slice from "./slice" +import {reducer} from "./reducer" -export type TabsState = ReturnType +export type TabsState = ReturnType diff --git a/src/js/state/getPersistable.ts b/src/js/state/getPersistable.ts index 5d7ca13bed..e0352d68c9 100644 --- a/src/js/state/getPersistable.ts +++ b/src/js/state/getPersistable.ts @@ -14,6 +14,8 @@ const WINDOW_PERSIST: StateKey[] = [ "queries", "queryVersions", "tabHistories", + "sessionHistories", + "sessionQueries", "lakes", ] @@ -24,6 +26,8 @@ const TAB_PERSIST: TabKey[] = [ "columns", "layout", "editor", + "lastFocused", + "lastLocationKey", ] function deleteAccessTokens(state: Partial) { diff --git a/src/js/state/globalReducer.ts b/src/js/state/globalReducer.ts index 874c5b884d..45efde09c5 100644 --- a/src/js/state/globalReducer.ts +++ b/src/js/state/globalReducer.ts @@ -10,7 +10,6 @@ import PluginStorage, {PluginStorageState} from "./PluginStorage" import Queries from "./Queries" import {QueriesState} from "./Queries/types" import RemoteQueries from "./RemoteQueries" -import {DraftQueriesState} from "./DraftQueries" import QueryVersions from "./QueryVersions" import {QueryVersionsState} from "src/js/state/QueryVersions/types" @@ -24,7 +23,6 @@ export type GlobalState = { queries: QueriesState queryVersions: QueryVersionsState remoteQueries: QueriesState - draftQueries: DraftQueriesState } export default combineReducers({ diff --git a/src/js/state/migrations/202207270956_removeDraftQueries.test.ts b/src/js/state/migrations/202207270956_removeDraftQueries.test.ts new file mode 100644 index 0000000000..957fe947b4 --- /dev/null +++ b/src/js/state/migrations/202207270956_removeDraftQueries.test.ts @@ -0,0 +1,11 @@ +import {migrate} from "src/test/unit/helpers/migrate" + +test("migrating 202207270956_removeDraftQueries", async () => { + const next = await migrate({state: "v0.30.0", to: "202207270956"}) + + expect.assertions(1) + // @ts-ignore + for (const {state} of Object.values(next.windows)) { + expect(state.draftQueries).toBeUndefined() + } +}) diff --git a/src/js/state/migrations/202207270956_removeDraftQueries.ts b/src/js/state/migrations/202207270956_removeDraftQueries.ts new file mode 100644 index 0000000000..17a07167b5 --- /dev/null +++ b/src/js/state/migrations/202207270956_removeDraftQueries.ts @@ -0,0 +1,9 @@ +import {getAllStates} from "./utils/getTestState" + +export default function removeDraftQueries(state: any) { + for (const s of getAllStates(state)) { + delete s.draftQueries + } + + return state +} diff --git a/src/js/state/rootReducer.ts b/src/js/state/rootReducer.ts index 5af73b614f..1ddba04786 100644 --- a/src/js/state/rootReducer.ts +++ b/src/js/state/rootReducer.ts @@ -21,8 +21,9 @@ import Launches from "./Launches" import Appearance from "./Appearance" import RemoteQueries from "./RemoteQueries" import Ingests from "./Ingests" -import DraftQueries from "./DraftQueries" import QueryVersions from "./QueryVersions" +import SessionQueries from "./SessionQueries" +import SessionHistories from "./SessionHistories" const rootReducer = combineReducers({ appearance: Appearance.reducer, @@ -44,7 +45,8 @@ const rootReducer = combineReducers({ queries: Queries.reducer, queryVersions: QueryVersions.reducer, remoteQueries: RemoteQueries.reducer, - draftQueries: DraftQueries.reducer, + sessionQueries: SessionQueries.reducer, + sessionHistories: SessionHistories.reducer, tabHistories: TabHistories.reducer, url: Url.reducer, toolbars: Toolbars.reducer, diff --git a/src/js/state/types.ts b/src/js/state/types.ts index 90cfa7dbb4..a2449e15e4 100644 --- a/src/js/state/types.ts +++ b/src/js/state/types.ts @@ -20,8 +20,9 @@ import {TabHistoriesState} from "./TabHistories/types" import {TabsState} from "./Tabs/types" import {ToolbarsState} from "./Toolbars" import {LakeStatusesState} from "./LakeStatuses/types" -import {DraftQueriesState} from "./DraftQueries" +import {SessionQueriesState} from "./SessionQueries/types" import {QueryVersionsState} from "./QueryVersions/types" +import {SessionHistoriesState} from "./SessionHistories/types" export type ThunkExtraArg = { dispatch: AppDispatch @@ -56,7 +57,8 @@ export type State = { queries: QueriesState remoteQueries: QueriesState queryVersions: QueryVersionsState - draftQueries: DraftQueriesState + sessionQueries: SessionQueriesState + sessionHistories: SessionHistoriesState systemTest: SystemTestState toolbars: ToolbarsState } diff --git a/src/js/zql/toZql.ts b/src/js/zql/toZql.ts index 684e76c26b..f08977b3c6 100644 --- a/src/js/zql/toZql.ts +++ b/src/js/zql/toZql.ts @@ -28,7 +28,7 @@ export function toZql(object: unknown): string { if (isString(object)) return toZqlString(object) if (object instanceof Date) return toZqlDate(object) if (typeof object === "boolean") return toZqlBool(object) - + if (object === null) return toZqlNull() throw new Error(`Can't convert object to ZQL: ${object}`) } @@ -37,6 +37,10 @@ const ESCAPED_DOUBLE_QUOTE = '\\"' const BACK_SLASH = /\\/g const ESCAPED_BACK_SLASH = "\\\\" +function toZqlNull() { + return "null" +} + function toZqlString(string: string) { return `"${string .replace(BACK_SLASH, ESCAPED_BACK_SLASH) diff --git a/src/plugins/brimcap/brimcap-plugin.ts b/src/plugins/brimcap/brimcap-plugin.ts index 866df704f2..1f78557f9b 100644 --- a/src/plugins/brimcap/brimcap-plugin.ts +++ b/src/plugins/brimcap/brimcap-plugin.ts @@ -87,6 +87,7 @@ export default class BrimcapPlugin { private async tryConn(detail: zed.Record, queryId: string) { const uid = findUid(detail) const pool = this.api.current.poolName + if (!pool || !uid) return null const res = await this.api.query(findConnLog(pool, uid), { id: `brimcap/try-conn-${queryId}`, }) diff --git a/src/ppl/import/LoadFilesInput.tsx b/src/ppl/import/LoadFilesInput.tsx index fd1b37d5d3..e4036d7e0f 100644 --- a/src/ppl/import/LoadFilesInput.tsx +++ b/src/ppl/import/LoadFilesInput.tsx @@ -1,6 +1,6 @@ import {useBrimApi} from "src/app/core/context" import {useImportOnDrop} from "src/app/features/import/use-import-on-drop" -import ToolbarButton from "src/app/query-home/toolbar/button" +import ToolbarButton from "src/app/query-home/toolbar/actions/button" import classNames from "classnames" import React, {ChangeEvent, MouseEvent} from "react" import useCallbackRef from "src/js/components/hooks/useCallbackRef" diff --git a/src/test/unit/helpers/render.tsx b/src/test/unit/helpers/render.tsx index c0e24c5d27..2d2faa5b22 100644 --- a/src/test/unit/helpers/render.tsx +++ b/src/test/unit/helpers/render.tsx @@ -1,32 +1,17 @@ import {render as rtlRender} from "@testing-library/react" -import AppTabsRouter from "src/app/router/app-tabs-router" -import WindowRouter from "src/app/router/app-window-router" import React, {ComponentType, ReactElement} from "react" -import {Provider} from "react-redux" import HTMLContextMenu from "src/js/components/HTMLContextMenu" -import theme from "src/js/style-theme" -import {ThemeProvider} from "styled-components" +import {BrimProvider} from "src/app/core/context" -function getRouter() { - if (global.windowName === "detail") return WindowRouter - if (global.windowName === "search") return AppTabsRouter - return NoRouter -} - -function NoRouter({children}) { - return children -} - -export function render(ui: ReactElement, {store}) { +export function render(ui: ReactElement, {store, api}) { function Wrapper({children}) { - const Router = getRouter() return ( - - - {children} + + <> + {children} - - + + ) } diff --git a/src/test/unit/helpers/setup-brim.ts b/src/test/unit/helpers/setup-brim.ts index 8affa20902..238b2b2241 100644 --- a/src/test/unit/helpers/setup-brim.ts +++ b/src/test/unit/helpers/setup-brim.ts @@ -1,4 +1,5 @@ import "@testing-library/jest-dom" +import BrimApi from "src/js/api" import {BrimMain} from "src/js/electron/brim" import {main} from "src/js/electron/main" import initialize from "src/js/initializers/initialize" @@ -14,11 +15,18 @@ class BrimTestContext { store: Store plugins: PluginManager main: BrimMain + api: BrimApi - assign(args: {store: Store; plugins: PluginManager; main: BrimMain}) { + assign(args: { + store: Store + plugins: PluginManager + main: BrimMain + api: BrimApi + }) { this.store = args.store this.plugins = args.plugins this.main = args.main + this.api = args.api } select = (fn) => fn(this.store.getState()) @@ -42,15 +50,15 @@ async function bootBrim({page}: Args = defaults()) { const brimMain = await main({lake: false}) onPage(page) const {store, pluginManager} = await initialize() - return {store, main: brimMain, plugins: pluginManager} + const api = new BrimApi() + api.init(store.dispatch, store.getState) + return {store, main: brimMain, plugins: pluginManager, api} } export function setupBrim(opts: Args = defaults()) { const context = new BrimTestContext() beforeEach(async () => { - // If this fails, the tests will still run. When we're on - // jest 27, this will fail the test as expected. const props = await bootBrim(opts) context.assign(props) if (opts.lake) {