diff --git a/src/js/state/QueryLibrary/actions.ts b/src/js/state/QueryLibrary/actions.ts new file mode 100644 index 0000000000..6a87e6c84d --- /dev/null +++ b/src/js/state/QueryLibrary/actions.ts @@ -0,0 +1,38 @@ +import { + Group, + QLIB_ADD_ITEM, + QLIB_EDIT_ITEM, + QLIB_MOVE_ITEM, + QLIB_REMOVE_ITEM, + QLIB_SET_ALL, + Query +} from "./types" + +export default { + setAll: (rootGroup: Group): QLIB_SET_ALL => ({ + type: "QLIB_SET_ALL", + rootGroup + }), + addItem: (item: Query | Group, groupPath: number[]): QLIB_ADD_ITEM => ({ + type: "QLIB_ADD_ITEM", + item, + groupPath + }), + removeItem: (itemPath: number[]): QLIB_REMOVE_ITEM => ({ + type: "QLIB_REMOVE_ITEM", + itemPath + }), + editItem: (item: Query | Group, itemPath: number[]): QLIB_EDIT_ITEM => ({ + type: "QLIB_EDIT_ITEM", + item, + itemPath + }), + moveItem: ( + srcItemPath: number[], + destItemPath: number[] + ): QLIB_MOVE_ITEM => ({ + type: "QLIB_MOVE_ITEM", + srcItemPath, + destItemPath + }) +} diff --git a/src/js/state/QueryLibrary/index.ts b/src/js/state/QueryLibrary/index.ts new file mode 100644 index 0000000000..7093d5b6a3 --- /dev/null +++ b/src/js/state/QueryLibrary/index.ts @@ -0,0 +1,9 @@ +import actions from "./actions" +import reducer from "./reducer" +import selectors from "./selectors" + +export default { + ...actions, + ...selectors, + reducer +} diff --git a/src/js/state/QueryLibrary/initial.ts b/src/js/state/QueryLibrary/initial.ts new file mode 100644 index 0000000000..9891d2e63f --- /dev/null +++ b/src/js/state/QueryLibrary/initial.ts @@ -0,0 +1,9 @@ +import {QueryLibraryState} from "./types" + +const init = (): QueryLibraryState => ({ + id: "root", + name: "root", + items: [] +}) + +export default init diff --git a/src/js/state/QueryLibrary/reducer.ts b/src/js/state/QueryLibrary/reducer.ts new file mode 100644 index 0000000000..8c2eccef89 --- /dev/null +++ b/src/js/state/QueryLibrary/reducer.ts @@ -0,0 +1,91 @@ +import {Group, Query, QueryLibraryAction, QueryLibraryState} from "./types" +import produce from "immer" +import {get, set, initial, last} from "lodash" +import init from "./initial" + +export default produce( + (draft: QueryLibraryState, action: QueryLibraryAction) => { + switch (action.type) { + case "QLIB_SET_ALL": + return action.rootGroup + case "QLIB_ADD_ITEM": + addItemToGroup(draft, action.groupPath, action.item) + return + case "QLIB_REMOVE_ITEM": + removeItemFromGroup(draft, action.itemPath) + return + case "QLIB_EDIT_ITEM": + if (!get(draft, toItemPath(action.itemPath), null)) return + + set(draft, toItemPath(action.itemPath), action.item) + return + case "QLIB_MOVE_ITEM": + moveItem(draft, action.srcItemPath, action.destItemPath) + return + } + }, + init() +) + +const toItemPath = (path: number[]): string => + path.map((pathNdx) => `items[${pathNdx}]`).join(".") + +const addItemToGroup = ( + draft: QueryLibraryState, + groupPath: number[], + item: Query | Group, + index?: number +): void => { + const parentGroup = get(draft, toItemPath(groupPath), null) + if (!parentGroup) return + + if (typeof index === "undefined") { + parentGroup.items.push(item) + return + } + + parentGroup.items.splice(index, 0, item) +} + +const removeItemFromGroup = ( + draft: QueryLibraryState, + itemPath: number[] +): void => { + const parentGroup = get(draft, toItemPath(initial(itemPath)), null) + if (!parentGroup) return + + parentGroup.items.splice(last(itemPath), 1) +} + +const moveItem = ( + draft: QueryLibraryState, + srcItemPath: number[], + destItemPath: number[] +): void => { + const srcItem = get(draft, toItemPath(srcItemPath), null) + + if (!srcItem) return + if (!get(draft, toItemPath(initial(destItemPath)), null)) return + + // If the move is all in the same directory then the adjusting indices can + // cause an off by one issue since the destination index will be affected after + // removal (e.g. an item cannot be moved to the end of its current group because of this). + // For this situation we instead remove the item first, and then insert its copy + if (srcItemPath.length === destItemPath.length) { + removeItemFromGroup(draft, srcItemPath) + addItemToGroup( + draft, + initial(destItemPath), + {...srcItem}, + last(destItemPath) + ) + } else { + addItemToGroup( + draft, + initial(destItemPath), + {...srcItem}, + last(destItemPath) + ) + removeItemFromGroup(draft, srcItemPath) + } +} diff --git a/src/js/state/QueryLibrary/selectors.ts b/src/js/state/QueryLibrary/selectors.ts new file mode 100644 index 0000000000..58b11e5c1a --- /dev/null +++ b/src/js/state/QueryLibrary/selectors.ts @@ -0,0 +1,6 @@ +import {QueryLibraryState} from "./types" +import {State} from "../types" + +export default { + getRaw: (state: State): QueryLibraryState => state.queryLibrary +} diff --git a/src/js/state/QueryLibrary/test.ts b/src/js/state/QueryLibrary/test.ts new file mode 100644 index 0000000000..12a382aa03 --- /dev/null +++ b/src/js/state/QueryLibrary/test.ts @@ -0,0 +1,201 @@ +import initTestStore from "../../test/initTestStore" +import QueryLibrary from "./" +import {Group} from "./types" +import get from "lodash/get" +import {State} from "../types" + +let store +beforeEach(() => { + store = initTestStore() +}) + +const testLib = { + id: "root", + name: "root", + items: [ + { + // .items[0] + id: "testId1", + name: "testName1", + items: [ + { + // .items[0].items[0] + id: "testId2", + name: "testName2", + description: "testDescription2", + value: "testValue2", + tags: ["testTag1", "testTag2"] + }, + { + // .items[0].items[1] + id: "testId3", + name: "testName3", + items: [ + { + // .items[0].items[1].items[0] + id: "testId4", + name: "testName4", + description: "testDescription4", + value: "testValue4", + tags: ["testTag2"] + } + ] + }, + { + // .items[0].items[2] + id: "testId5", + name: "testName5", + description: "testDescription5", + value: "testValue5", + tags: ["testTag1"] + } + ] + } + ] +} + +const newQuery = { + id: "newQueryId", + name: "newQueryName", + description: "newQueryDescription", + value: "newQueryValue", + tags: [] +} + +const newGroup = { + id: "newGroupId", + name: "newGroupName", + items: [] +} + +const getGroup = (state: State, path: number[]): Group => { + return get( + QueryLibrary.getRaw(state), + path.map((pathNdx) => `items[${pathNdx}]`).join(".") + ) +} + +test("set all", () => { + store.dispatch(QueryLibrary.setAll(testLib)) + + const state = store.getState() + + expect(QueryLibrary.getRaw(state)).toEqual(testLib) +}) + +test("add query", () => { + store.dispatch(QueryLibrary.setAll(testLib)) + + expect(getGroup(store.getState(), [0]).items).toHaveLength(3) + + store.dispatch(QueryLibrary.addItem(newQuery, [0])) + + expect(getGroup(store.getState(), [0]).items).toHaveLength(4) + expect(getGroup(store.getState(), [0]).items[3]).toEqual(newQuery) +}) + +test("add query, nested", () => { + store.dispatch(QueryLibrary.setAll(testLib)) + + expect(getGroup(store.getState(), [0, 1]).items).toHaveLength(1) + + store.dispatch(QueryLibrary.addItem(newQuery, [0, 1])) + + expect(getGroup(store.getState(), [0, 1]).items).toHaveLength(2) + expect(getGroup(store.getState(), [0, 1]).items[1]).toEqual(newQuery) +}) + +test("add group, add query to new group", () => { + store.dispatch(QueryLibrary.setAll(testLib)) + + expect(getGroup(store.getState(), [0]).items).toHaveLength(3) + + store.dispatch(QueryLibrary.addItem(newGroup, [0])) + + expect(getGroup(store.getState(), [0]).items).toHaveLength(4) + expect(getGroup(store.getState(), [0]).items[3]).toEqual(newGroup) + expect(getGroup(store.getState(), [0, 3]).items).toHaveLength(0) + + store.dispatch(QueryLibrary.addItem(newQuery, [0, 3])) + + expect(getGroup(store.getState(), [0, 3]).items).toHaveLength(1) + expect(getGroup(store.getState(), [0, 3]).items[0]).toEqual(newQuery) +}) + +test("remove query, group", () => { + store.dispatch(QueryLibrary.setAll(testLib)) + + const testName1Group = getGroup(store.getState(), [0]).items + expect(testName1Group).toHaveLength(3) + + store.dispatch(QueryLibrary.removeItem([0, 0])) + + expect(getGroup(store.getState(), [0]).items).toHaveLength(2) + expect(getGroup(store.getState(), [0]).items).toEqual(testName1Group.slice(1)) + + store.dispatch(QueryLibrary.removeItem([0, 0])) + expect(getGroup(store.getState(), [0]).items).toHaveLength(1) + expect(getGroup(store.getState(), [0]).items).toEqual([testName1Group[2]]) +}) + +test("move query, same group", () => { + store.dispatch(QueryLibrary.setAll(testLib)) + + const testName1Group = getGroup(store.getState(), [0]).items + expect(testName1Group).toHaveLength(3) + + const testName2Query = testName1Group[0] + + // move to end + store.dispatch(QueryLibrary.moveItem([0, 0], [0, 2])) + + expect(getGroup(store.getState(), [0]).items).toHaveLength(3) + + expect(getGroup(store.getState(), [0]).items).toEqual([ + ...testName1Group.slice(1), + testName2Query + ]) + + // move back to beginning + store.dispatch(QueryLibrary.moveItem([0, 2], [0, 0])) + + expect(getGroup(store.getState(), [0]).items).toHaveLength(3) + expect(getGroup(store.getState(), [0]).items).toEqual(testName1Group) +}) + +test("move query, different group", () => { + store.dispatch(QueryLibrary.setAll(testLib)) + + const testName1Group = getGroup(store.getState(), [0]).items + const testName3Group = (testName1Group[1] as Group).items + + expect(testName1Group).toHaveLength(3) + expect(testName3Group).toHaveLength(1) + + const testName2Query = testName1Group[0] + + store.dispatch(QueryLibrary.moveItem([0, 0], [0, 1, 0])) + + const newTestName1Group = getGroup(store.getState(), [0]).items + const newTestName3Group = (newTestName1Group[0] as Group).items + + expect(newTestName1Group).toHaveLength(2) + expect(newTestName3Group).toHaveLength(2) + + expect(newTestName1Group[0].id).toEqual(testName1Group[1].id) + expect(newTestName3Group).toEqual([testName2Query, ...testName3Group]) +}) + +test("edit query", () => { + store.dispatch(QueryLibrary.setAll(testLib)) + + store.dispatch(QueryLibrary.editItem(newQuery, [0, 0])) + expect(getGroup(store.getState(), [0]).items[0]).toEqual(newQuery) +}) + +test("edit group", () => { + store.dispatch(QueryLibrary.setAll(testLib)) + + store.dispatch(QueryLibrary.editItem(newGroup, [0, 1])) + expect(getGroup(store.getState(), [0, 1])).toEqual(newGroup) +}) diff --git a/src/js/state/QueryLibrary/types.ts b/src/js/state/QueryLibrary/types.ts new file mode 100644 index 0000000000..499d942bfa --- /dev/null +++ b/src/js/state/QueryLibrary/types.ts @@ -0,0 +1,50 @@ +export type QueryLibraryState = Group + +export interface Query { + id: string + name: string + value: string + description: string + tags: string[] +} + +export interface Group { + id: string + name: string + items: (Group | Query)[] +} + +export type QueryLibraryAction = + | QLIB_SET_ALL + | QLIB_ADD_ITEM + | QLIB_REMOVE_ITEM + | QLIB_EDIT_ITEM + | QLIB_MOVE_ITEM + +export interface QLIB_SET_ALL { + type: "QLIB_SET_ALL" + rootGroup: Group +} + +export interface QLIB_ADD_ITEM { + type: "QLIB_ADD_ITEM" + item: Query | Group + groupPath: number[] +} + +export interface QLIB_REMOVE_ITEM { + type: "QLIB_REMOVE_ITEM" + itemPath: number[] +} + +export interface QLIB_EDIT_ITEM { + type: "QLIB_EDIT_ITEM" + item: Query | Group + itemPath: number[] +} + +export interface QLIB_MOVE_ITEM { + type: "QLIB_MOVE_ITEM" + srcItemPath: number[] + destItemPath: number[] +} diff --git a/src/js/state/rootReducer.ts b/src/js/state/rootReducer.ts index 3157a13cbc..2087032d26 100644 --- a/src/js/state/rootReducer.ts +++ b/src/js/state/rootReducer.ts @@ -12,6 +12,7 @@ import Prefs from "./Prefs" import Spaces from "./Spaces" import Tabs from "./Tabs" import View from "./View" +import QueryLibrary from "./QueryLibrary" export default combineReducers({ errors: Errors.reducer, @@ -25,5 +26,6 @@ export default combineReducers({ spaces: Spaces.reducer, packets: Packets.reducer, prefs: Prefs.reducer, - connectionStatuses: ConnectionStatuses.reducer + connectionStatuses: ConnectionStatuses.reducer, + queryLibrary: QueryLibrary.reducer }) diff --git a/src/js/state/types.ts b/src/js/state/types.ts index a19663c858..824a93e2e1 100644 --- a/src/js/state/types.ts +++ b/src/js/state/types.ts @@ -13,6 +13,7 @@ import {TabsState} from "./Tabs/types" import {ViewState} from "./View/types" import {createZealot, Zealot} from "zealot" import {ConnectionStatusesState} from "./ConnectionStatuses/types" +import {QueryLibraryState} from "./QueryLibrary/types" export type GetState = () => State export type ThunkExtraArg = { @@ -41,4 +42,5 @@ export type State = { packets: PacketsState prefs: PrefsState connectionStatuses: ConnectionStatusesState + queryLibrary: QueryLibraryState }