diff --git a/.storybook/config.js b/.storybook/config.js index f51d6c4c..1b26dbd3 100644 --- a/.storybook/config.js +++ b/.storybook/config.js @@ -2,11 +2,9 @@ import React from "react" import { configure, addDecorator } from "@storybook/react" import { withKnobs } from "@storybook/addon-knobs" -import ReactotronProvider from "../src/components/ReactotronProvider" +import ReactotronAppProvider from "../src/components/ReactotronAppProvider" -import theme from "../src/theme" - -const StyledDecorator = storyFn => {storyFn()} +const StyledDecorator = storyFn => {storyFn()} addDecorator(StyledDecorator) addDecorator(withKnobs) diff --git a/package.json b/package.json index 639828ce..0b8c8f3b 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@storybook/addons": "5.3.13", "@storybook/react": "5.3.13", "@types/jest": "25.1.3", + "@testing-library/react-hooks": "3.2.1", "@types/react-modal": "3.10.2", "@types/react-motion": "0.0.29", "@types/react-tooltip": "3.11.0", @@ -70,6 +71,8 @@ "eslint-plugin-import": "2.20.1", "eslint-plugin-node": "11.0.0", "eslint-plugin-promise": "4.2.1", + "eslint-plugin-react": "^7.18.0", + "eslint-plugin-react-hooks": "^2.3.0", "eslint-plugin-standard": "4.0.1", "jest": "25.1.0", "npm-run-all": "4.1.5", @@ -80,6 +83,7 @@ "react-motion": "0.5.2", "react-tooltip": "4.0.3", "rollup": "1.31.1", + "react-test-renderer": "16.12.0", "rollup-plugin-babel": "4.3.3", "rollup-plugin-babel-minify": "9.1.1", "rollup-plugin-filesize": "6.2.1", @@ -96,6 +100,7 @@ "parser": "@typescript-eslint/parser", "extends": [ "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", "standard", "prettier" ], @@ -106,9 +111,13 @@ "project": "./tsconfig.json" }, "plugins": [ - "@typescript-eslint" + "@typescript-eslint", + "react-hooks" ], "rules": { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "react/prop-types": 0, "no-unused-vars": 0, "no-undef": 0, "space-before-function-paren": 0, @@ -119,15 +128,15 @@ "@typescript-eslint/no-object-literal-type-assertion": 0, "@typescript-eslint/no-empty-interface": 0, "@typescript-eslint/no-var-requires": 0, - "@typescript-eslint/member-delimiter-style": 0, - "@typescript-eslint/no-unused-vars": 0 + "@typescript-eslint/member-delimiter-style": 0 } }, "jest": { "preset": "ts-jest", - "testEnvironment": "node", + "testEnvironment": "jsdom", "testMatch": [ - "**/*.test.ts" + "**/*.test.ts", + "**/*.test.tsx" ] } } diff --git a/src/components/EmptyState/EmptyState.story.tsx b/src/components/EmptyState/EmptyState.story.tsx new file mode 100644 index 00000000..eaebb70a --- /dev/null +++ b/src/components/EmptyState/EmptyState.story.tsx @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import React from "react" +import { MdReorder } from "react-icons/md" + +import EmptyState from "./index" + +export default { + title: "Empty State", +} + +export const Default = () => ( + + Some more information + +) diff --git a/src/components/EmptyState/index.tsx b/src/components/EmptyState/index.tsx new file mode 100644 index 00000000..ed97e87e --- /dev/null +++ b/src/components/EmptyState/index.tsx @@ -0,0 +1,42 @@ +import React, { FunctionComponent } from "react" +import styled from "styled-components" + +const Container = styled.div` + height: 100%; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: ${props => props.theme.foregroundLight}; +` + +const Title = styled.div` + font-size: 2rem; + padding-bottom: 50px; + padding-top: 10px; +` + +const Message = styled.div` + color: ${props => props.theme.foreground}; + max-width: 400px; + line-height: 1.4; + text-align: center; +` + +interface Props { + icon?: any // TODO: Type Better? + title: string +} + +const EmptyState: FunctionComponent = ({ title, icon: Icon, children }) => { + return ( + + {Icon && } + {title} + {children} + + ) +} + +export default EmptyState diff --git a/src/components/ReactotronProvider/index.tsx b/src/components/ReactotronAppProvider/index.tsx similarity index 72% rename from src/components/ReactotronProvider/index.tsx rename to src/components/ReactotronAppProvider/index.tsx index 9f015e56..126992d6 100644 --- a/src/components/ReactotronProvider/index.tsx +++ b/src/components/ReactotronAppProvider/index.tsx @@ -5,11 +5,13 @@ import theme from "../../theme" const ReactotronContainer = styled.div` font-family: ${props => props.theme.fontFamily}; + font-size: 0.94em; width: 100%; height: 100%; + user-select: none; ` -const ReactotronProvider: FunctionComponent = ({ children }) => { +const ReactotronAppProvider: FunctionComponent = ({ children }) => { return ( {children} @@ -17,4 +19,4 @@ const ReactotronProvider: FunctionComponent = ({ children }) => { ) } -export default ReactotronProvider; +export default ReactotronAppProvider; diff --git a/src/contexts/CustomCommands/index.tsx b/src/contexts/CustomCommands/index.tsx new file mode 100644 index 00000000..7784695d --- /dev/null +++ b/src/contexts/CustomCommands/index.tsx @@ -0,0 +1,41 @@ +import React, { FunctionComponent, useContext, useCallback } from "react" + +import ReactotronContext from "../Reactotron" + +import useCustomCommands, { CustomCommand } from "./useCustomCommands" + +interface Context { + customCommands: CustomCommand[] + sendCustomCommand: (command: any, args: any) => void +} + +const CustomCommandsContext = React.createContext({ + customCommands: [], + sendCustomCommand: null, +}) + +const Provider: FunctionComponent = ({ children }) => { + const { sendCommand } = useContext(ReactotronContext) + const { customCommands } = useCustomCommands() + + const sendCustomCommand = useCallback( + (command, args) => { + sendCommand("custom", { command, args }) + }, + [sendCommand] + ) + + return ( + + {children} + + ) +} + +export default CustomCommandsContext +export const CustomCommandsProvider = Provider diff --git a/src/contexts/CustomCommands/useCustomCommands.test.tsx b/src/contexts/CustomCommands/useCustomCommands.test.tsx new file mode 100644 index 00000000..0026b1fd --- /dev/null +++ b/src/contexts/CustomCommands/useCustomCommands.test.tsx @@ -0,0 +1,109 @@ +/* eslint-disable react/display-name */ +import React from "react" +import { renderHook } from "@testing-library/react-hooks" + +import ReactotronContext from "../Reactotron" +import { CommandType } from "../../types" + +import useCustomCommands from "./useCustomCommands" + +function buildContextValues({ addCommandListener = null } = {}) { + return { + commands: [], + sendCommand: jest.fn(), + clearCommands: jest.fn(), + addCommandListener: addCommandListener || jest.fn(), + isDispatchModalOpen: false, + dispatchModalInitialAction: "", + openDispatchModal: jest.fn(), + closeDispatchModal: jest.fn(), + isSubscriptionModalOpen: false, + openSubscriptionModal: jest.fn(), + closeSubscriptionModal: jest.fn(), + } +} + +describe("contexts/CustomCommands/useCustomCommands", () => { + it("should add and remove custom commands", () => { + let addCallback = null + + const contextValues = buildContextValues({ + addCommandListener: callback => { + addCallback = callback + }, + }) + + const { result } = renderHook(() => useCustomCommands(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.customCommands).toEqual([]) + + addCallback({ + type: CommandType.CustomCommandRegister, + clientId: "1234", + payload: { + id: 0, + }, + }) + + expect(result.current.customCommands).toEqual([ + { + clientId: "1234", + id: 0, + }, + ]) + + addCallback({ + type: CommandType.CustomCommandUnregister, + clientId: "1234", + payload: { + id: 0, + }, + }) + + expect(result.current.customCommands).toEqual([]) + }) + + it("should clear all commands after a reconnect", () => { + let addCallback = null + + const contextValues = buildContextValues({ + addCommandListener: callback => { + addCallback = callback + }, + }) + + const { result } = renderHook(() => useCustomCommands(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.customCommands).toEqual([]) + + addCallback({ + type: CommandType.CustomCommandRegister, + clientId: "1234", + payload: { + id: 0, + }, + }) + + expect(result.current.customCommands).toEqual([ + { + clientId: "1234", + id: 0, + }, + ]) + + addCallback({ + type: CommandType.ClientIntro, + clientId: "1234", + }) + + expect(result.current.customCommands).toEqual([]) + }) +}) diff --git a/src/contexts/CustomCommands/useCustomCommands.ts b/src/contexts/CustomCommands/useCustomCommands.ts new file mode 100644 index 00000000..7dab7e35 --- /dev/null +++ b/src/contexts/CustomCommands/useCustomCommands.ts @@ -0,0 +1,99 @@ +import { useReducer, useContext, useEffect } from "react" +import produce from "immer" + +import { Command, CommandType } from "../../types" +import ReactotronContext from "../Reactotron" + +export interface CustomCommand { + clientId: string + id: string + title?: string + command: string + description?: string + args?: { + name: string + }[] +} + +interface CustomCommandState { + customCommands: CustomCommand[] +} + +enum CustomCommandsActionType { + CommandAdd = "COMMAND_ADD", + CommandRemove = "COMMAND_REMOVE", + CommandClear = "COMMAND_CLEAR", +} + +type Action = + | { + type: CustomCommandsActionType.CommandAdd + payload: Command + } + | { type: CustomCommandsActionType.CommandRemove; payload: Command } + | { type: CustomCommandsActionType.CommandClear; payload: string } + +function customCommandsReducer(state: CustomCommandState, action: Action) { + switch (action.type) { + case CustomCommandsActionType.CommandAdd: + return produce(state, draftState => { + draftState.customCommands.push({ + clientId: action.payload.clientId, + ...action.payload.payload, + }) + }) + case CustomCommandsActionType.CommandRemove: + return produce(state, draftState => { + const commandIndex = draftState.customCommands.findIndex( + cc => cc.clientId === action.payload.clientId && cc.id === action.payload.payload.id + ) + + if (commandIndex === -1) return + + draftState.customCommands = [ + ...draftState.customCommands.slice(0, commandIndex), + ...draftState.customCommands.slice(commandIndex + 1), + ] + }) + case CustomCommandsActionType.CommandClear: + return produce(state, draftState => { + draftState.customCommands = draftState.customCommands.filter( + cc => cc.clientId !== action.payload + ) + }) + default: + return state + } +} + +function useCustomCommands() { + const { addCommandListener } = useContext(ReactotronContext) + const [state, dispatch] = useReducer(customCommandsReducer, { customCommands: [] }) + + useEffect(() => { + addCommandListener(command => { + if (command.type === CommandType.ClientIntro) { + dispatch({ + type: CustomCommandsActionType.CommandClear, + payload: command.clientId, + }) + } else if (command.type === CommandType.CustomCommandRegister) { + dispatch({ + type: CustomCommandsActionType.CommandAdd, + payload: command, + }) + } else if (command.type === CommandType.CustomCommandUnregister) { + dispatch({ + type: CustomCommandsActionType.CommandRemove, + payload: command, + }) + } + }) + }, [addCommandListener]) + + return { + customCommands: state.customCommands, + } +} + +export default useCustomCommands diff --git a/src/contexts/ReactNative/index.tsx b/src/contexts/ReactNative/index.tsx new file mode 100644 index 00000000..312207f5 --- /dev/null +++ b/src/contexts/ReactNative/index.tsx @@ -0,0 +1,34 @@ +import React, { FunctionComponent } from "react" + +import useStorybook from "./useStorybook" + +interface Context { + isStorybookOn: boolean + turnOnStorybook: () => void + turnOffStorybook: () => void +} + +const ReactNativeContext = React.createContext({ + isStorybookOn: false, + turnOnStorybook: null, + turnOffStorybook: null, +}) + +const Provider: FunctionComponent = ({ children }) => { + const { isStorybookOn, turnOnStorybook, turnOffStorybook } = useStorybook() + + return ( + + {children} + + ) +} + +export default ReactNativeContext +export const ReactNativeProvider = Provider diff --git a/src/contexts/ReactNative/useStorybook.test.tsx b/src/contexts/ReactNative/useStorybook.test.tsx new file mode 100644 index 00000000..b7a176f3 --- /dev/null +++ b/src/contexts/ReactNative/useStorybook.test.tsx @@ -0,0 +1,72 @@ +/* eslint-disable react/display-name */ +import React from "react" +import { renderHook } from "@testing-library/react-hooks" + +import ReactotronContext from "../Reactotron" +import { CommandType } from "../../types" + +import useStorybook from "./useStorybook" + +function buildContextValues({ addCommandListener = null } = {}) { + return { + commands: [], + sendCommand: jest.fn(), + clearCommands: jest.fn(), + addCommandListener: addCommandListener || jest.fn(), + isDispatchModalOpen: false, + dispatchModalInitialAction: "", + openDispatchModal: jest.fn(), + closeDispatchModal: jest.fn(), + isSubscriptionModalOpen: false, + openSubscriptionModal: jest.fn(), + closeSubscriptionModal: jest.fn(), + } +} + +describe("contexts/ReactNative/useStorybook", () => { + it("should call send command when storybook is turned on and off", () => { + const contextValues = buildContextValues() + + const { result } = renderHook(() => useStorybook(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.isStorybookOn).toBeFalsy() + + result.current.turnOnStorybook() + + expect(result.current.isStorybookOn).toBeTruthy() + expect(contextValues.sendCommand).toHaveBeenLastCalledWith("storybook", true) + + result.current.turnOffStorybook() + + expect(result.current.isStorybookOn).toBeFalsy() + expect(contextValues.sendCommand).toHaveBeenLastCalledWith("storybook", false) + }) + + it("should send current storybook status to connecting clients", () => { + let addCallback = null + + const contextValues = buildContextValues({ + addCommandListener: callback => { + addCallback = callback + }, + }) + + const { result } = renderHook(() => useStorybook(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + addCallback({ type: CommandType.ClientIntro, clientId: "1234" }) + expect(contextValues.sendCommand).toHaveBeenLastCalledWith("storybook", false, "1234") + + result.current.turnOnStorybook() + + addCallback({ type: CommandType.ClientIntro, clientId: "1234" }) + expect(contextValues.sendCommand).toHaveBeenLastCalledWith("storybook", true, "1234") + }) +}) diff --git a/src/contexts/ReactNative/useStorybook.ts b/src/contexts/ReactNative/useStorybook.ts new file mode 100644 index 00000000..41e36201 --- /dev/null +++ b/src/contexts/ReactNative/useStorybook.ts @@ -0,0 +1,41 @@ +import { useState, useCallback, useContext, useEffect, useRef } from "react" + +import ReactotronContext from "../Reactotron" +import { CommandType } from "../../types" + +function useStorybook() { + const { sendCommand, addCommandListener } = useContext(ReactotronContext) + const [isStorybookOn, setIsStorybookOn] = useState(false) + + // We use these refs to avoid executing the following useEffect over and over adding a bunch of listeners but allow it to have updated info. + // I would like to see if there is a more "correct" approach eventually but this works for now. + const isStorybookOnRef = useRef(isStorybookOn) + isStorybookOnRef.current = isStorybookOn + + const sendCommandRef = useRef(sendCommand) + sendCommandRef.current = sendCommand + + useEffect(() => { + addCommandListener(command => { + // TODO: Switch to a connection event if/when that is available + if (command.type !== CommandType.ClientIntro) return + + sendCommandRef.current("storybook", isStorybookOnRef.current, command.clientId) + }) + }, [addCommandListener, isStorybookOnRef, sendCommandRef]) + // End of this ref madness + + const turnOnStorybook = useCallback(() => { + setIsStorybookOn(true) + sendCommand("storybook", true) + }, [sendCommand]) + + const turnOffStorybook = useCallback(() => { + setIsStorybookOn(false) + sendCommand("storybook", false) + }, [sendCommand]) + + return { isStorybookOn, turnOnStorybook, turnOffStorybook } +} + +export default useStorybook diff --git a/src/contexts/Reactotron/index.tsx b/src/contexts/Reactotron/index.tsx new file mode 100644 index 00000000..5e9ac417 --- /dev/null +++ b/src/contexts/Reactotron/index.tsx @@ -0,0 +1,83 @@ +import React, { FunctionComponent } from "react" + +import { Command } from "../../types" + +import useReactotron from "./useReactotron" + +interface Props { + commands: Command[] + sendCommand: (type: string, payload: any, clientId?: string) => void + clearCommands: () => void + addCommandListener: (callback: (command: Command) => void) => void +} + +interface ContextProps extends Props { + // Command Events + addCommandListener: (callback: (command: Command) => void) => void + + // Dispatch Modal + isDispatchModalOpen: boolean + dispatchModalInitialAction: string + openDispatchModal: (initialAction: string) => void + closeDispatchModal: () => void + + // Subscription Modal + isSubscriptionModalOpen: boolean + openSubscriptionModal: () => void + closeSubscriptionModal: () => void +} + +const ReactotronContext = React.createContext({ + commands: [], + sendCommand: null, + clearCommands: null, + addCommandListener: null, + isDispatchModalOpen: false, + dispatchModalInitialAction: "", + openDispatchModal: null, + closeDispatchModal: null, + isSubscriptionModalOpen: false, + openSubscriptionModal: null, + closeSubscriptionModal: null, +}) + +const Provider: FunctionComponent = ({ + commands, + sendCommand, + clearCommands, + addCommandListener, + children, +}) => { + const { + isDispatchModalOpen, + dispatchModalInitialAction, + openDispatchModal, + closeDispatchModal, + isSubscriptionModalOpen, + openSubscriptionModal, + closeSubscriptionModal, + } = useReactotron() + + return ( + + {children} + + ) +} + +export default ReactotronContext +export const ReactotronProvider = Provider diff --git a/src/contexts/Reactotron/useReactotron.test.ts b/src/contexts/Reactotron/useReactotron.test.ts new file mode 100644 index 00000000..e56a302c --- /dev/null +++ b/src/contexts/Reactotron/useReactotron.test.ts @@ -0,0 +1,52 @@ +import { renderHook } from "@testing-library/react-hooks" + +import useReactotron from "./useReactotron" + +describe("contexts/Reactotron/useReactotron", () => { + describe("Dispatch Modal", () => { + it("should allow opening the modal", () => { + const { result } = renderHook(() => useReactotron()) + + expect(result.current.isDispatchModalOpen).toBe(false) + + result.current.openDispatchModal("{ hello: true }") + + expect(result.current.isDispatchModalOpen).toBe(true) + expect(result.current.dispatchModalInitialAction).toBe("{ hello: true }") + }) + + it("should close the modal after it is open", () => { + const { result } = renderHook(() => useReactotron()) + + expect(result.current.isDispatchModalOpen).toBe(false) + result.current.openDispatchModal("{ hello: true }") + + result.current.closeDispatchModal() + + expect(result.current.isDispatchModalOpen).toBe(false) + }) + }) + + describe("Subscription Modal", () => { + it("should allow opening the modal", () => { + const { result } = renderHook(() => useReactotron()) + + expect(result.current.isSubscriptionModalOpen).toBe(false) + + result.current.openSubscriptionModal() + + expect(result.current.isSubscriptionModalOpen).toBe(true) + }) + + it("should close the modal after it is open", () => { + const { result } = renderHook(() => useReactotron()) + + expect(result.current.isSubscriptionModalOpen).toBe(false) + result.current.openSubscriptionModal() + + result.current.closeSubscriptionModal() + + expect(result.current.isSubscriptionModalOpen).toBe(false) + }) + }) +}) diff --git a/src/contexts/Reactotron/useReactotron.ts b/src/contexts/Reactotron/useReactotron.ts new file mode 100644 index 00000000..90c58c29 --- /dev/null +++ b/src/contexts/Reactotron/useReactotron.ts @@ -0,0 +1,92 @@ +import { useReducer, useCallback } from "react" + +interface ReactotronState { + isDispatchModalOpen: boolean + dispatchModalInitialAction: string + isSubscriptionModalOpen: boolean +} + +enum ReactotronActionType { + DispatchModalOpen = "DISPATCH_OPEN", + DispatchModalClose = "DISPATCH_CLOSE", + SubscriptionModalOpen = "SUBSCRIPTION_OPEN", + SubscriptionModalClose = "SUBSCRIPTION_CLOSE", +} + +interface ReactotronAction { + type: ReactotronActionType + payload?: string +} + +function reactotronReducer(state: ReactotronState, action: ReactotronAction) { + switch (action.type) { + case ReactotronActionType.DispatchModalOpen: + return { + ...state, + isDispatchModalOpen: true, + dispatchModalInitialAction: action.payload || "", + } + case ReactotronActionType.DispatchModalClose: + return { + ...state, + isDispatchModalOpen: false, + } + case ReactotronActionType.SubscriptionModalOpen: + return { + ...state, + isSubscriptionModalOpen: true, + } + case ReactotronActionType.SubscriptionModalClose: + return { + ...state, + isSubscriptionModalOpen: false, + } + default: + return state + } +} + +function useReactotron() { + const [state, dispatch] = useReducer(reactotronReducer, { + isDispatchModalOpen: false, + dispatchModalInitialAction: "", + isSubscriptionModalOpen: false, + }) + + const openDispatchModal = useCallback((intiialAction: string) => { + dispatch({ + type: ReactotronActionType.DispatchModalOpen, + payload: intiialAction, + }) + }, []) + + const closeDispatchModal = useCallback(() => { + dispatch({ + type: ReactotronActionType.DispatchModalClose, + }) + }, []) + + const openSubscriptionModal = useCallback(() => { + dispatch({ + type: ReactotronActionType.SubscriptionModalOpen, + }) + }, []) + + const closeSubscriptionModal = useCallback(() => { + dispatch({ + type: ReactotronActionType.SubscriptionModalClose, + }) + }, []) + + return { + isDispatchModalOpen: state.isDispatchModalOpen, + dispatchModalInitialAction: state.dispatchModalInitialAction, + openDispatchModal, + closeDispatchModal, + isSubscriptionModalOpen: state.isSubscriptionModalOpen, + openSubscriptionModal, + closeSubscriptionModal, + } +} + +export default useReactotron diff --git a/src/contexts/State/index.tsx b/src/contexts/State/index.tsx new file mode 100644 index 00000000..53c3692a --- /dev/null +++ b/src/contexts/State/index.tsx @@ -0,0 +1,82 @@ +import React, { FunctionComponent } from "react" + +import useSubscriptions from "./useSubscriptions" +import useSnapshots, { Snapshot } from "./useSnapshots" + +interface Context { + subscriptions: string[] + addSubscription: (path: string) => void + removeSubscription: (path: string) => void + clearSubscriptions: () => void + snapshots: Snapshot[] + isSnapshotRenameModalOpen: boolean + renameingSnapshot: Snapshot + createSnapshot: () => void + restoreSnapshot: (snapshot: Snapshot) => void + removeSnapshot: (snapshot: Snapshot) => void + renameSnapshot: (name: string) => void + openSnapshotRenameModal: (snapshot: Snapshot) => void + closeSnapshotRenameModal: () => void +} + +const StateContext = React.createContext({ + subscriptions: [], + addSubscription: null, + removeSubscription: null, + clearSubscriptions: null, + snapshots: [], + isSnapshotRenameModalOpen: false, + renameingSnapshot: null, + createSnapshot: null, + restoreSnapshot: null, + removeSnapshot: null, + renameSnapshot: null, + openSnapshotRenameModal: null, + closeSnapshotRenameModal: null, +}) + +const Provider: FunctionComponent = ({ children }) => { + const { + subscriptions, + addSubscription, + removeSubscription, + clearSubscriptions, + } = useSubscriptions() + + const { + snapshots, + isSnapshotRenameModalOpen, + renameingSnapshot, + createSnapshot, + restoreSnapshot, + removeSnapshot, + renameSnapshot, + openSnapshotRenameModal, + closeSnapshotRenameModal, + } = useSnapshots() + + return ( + + {children} + + ) +} + +export default StateContext +export const StateProvider = Provider diff --git a/src/contexts/State/useSnapshots.test.tsx b/src/contexts/State/useSnapshots.test.tsx new file mode 100644 index 00000000..a317bc19 --- /dev/null +++ b/src/contexts/State/useSnapshots.test.tsx @@ -0,0 +1,202 @@ +/* eslint-disable react/display-name */ +import React from "react" +import { renderHook } from "@testing-library/react-hooks" + +import { CommandType } from "../../types" +import ReactotronContext from "../Reactotron" + +import useSnapshots from "./useSnapshots" + +function buildContextValues({ addCommandListener = null } = {}) { + return { + commands: [], + sendCommand: jest.fn(), + clearCommands: jest.fn(), + addCommandListener: addCommandListener || jest.fn(), + isDispatchModalOpen: false, + dispatchModalInitialAction: "", + openDispatchModal: jest.fn(), + closeDispatchModal: jest.fn(), + isSubscriptionModalOpen: false, + openSubscriptionModal: jest.fn(), + closeSubscriptionModal: jest.fn(), + } +} + +describe("contexts/State/useSnapshots", () => { + it("should add a subscription when a command to do so comes in", () => { + let addCallback = null + + const contextValues = buildContextValues({ + addCommandListener: callback => { + addCallback = callback + }, + }) + + const { result } = renderHook(() => useSnapshots(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.snapshots).toEqual([]) + + addCallback({ + type: CommandType.StateBackupResponse, + clientId: "1234", + payload: { + id: 0, + name: "test", + }, + }) + + expect(result.current.snapshots).toEqual([ + { + id: 0, + name: "test", + }, + ]) + }) + + it("should request a subscription", () => { + const contextValues = buildContextValues() + + const { result } = renderHook(() => useSnapshots(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + result.current.createSnapshot() + + expect(contextValues.sendCommand).toHaveBeenCalledWith("state.backup.request", {}) + }) + + it("should request a restore", () => { + let addCallback = null + + const contextValues = buildContextValues({ + addCommandListener: callback => { + addCallback = callback + }, + }) + + const { result } = renderHook(() => useSnapshots(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + addCallback({ + type: CommandType.StateBackupResponse, + clientId: "1234", + payload: { + id: 0, + name: "test", + state: { test: true }, + }, + }) + + result.current.restoreSnapshot(result.current.snapshots[0]) + + expect(contextValues.sendCommand).toHaveBeenCalledWith("state.restore.request", { + state: result.current.snapshots[0].state, + }) + }) + + it("should remove a restore", () => { + let addCallback = null + + const contextValues = buildContextValues({ + addCommandListener: callback => { + addCallback = callback + }, + }) + + const { result } = renderHook(() => useSnapshots(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + addCallback({ + type: CommandType.StateBackupResponse, + clientId: "1234", + payload: { + id: 0, + name: "test", + state: { test: true }, + }, + }) + + result.current.removeSnapshot(result.current.snapshots[0]) + + expect(result.current.snapshots.length).toEqual(0) + }) + + it("should open and close the rename modal", () => { + let addCallback = null + + const contextValues = buildContextValues({ + addCommandListener: callback => { + addCallback = callback + }, + }) + + const { result } = renderHook(() => useSnapshots(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + addCallback({ + type: CommandType.StateBackupResponse, + clientId: "1234", + payload: { + id: 0, + name: "test", + state: { test: true }, + }, + }) + + result.current.openSnapshotRenameModal(result.current.snapshots[0]) + expect(result.current.isSnapshotRenameModalOpen).toBeTruthy() + + result.current.closeSnapshotRenameModal() + expect(result.current.isSnapshotRenameModalOpen).toBeFalsy() + }) + + it("should rename a restore", () => { + let addCallback = null + + const contextValues = buildContextValues({ + addCommandListener: callback => { + addCallback = callback + }, + }) + + const { result } = renderHook(() => useSnapshots(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + addCallback({ + type: CommandType.StateBackupResponse, + clientId: "1234", + payload: { + id: 0, + name: "test", + state: { test: true }, + }, + }) + + result.current.openSnapshotRenameModal(result.current.snapshots[0]) + + expect(result.current.renameingSnapshot).toEqual(result.current.snapshots[0]) + + result.current.renameSnapshot("test2") + + expect(result.current.snapshots[0].name).toEqual("test2") + }) +}) diff --git a/src/contexts/State/useSnapshots.ts b/src/contexts/State/useSnapshots.ts new file mode 100644 index 00000000..023fdbd9 --- /dev/null +++ b/src/contexts/State/useSnapshots.ts @@ -0,0 +1,156 @@ +import { useContext, useEffect, useReducer, useCallback } from "react" +import { format } from "date-fns" +import produce from "immer" + +import { Command, CommandType } from "../../types" +import ReactotronContext from "../Reactotron" + +export interface Snapshot { + id: number + name: string + state: any +} + +interface SnapshotState { + uniqueIdCounter: number + snapshots: Snapshot[] + renameingSnapshot: Snapshot + isSnapshotRenameModalOpen: boolean +} + +enum SnapshotActionType { + SnapshotAdd = "SNAPSHOT_ADD", + SnapshotRemove = "SNAPSHOT_REMOVE", + SnapshotRename = "SNAPSHOT_RENAME", + RenameModalOpen = "RENAME_MODAL_OPEN", + RenameModalClose = "RENAME_MODAL_CLOSE", +} + +type Action = + | { + type: SnapshotActionType.SnapshotAdd + payload: Command + } + | { type: SnapshotActionType.SnapshotRemove; payload: Snapshot } + | { type: SnapshotActionType.SnapshotRename; payload: string } + | { type: SnapshotActionType.RenameModalOpen; payload: Snapshot } + | { type: SnapshotActionType.RenameModalClose } + +function timelineReducer(state: SnapshotState, action: Action) { + switch (action.type) { + case SnapshotActionType.SnapshotAdd: + return produce(state, draftState => { + draftState.snapshots.push({ + id: draftState.uniqueIdCounter++, + name: action.payload.payload.name || format(new Date(), "EEEE @ h:mm:ss a"), + state: action.payload.payload.state, + }) + }) + case SnapshotActionType.SnapshotRemove: + return produce(state, draftState => { + const snapshotIndex = draftState.snapshots.findIndex(s => s.id === action.payload.id) + + if (snapshotIndex === -1) return + + draftState.snapshots = [ + ...draftState.snapshots.slice(0, snapshotIndex), + ...draftState.snapshots.slice(snapshotIndex + 1), + ] + }) + case SnapshotActionType.SnapshotRename: + return produce(state, draftState => { + const snapshot = draftState.snapshots.find(s => s.id === draftState.renameingSnapshot.id) + + if (!snapshot) return + + snapshot.name = action.payload + draftState.isSnapshotRenameModalOpen = false + }) + case SnapshotActionType.RenameModalOpen: + return produce(state, draftState => { + draftState.renameingSnapshot = action.payload + draftState.isSnapshotRenameModalOpen = true + }) + case SnapshotActionType.RenameModalClose: + return produce(state, draftState => { + draftState.isSnapshotRenameModalOpen = false + }) + default: + return state + } +} + +function useSnapshots() { + const { sendCommand, addCommandListener } = useContext(ReactotronContext) + const [state, dispatch] = useReducer(timelineReducer, { + uniqueIdCounter: 0, + snapshots: [], + renameingSnapshot: null, + isSnapshotRenameModalOpen: false, + }) + + useEffect(() => { + addCommandListener(command => { + if (command.type !== CommandType.StateBackupResponse) return + + dispatch({ + type: SnapshotActionType.SnapshotAdd, + payload: command, + }) + }) + }, [addCommandListener]) + + const createSnapshot = useCallback(() => { + sendCommand("state.backup.request", {}) + }, [sendCommand]) + + const restoreSnapshot = useCallback( + (snapshot: Snapshot) => { + if (!snapshot || !snapshot.state) return + + sendCommand("state.restore.request", { state: snapshot.state }) + }, + [sendCommand] + ) + + const removeSnapshot = useCallback(snapshot => { + dispatch({ + type: SnapshotActionType.SnapshotRemove, + payload: snapshot, + }) + }, []) + + const renameSnapshot = useCallback((name: string) => { + dispatch({ + type: SnapshotActionType.SnapshotRename, + payload: name, + }) + }, []) + + const openSnapshotRenameModal = useCallback((snapshot: Snapshot) => { + dispatch({ + type: SnapshotActionType.RenameModalOpen, + payload: snapshot, + }) + }, []) + + const closeSnapshotRenameModal = useCallback(() => { + dispatch({ + type: SnapshotActionType.RenameModalClose, + }) + }, []) + + return { + snapshots: state.snapshots, + isSnapshotRenameModalOpen: state.isSnapshotRenameModalOpen, + renameingSnapshot: state.renameingSnapshot, + createSnapshot, + restoreSnapshot, + removeSnapshot, + renameSnapshot, + openSnapshotRenameModal, + closeSnapshotRenameModal, + } +} + +export default useSnapshots diff --git a/src/contexts/State/useSubscriptions.test.tsx b/src/contexts/State/useSubscriptions.test.tsx new file mode 100644 index 00000000..be1310d2 --- /dev/null +++ b/src/contexts/State/useSubscriptions.test.tsx @@ -0,0 +1,155 @@ +/* eslint-disable react/display-name */ +import React from "react" +import { renderHook } from "@testing-library/react-hooks" + +import ReactotronContext from "../Reactotron" + +import useSubscriptions, { StorageKey } from "./useSubscriptions" + +function buildContextValues({ addCommandListener = null } = {}) { + return { + commands: [], + sendCommand: jest.fn(), + clearCommands: jest.fn(), + addCommandListener: addCommandListener || jest.fn(), + isDispatchModalOpen: false, + dispatchModalInitialAction: "", + openDispatchModal: jest.fn(), + closeDispatchModal: jest.fn(), + isSubscriptionModalOpen: false, + openSubscriptionModal: jest.fn(), + closeSubscriptionModal: jest.fn(), + } +} + +describe("contexts/State/useSubscriptions", () => { + beforeEach(() => { + localStorage.removeItem(StorageKey.Subscriptions) + }) + + describe("Initial Settings", () => { + it("should default to no subscriptions", () => { + const contextValues = buildContextValues() + + const { result } = renderHook(() => useSubscriptions(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.subscriptions).toEqual([]) + }) + + it("should have a stored list of subscriptions", () => { + const contextValues = buildContextValues() + + localStorage.setItem(StorageKey.Subscriptions, JSON.stringify(["test", "test2"])) + + const { result } = renderHook(() => useSubscriptions(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.subscriptions).toEqual(["test", "test2"]) + }) + }) + + describe("Actions", () => { + it("should add a subscription", () => { + const contextValues = buildContextValues() + + const { result } = renderHook(() => useSubscriptions(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + result.current.addSubscription("test") + + expect(result.current.subscriptions).toEqual(["test"]) + expect(contextValues.sendCommand).toHaveBeenCalledWith("state.values.subscribe", { paths: ["test"] }) + expect(localStorage.getItem(StorageKey.Subscriptions)).toEqual(JSON.stringify(["test"])) + }) + + it("should not add a duplicate subscription", () => { + const contextValues = buildContextValues() + + const { result } = renderHook(() => useSubscriptions(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + result.current.addSubscription("test") + + expect(result.current.subscriptions).toEqual(["test"]) + expect(contextValues.sendCommand).toHaveBeenCalledWith("state.values.subscribe", { paths: ["test"] }) + expect(localStorage.getItem(StorageKey.Subscriptions)).toEqual(JSON.stringify(["test"])) + + result.current.addSubscription("test") + expect(result.current.subscriptions.length).toEqual(1) + }) + + it("should remove a subscription", () => { + localStorage.setItem(StorageKey.Subscriptions, JSON.stringify(["test", "test2", "test3"])) + const contextValues = buildContextValues() + + const { result } = renderHook(() => useSubscriptions(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + result.current.removeSubscription("test2") + + expect(result.current.subscriptions).toEqual(["test", "test3"]) + expect(contextValues.sendCommand).toHaveBeenCalledWith("state.values.subscribe", { + paths: ["test", "test3"], + }) + expect(localStorage.getItem(StorageKey.Subscriptions)).toEqual( + JSON.stringify(["test", "test3"]) + ) + }) + + it("should handle removing a subscription that does not exist", () => { + localStorage.setItem(StorageKey.Subscriptions, JSON.stringify(["test", "test2"])) + const contextValues = buildContextValues() + + const { result } = renderHook(() => useSubscriptions(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + result.current.removeSubscription("test3") + + expect(result.current.subscriptions).toEqual(["test", "test2"]) + expect(contextValues.sendCommand).toHaveBeenCalledWith("state.values.subscribe", { + paths: ["test", "test2"], + }) + expect(localStorage.getItem(StorageKey.Subscriptions)).toEqual( + JSON.stringify(["test", "test2"]) + ) + }) + + it("should clear subscriptions", () => { + localStorage.setItem(StorageKey.Subscriptions, JSON.stringify(["test", "test2"])) + const contextValues = buildContextValues() + + const { result } = renderHook(() => useSubscriptions(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + result.current.clearSubscriptions() + + expect(result.current.subscriptions).toEqual([]) + expect(contextValues.sendCommand).toHaveBeenCalledWith("state.values.subscribe", { + paths: [], + }) + expect(localStorage.getItem(StorageKey.Subscriptions)).toEqual(JSON.stringify([])) + }) + }) +}) diff --git a/src/contexts/State/useSubscriptions.ts b/src/contexts/State/useSubscriptions.ts new file mode 100644 index 00000000..913e111c --- /dev/null +++ b/src/contexts/State/useSubscriptions.ts @@ -0,0 +1,107 @@ +import { useReducer, useEffect, useCallback, useContext } from "react" + +import ReactotronContext from "../Reactotron" + +export enum StorageKey { + Subscriptions = "ReactotronSubscriptions", +} + +interface State { + subscriptions: string[] +} + +interface Action { + type: "SUBSCRIPTIONS_SET" + payload?: string | string[] +} + +function subscriptionsReducer(state: State, action: Action) { + switch (action.type) { + case "SUBSCRIPTIONS_SET": + return { ...state, subscriptions: action.payload as string[] } + default: + return state + } +} + +function useSubscriptions() { + const { sendCommand } = useContext(ReactotronContext) + + const [state, dispatch] = useReducer(subscriptionsReducer, { + subscriptions: [], + }) + + // Internal Handlers + const sendSubscriptions = useCallback( + (subscriptions: string[]) => { + localStorage.setItem(StorageKey.Subscriptions, JSON.stringify(subscriptions)) + + sendCommand("state.values.subscribe", { paths: subscriptions }) + }, + [sendCommand] + ) + + // Load up saved subscriptions! + useEffect(() => { + const subscriptions = JSON.parse(localStorage.getItem(StorageKey.Subscriptions) || "[]") + + dispatch({ + type: "SUBSCRIPTIONS_SET", + payload: subscriptions, + }) + + if (subscriptions.length === 0) return + + sendSubscriptions(subscriptions) + }, [sendSubscriptions]) + + // Setup event handlers + const addSubscription = (path: string) => { + if (state.subscriptions.indexOf(path) > -1) return + + const newSubscriptions = [...state.subscriptions, path] + + sendSubscriptions(newSubscriptions) + + dispatch({ + type: "SUBSCRIPTIONS_SET", + payload: newSubscriptions, + }) + } + + const removeSubscription = (path: string) => { + const idx = state.subscriptions.indexOf(path) + + if (idx < 0) return; + + const newSubscriptions = [ + ...state.subscriptions.slice(0, idx), + ...state.subscriptions.slice(idx + 1), + ] + + sendSubscriptions(newSubscriptions) + + dispatch({ + type: "SUBSCRIPTIONS_SET", + payload: newSubscriptions, + }) + } + + const clearSubscriptions = () => { + sendSubscriptions([]) + + dispatch({ + type: "SUBSCRIPTIONS_SET", + payload: [], + }) + } + + return { + subscriptions: state.subscriptions, + addSubscription, + removeSubscription, + clearSubscriptions, + } +} + +export default useSubscriptions diff --git a/src/contexts/Timeline/index.tsx b/src/contexts/Timeline/index.tsx new file mode 100644 index 00000000..ce59cd61 --- /dev/null +++ b/src/contexts/Timeline/index.tsx @@ -0,0 +1,72 @@ +import React, { FunctionComponent } from "react" + +import { CommandType } from "../../types" + +import useTimeline from "./useTimeline" + +interface Context { + isSearchOpen: boolean + toggleSearch: () => void + search: string + setSearch: (search: string) => void + isFilterOpen: boolean + openFilter: () => void + closeFilter: () => void + isReversed: boolean + toggleReverse: () => void + hiddenCommands: CommandType[] + setHiddenCommands: (commandTypes: CommandType[]) => void +} + +const TimelineContext = React.createContext({ + isSearchOpen: false, + toggleSearch: null, + search: "", + setSearch: null, + isFilterOpen: false, + openFilter: null, + closeFilter: null, + isReversed: false, + toggleReverse: null, + hiddenCommands: [], + setHiddenCommands: null, +}) + +const Provider: FunctionComponent = ({ children }) => { + const { + isSearchOpen, + toggleSearch, + search, + setSearch, + isFilterOpen, + openFilter, + closeFilter, + isReversed, + toggleReverse, + hiddenCommands, + setHiddenCommands, + } = useTimeline() + + return ( + + {children} + + ) +} + +export default TimelineContext +export const TimelineProvider = Provider diff --git a/src/contexts/Timeline/useTimeline.test.ts b/src/contexts/Timeline/useTimeline.test.ts new file mode 100644 index 00000000..3fc05852 --- /dev/null +++ b/src/contexts/Timeline/useTimeline.test.ts @@ -0,0 +1,114 @@ +import { renderHook } from "@testing-library/react-hooks" + +import { CommandType } from "../../types" + +import useTimline, { StorageKey } from "./useTimeline" + +describe("contexts/Timline/useTimeline", () => { + beforeEach(() => { + localStorage.removeItem(StorageKey.ReversedOrder) + localStorage.removeItem(StorageKey.HiddenCommands) + }) + + describe("Initial Settings", () => { + it("should default to regular order", () => { + const { result } = renderHook(() => useTimline()) + + expect(result.current.isReversed).toBeFalsy() + }) + + it("should load if user had the timeline reversed", () => { + localStorage.setItem(StorageKey.ReversedOrder, "reversed") + + const { result } = renderHook(() => useTimline()) + + expect(result.current.isReversed).toBeTruthy() + }) + + it("should load if user had the timeline regular order", () => { + localStorage.setItem(StorageKey.ReversedOrder, "regular") + + const { result } = renderHook(() => useTimline()) + + expect(result.current.isReversed).toBeFalsy() + }) + + it("should default to no hidden commands", () => { + const { result } = renderHook(() => useTimline()) + + expect(result.current.hiddenCommands).toEqual([]) + }) + + it("should have saved hidden commands", () => { + localStorage.setItem(StorageKey.HiddenCommands, JSON.stringify(["test"])) + + const { result } = renderHook(() => useTimline()) + + expect(result.current.hiddenCommands).toEqual(["test"]) + }) + }) + + describe("actions", () => { + it("should toggle search", () => { + const { result } = renderHook(() => useTimline()) + + expect(result.current.isSearchOpen).toBeFalsy() + result.current.toggleSearch() + expect(result.current.isSearchOpen).toBeTruthy() + result.current.toggleSearch() + expect(result.current.isSearchOpen).toBeFalsy() + }) + + it("should set the search string", () => { + const { result } = renderHook(() => useTimline()) + + expect(result.current.search).toEqual("") + result.current.setSearch("H") + expect(result.current.search).toEqual("H") + result.current.setSearch("L") + expect(result.current.search).toEqual("L") + result.current.setSearch("") + expect(result.current.search).toEqual("") + }) + + it("should open the filter", () => { + const { result } = renderHook(() => useTimline()) + + expect(result.current.isFilterOpen).toBeFalsy() + result.current.openFilter() + expect(result.current.isFilterOpen).toBeTruthy() + }) + + it("should close the filter", () => { + const { result } = renderHook(() => useTimline()) + + result.current.openFilter() + expect(result.current.isFilterOpen).toBeTruthy() + result.current.closeFilter() + expect(result.current.isFilterOpen).toBeFalsy() + }) + + it("should toggle reverse", () => { + const { result } = renderHook(() => useTimline()) + + expect(result.current.isReversed).toBeFalsy() + result.current.toggleReverse() + expect(localStorage.getItem(StorageKey.ReversedOrder)).toEqual("reversed") + expect(result.current.isReversed).toBeTruthy() + result.current.toggleReverse() + expect(localStorage.getItem(StorageKey.ReversedOrder)).toEqual("regular") + expect(result.current.isReversed).toBeFalsy() + }) + + it("should set hidden commands", () => { + const { result } = renderHook(() => useTimline()) + + expect(result.current.hiddenCommands).toEqual([]) + result.current.setHiddenCommands([CommandType.ClientIntro]) + expect(localStorage.getItem(StorageKey.HiddenCommands)).toEqual( + JSON.stringify([CommandType.ClientIntro]) + ) + expect(result.current.hiddenCommands).toEqual([CommandType.ClientIntro]) + }) + }) +}) diff --git a/src/contexts/Timeline/useTimeline.ts b/src/contexts/Timeline/useTimeline.ts new file mode 100644 index 00000000..ce46b86e --- /dev/null +++ b/src/contexts/Timeline/useTimeline.ts @@ -0,0 +1,155 @@ +import { useReducer, useEffect } from "react" + +import { CommandType } from "../../types" + +export enum StorageKey { + ReversedOrder = "ReactotronTimelineReversedOrder", + HiddenCommands = "ReactotronTimelineHiddenCommands", +} + +interface TimelineState { + isSearchOpen: boolean + search: string + isFilterOpen: boolean + isReversed: boolean + hiddenCommands: CommandType[] +} + +enum TimelineActionType { + SearchOpen = "SEARCH_OPEN", + SearchClose = "SEARCH_CLOSE", + SearchSet = "SEARCH_SET", + FilterOpen = "FILTER_OPEN", + FilterClose = "FILTER_CLOSE", + OrderReverse = "ORDER_REVERSE", + OrderRegular = "ORDER_REGULAR", + HiddenCommandsSet = "HIDDENCOMMANDS_SET", +} + +type Action = + | { + type: + | TimelineActionType.SearchOpen + | TimelineActionType.SearchClose + | TimelineActionType.FilterOpen + | TimelineActionType.FilterClose + | TimelineActionType.OrderReverse + | TimelineActionType.OrderRegular + } + | { + type: TimelineActionType.SearchSet + payload: string + } + | { + type: TimelineActionType.HiddenCommandsSet + payload: CommandType[] + } + +function timelineReducer(state: TimelineState, action: Action) { + switch (action.type) { + case TimelineActionType.SearchOpen: + return { ...state, isSearchOpen: true } + case TimelineActionType.SearchClose: + return { ...state, isSearchOpen: false } + case TimelineActionType.SearchSet: + return { ...state, search: action.payload } + case TimelineActionType.FilterOpen: + return { ...state, isFilterOpen: true } + case TimelineActionType.FilterClose: + return { ...state, isFilterOpen: false } + case TimelineActionType.OrderReverse: + return { ...state, isReversed: true } + case TimelineActionType.OrderRegular: + return { ...state, isReversed: false } + case TimelineActionType.HiddenCommandsSet: + return { ...state, hiddenCommands: action.payload } + default: + return state + } +} + +function useTimeline() { + const [state, dispatch] = useReducer(timelineReducer, { + isSearchOpen: false, + search: "", + isFilterOpen: false, + isReversed: false, + hiddenCommands: [], + }) + + // Load some values + useEffect(() => { + const isReversed = localStorage.getItem(StorageKey.ReversedOrder) === "reversed" + const hiddenCommands = JSON.parse(localStorage.getItem(StorageKey.HiddenCommands) || "[]") + + dispatch({ + type: isReversed ? TimelineActionType.OrderReverse : TimelineActionType.OrderRegular, + }) + + dispatch({ + type: TimelineActionType.HiddenCommandsSet, + payload: hiddenCommands, + }) + }, []) + + // Setup event handlers + const toggleSearch = () => { + dispatch({ + type: state.isSearchOpen ? TimelineActionType.SearchClose : TimelineActionType.SearchOpen, + }) + } + + const setSearch = (search: string) => { + dispatch({ + type: TimelineActionType.SearchSet, + payload: search, + }) + } + + const openFilter = () => { + dispatch({ + type: TimelineActionType.FilterOpen, + }) + } + + const closeFilter = () => { + dispatch({ + type: TimelineActionType.FilterClose, + }) + } + + const toggleReverse = () => { + const isReversed = !state.isReversed + + localStorage.setItem(StorageKey.ReversedOrder, isReversed ? "reversed" : "regular") + + dispatch({ + type: isReversed ? TimelineActionType.OrderReverse : TimelineActionType.OrderRegular, + }) + } + + const setHiddenCommands = (hiddenCommands: CommandType[]) => { + localStorage.setItem(StorageKey.HiddenCommands, JSON.stringify(hiddenCommands)) + + dispatch({ + type: TimelineActionType.HiddenCommandsSet, + payload: hiddenCommands, + }) + } + + return { + isSearchOpen: state.isSearchOpen, + toggleSearch, + search: state.search, + setSearch, + isFilterOpen: state.isFilterOpen, + openFilter, + closeFilter, + isReversed: state.isReversed, + toggleReverse, + hiddenCommands: state.hiddenCommands, + setHiddenCommands, + } +} + +export default useTimeline diff --git a/src/index.ts b/src/index.ts index a63ef429..0fed8d0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,17 +3,26 @@ import theme from "./theme" // Components import ContentView from "./components/ContentView" +import EmptyState from "./components/EmptyState" import Header from "./components/Header" import Modal from "./components/Modal" -import ReactotronProvider from "./components/ReactotronProvider" +import ReactotronAppProvider from "./components/ReactotronAppProvider" import ActionButton from "./components/ActionButton" import TimelineCommand from "./components/TimelineCommand" import TimelineCommandTabButton from "./components/TimelineCommandTabButton" import Timestamp from "./components/Timestamp" import TreeView from "./components/TreeView" +// Contexts +import ReactotronContext, { ReactotronProvider } from "./contexts/Reactotron" +import CustomCommandsContext, { CustomCommandsProvider } from "./contexts/CustomCommands" +import ReactNativeContext, { ReactNativeProvider } from "./contexts/ReactNative" +import StateContext, { StateProvider } from "./contexts/State" +import TimelineContext, { TimelineProvider } from "./contexts/Timeline" + // Modals import DispatchActionModal from "./modals/DispatchActionModal" +import SnapshotRenameModal from "./modals/SnapshotRenameModal" import SubscriptionAddModal from "./modals/SubscriptionAddModal" import TimelineFilterModal from "./modals/TimelineFilterModal" @@ -30,19 +39,32 @@ import { CommandType } from "./types" export { theme, ContentView, - DispatchActionModal, + EmptyState, Header, Modal, - ReactotronProvider, - SubscriptionAddModal, + ReactotronAppProvider, ActionButton, TimelineCommand, timelineCommandResolver, TimelineCommandTabButton, + DispatchActionModal, + SnapshotRenameModal, + SubscriptionAddModal, TimelineFilterModal, Timestamp, TreeView, repairSerialization, filterCommands, CommandType, + // Contexts + ReactotronContext, + ReactotronProvider, + CustomCommandsContext, + CustomCommandsProvider, + ReactNativeContext, + ReactNativeProvider, + StateContext, + StateProvider, + TimelineContext, + TimelineProvider, } diff --git a/src/modals/DispatchActionModal/DispatchActionModal.story.tsx b/src/modals/DispatchActionModal/DispatchActionModal.story.tsx index d5effa10..03e4d12a 100644 --- a/src/modals/DispatchActionModal/DispatchActionModal.story.tsx +++ b/src/modals/DispatchActionModal/DispatchActionModal.story.tsx @@ -7,6 +7,10 @@ export default { title: "Dispatch Action Modal", } -export const Default = () => ( - {}} onDispatchAction={() => {}} /> +export const Darwin = () => ( + {}} onDispatchAction={() => {}} isDarwin /> +) + +export const NonDarwin = () => ( + {}} onDispatchAction={() => {}} isDarwin={false} /> ) diff --git a/src/modals/DispatchActionModal/index.tsx b/src/modals/DispatchActionModal/index.tsx index c34e53d2..23cb6b0a 100644 --- a/src/modals/DispatchActionModal/index.tsx +++ b/src/modals/DispatchActionModal/index.tsx @@ -3,6 +3,11 @@ import styled from "styled-components" import Modal, { KeystrokeContainer, Keystroke } from "../../components/Modal" +const KEY_MAPS = { + command: "⌘", + ctrl: "CTRL", +} + const InstructionText = styled.div` text-align: left; color: ${props => props.theme.foreground}; @@ -36,6 +41,7 @@ interface Props { initialValue?: string onClose: () => void onDispatchAction: (action: string) => void + isDarwin: boolean } const DispatchActionModal: FunctionComponent = ({ @@ -43,6 +49,7 @@ const DispatchActionModal: FunctionComponent = ({ initialValue, onClose, onDispatchAction, + isDarwin, }) => { const [prevIsOpen, setPrevIsOpen] = useState(isOpen) const [action, setAction] = useState("") @@ -86,7 +93,7 @@ const DispatchActionModal: FunctionComponent = ({ onAfterOpen={handleAfterOpen} additionalKeystrokes={ - ENTER Dispatch + {isDarwin ? KEY_MAPS.command : KEY_MAPS.ctrl} + ENTER Dispatch } > diff --git a/src/modals/SnapshotRenameModal/SnapshotRenameModal.story.tsx b/src/modals/SnapshotRenameModal/SnapshotRenameModal.story.tsx new file mode 100644 index 00000000..3c9d0968 --- /dev/null +++ b/src/modals/SnapshotRenameModal/SnapshotRenameModal.story.tsx @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import React from "react" + +import SnapshotRenameModal from "./index" + +export default { + title: "Snapshot Rename Modal", +} + +export const Default = () => ( + {}} + onRenameSnapshot={() => {}} + /> +) diff --git a/src/modals/SnapshotRenameModal/index.tsx b/src/modals/SnapshotRenameModal/index.tsx new file mode 100644 index 00000000..159861af --- /dev/null +++ b/src/modals/SnapshotRenameModal/index.tsx @@ -0,0 +1,90 @@ +import React, { FunctionComponent, useRef, useState, useCallback } from "react" +import styled from "styled-components" + +import Modal, { KeystrokeContainer, Keystroke } from "../../components/Modal" + +const NameContainer = styled.div` + display: flex; + flex-direction: column; + padding: 15px; +` +const NameLabel = styled.label` + font-size: 13px; + color: ${props => props.theme.heading}; +` +const NameInput = styled.input` + border: 0; + border-bottom: 1px solid ${props => props.theme.line}; + font-size: 25px; + color: ${props => props.theme.foregroundLight}; + line-height: 40px; + background-color: inherit; +` + +interface Props { + snapshot: any // TODO: Type this better when we sort out the typings + isOpen: boolean + onClose: () => void + onRenameSnapshot: (name: string) => void +} + +const SnapshotAddModal: FunctionComponent = ({ + snapshot, + isOpen, + onClose, + onRenameSnapshot, +}) => { + const [name, setName] = useState("") + const inputRef = useRef(null) + + const handleAfterOpen = () => { + setName((snapshot || {}).name || "") + inputRef.current && inputRef.current.focus() + } + + const handleClose = useCallback(() => { + setName("") + onClose() + }, []) + + const handleChange = useCallback(e => { + setName(e.target.value) + }, []) + + const handleKeypress = useCallback( + e => { + if (e.key === "Enter") { + onRenameSnapshot(name) + setName("") + } + }, + [snapshot, name] + ) + + return ( + + ENTER Save + + } + > + + NAME + + + + ) +} + +export default SnapshotAddModal diff --git a/src/timelineCommands/BaseCommand.tsx b/src/timelineCommands/BaseCommand.tsx index b58f562e..01922610 100644 --- a/src/timelineCommands/BaseCommand.tsx +++ b/src/timelineCommands/BaseCommand.tsx @@ -13,7 +13,7 @@ export interface TimelineCommandPropsEx { } copyToClipboard?: (text: string) => void readFile?: (path: string) => void - sendCommand?: (command: any) => void + sendCommand?: (type: string, payload: any, clientId?: string) => void openDispatchDialog?: (action: string) => void dispatchAction?: (action: any) => void } @@ -27,6 +27,7 @@ export function buildTimelineCommand( Component: FunctionComponent>, startOpen = false ) { + // eslint-disable-next-line react/display-name return (props: TimelineCommandPropsEx) => { const [isOpen, setIsOpen] = useState(startOpen) diff --git a/src/timelineCommands/LogCommand/index.tsx b/src/timelineCommands/LogCommand/index.tsx index 034a013e..7747c8c4 100644 --- a/src/timelineCommands/LogCommand/index.tsx +++ b/src/timelineCommands/LogCommand/index.tsx @@ -201,7 +201,7 @@ function getPreview(message: string | object | boolean | number) { return String(message) } - return message; + return message } function useFileSource(stack, readFile) { @@ -313,12 +313,9 @@ const LogCommand: FunctionComponent = ({ const openInEditor = (file, lineNumber) => { if (file === "") return - sendCommand({ - type: "editor.open", - payload: { - file, - lineNumber, - }, + sendCommand("editor.open", { + file, + lineNumber, }) } @@ -346,6 +343,7 @@ const LogCommand: FunctionComponent = ({ {source.lines.map(line => { return ( { openInEditor(source.fileName, source.lineNumber) diff --git a/src/timelineCommands/StateKeysResponseCommand/index.tsx b/src/timelineCommands/StateKeysResponseCommand/index.tsx index f61177a8..af10f3d3 100644 --- a/src/timelineCommands/StateKeysResponseCommand/index.tsx +++ b/src/timelineCommands/StateKeysResponseCommand/index.tsx @@ -30,23 +30,32 @@ interface StateKeysResponsePayload { interface Props extends TimelineCommandProps {} -function buildClickHandler(key: string, currentPath: string, sendCommand: (command: any) => void) { +function buildClickHandler( + key: string, + currentPath: string, + sendCommand: (type: string, payload: any, clientId?: string) => void +) { return () => { - sendCommand({ - type: "state.values.request", - payload: { path: `${currentPath ? `${currentPath}.` : ""}${key}` }, - }) + sendCommand("state.values.request", { path: `${currentPath ? `${currentPath}.` : ""}${key}` }) } } -function renderKeys(keys: string[], currentPath: string, sendCommand: (command: any) => void) { +function renderKeys( + keys: string[], + currentPath: string, + sendCommand: (type: string, payload: any, clientId?: string) => void +) { if (!keys) return ¯\_(ツ)_/¯ if (keys.length === 0) return Sorry, no keys in there. return ( {keys.map(key => { - return {key} + return ( + + {key} + + ) })} ) diff --git a/src/types.ts b/src/types.ts index 2f85cc5d..0c3aab3d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,4 +11,19 @@ export enum CommandType { StateKeysResponse = "state.keys.response", StateValuesChange = "state.values.change", StateValuesResponse = "state.values.response", + StateBackupResponse = "state.backup.response", + CustomCommandRegister = "customCommand.register", + CustomCommandUnregister = "customCommand.unregister", +} + +export interface Command { + id: number + type: CommandType + connectionId: number + clientId?: string + date: Date + deltaTime: number + important: boolean + messageId: number + payload: any } diff --git a/src/utils/makeTable.tsx b/src/utils/makeTable.tsx index 4938d7f6..e5865889 100644 --- a/src/utils/makeTable.tsx +++ b/src/utils/makeTable.tsx @@ -49,6 +49,7 @@ function textForValue(value: any) { return value } +// eslint-disable-next-line react/display-name export default (obj: any) => (
{Object.keys(obj).map(key => { diff --git a/tsconfig.json b/tsconfig.json index 9c74f734..bea138b3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "declarationDir": "dist/types", "emitDeclarationOnly": true, "emitDecoratorMetadata": true, + "esModuleInterop": true, "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "module": "es2015", diff --git a/wallaby.js b/wallaby.js deleted file mode 100644 index d7a40a67..00000000 --- a/wallaby.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = function(wallaby) { - return { - files: ["src/**/*.ts", "!src/**/*.test.ts"], - - tests: ["src/**/*.test.ts"], - - compilers: { - "**/*.ts": wallaby.compilers.babel(), - }, - - env: { - type: "node", - runner: "node", - }, - - testFramework: "jest", - } -} diff --git a/yarn.lock b/yarn.lock index 64165a53..92d6b344 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1143,6 +1143,13 @@ dependencies: regenerator-runtime "^0.13.2" +"@babel/runtime@^7.5.4", "@babel/runtime@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.3.tgz#0811944f73a6c926bb2ad35e918dcc1bfab279f1" + integrity sha512-fVHx1rzEmwB130VTkLnxR+HmxcTjGzH12LYQcFFoBwakMd3aOMD4OsRN7tGG/UOYE2ektgFrS8uACAoRk1CY0w== + dependencies: + regenerator-runtime "^0.13.2" + "@babel/runtime@^7.7.2": version "7.7.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.6.tgz#d18c511121aff1b4f2cd1d452f1bac9601dd830f" @@ -1150,13 +1157,6 @@ dependencies: regenerator-runtime "^0.13.2" -"@babel/runtime@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.3.tgz#0811944f73a6c926bb2ad35e918dcc1bfab279f1" - integrity sha512-fVHx1rzEmwB130VTkLnxR+HmxcTjGzH12LYQcFFoBwakMd3aOMD4OsRN7tGG/UOYE2ektgFrS8uACAoRk1CY0w== - dependencies: - regenerator-runtime "^0.13.2" - "@babel/template@^7.1.0", "@babel/template@^7.4.4", "@babel/template@^7.6.0": version "7.6.0" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.6.0.tgz#7f0159c7f5012230dad64cca42ec9bdb5c9536e6" @@ -2417,6 +2417,14 @@ dependencies: defer-to-connect "^1.0.1" +"@testing-library/react-hooks@3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-3.2.1.tgz#19b6caa048ef15faa69d439c469033873ea01294" + integrity sha512-1OB6Ksvlk6BCJA1xpj8/WWz0XVd1qRcgqdaFAq+xeC6l61Ucj0P6QpA5u+Db/x9gU4DCX8ziR5b66Mlfg0M2RA== + dependencies: + "@babel/runtime" "^7.5.4" + "@types/testing-library__react-hooks" "^3.0.0" + "@tootallnate/once@1": version "1.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.0.0.tgz#9c13c2574c92d4503b005feca8f2e16cc1611506" @@ -2605,6 +2613,13 @@ dependencies: "@types/react" "*" +"@types/react-test-renderer@*": + version "16.9.2" + resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-16.9.2.tgz#e1c408831e8183e5ad748fdece02214a7c2ab6c5" + integrity sha512-4eJr1JFLIAlWhzDkBCkhrOIWOvOxcCAfQh+jiKg7l/nNZcCIL2MHl2dZhogIFKyHzedVWHaVP1Yydq/Ruu4agw== + dependencies: + "@types/react" "*" + "@types/react-textarea-autosize@^4.3.3": version "4.3.4" resolved "https://registry.yarnpkg.com/@types/react-textarea-autosize/-/react-textarea-autosize-4.3.4.tgz#9a93f751c91ad5e86387bce75e3b7e11ed195813" @@ -2659,6 +2674,14 @@ "@types/react-native" "*" csstype "^2.2.0" +"@types/testing-library__react-hooks@^3.0.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.2.0.tgz#52f3a109bef06080e3b1e3ae7ea1c014ce859897" + integrity sha512-dE8iMTuR5lzB+MqnxlzORlXzXyCL0EKfzH0w/lau20OpkHD37EaWjZDz0iNG8b71iEtxT4XKGmSKAGVEqk46mw== + dependencies: + "@types/react" "*" + "@types/react-test-renderer" "*" + "@types/webpack-env@^1.15.0": version "1.15.0" resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.15.0.tgz#bd9956d5044b1fb43e869a9ba9148862ff98d9fd" @@ -3228,6 +3251,15 @@ array-includes@^3.0.3: define-properties "^1.1.2" es-abstract "^1.7.0" +array-includes@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348" + integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0" + is-string "^1.0.5" + array-union@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" @@ -5553,6 +5585,13 @@ doctrine@1.5.0: esutils "^2.0.2" isarray "^1.0.0" +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + doctrine@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" @@ -6060,6 +6099,26 @@ eslint-plugin-promise@4.2.1: resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a" integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw== +eslint-plugin-react-hooks@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-2.3.0.tgz#53e073961f1f5ccf8dd19558036c1fac8c29d99a" + integrity sha512-gLKCa52G4ee7uXzdLiorca7JIQZPPXRAQDXV83J4bUEeUuc5pIEyZYAZ45Xnxe5IuupxEqHS+hUhSLIimK1EMw== + +eslint-plugin-react@^7.18.0: + version "7.18.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.18.0.tgz#2317831284d005b30aff8afb7c4e906f13fa8e7e" + integrity sha512-p+PGoGeV4SaZRDsXqdj9OWcOrOpZn8gXoGPcIQTzo2IDMbAKhNDnME9myZWqO3Ic4R3YmwAZ1lDjWl2R2hMUVQ== + dependencies: + array-includes "^3.1.1" + doctrine "^2.1.0" + has "^1.0.3" + jsx-ast-utils "^2.2.3" + object.entries "^1.1.1" + object.fromentries "^2.0.2" + object.values "^1.1.1" + prop-types "^15.7.2" + resolve "^1.14.2" + eslint-plugin-standard@4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz#ff0519f7ffaff114f76d1bd7c3996eef0f6e20b4" @@ -8214,6 +8273,11 @@ is-stream@^2.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== +is-string@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6" + integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== + is-symbol@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" @@ -8968,6 +9032,14 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jsx-ast-utils@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz#8a9364e402448a3ce7f14d357738310d9248054f" + integrity sha512-EdIHFMm+1BPynpKOpdPqiOsvnIrInRGJD7bzPZdPkjitQEqpdpUuFpq4T0npZFKTiB3RhWFdGN+oqOJIdhDhQA== + dependencies: + array-includes "^3.0.3" + object.assign "^4.1.0" + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -10739,6 +10811,16 @@ object.entries@^1.1.0: function-bind "^1.1.1" has "^1.0.3" +object.entries@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.1.tgz#ee1cf04153de02bb093fec33683900f57ce5399b" + integrity sha512-ilqR7BgdyZetJutmDPfXCDffGa0/Yzl2ivVNpbx/g4UeWrCdRnFDUBrKJGLhGieRHDATnyZXWBeCb29k9CJysQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + has "^1.0.3" + "object.fromentries@^2.0.0 || ^1.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.1.tgz#050f077855c7af8ae6649f45c80b16ee2d31e704" @@ -10749,6 +10831,16 @@ object.entries@^1.1.0: function-bind "^1.1.1" has "^1.0.3" +object.fromentries@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9" + integrity sha512-r3ZiBH7MQppDJVLx6fhD618GKNG40CZYH9wgwdhKxBDDbQgjeWGGd4AtkZad84d291YxvWe7bJGuE65Anh0dxQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + has "^1.0.3" + object.getownpropertydescriptors@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16" @@ -10774,6 +10866,16 @@ object.values@^1.1.0: function-bind "^1.1.1" has "^1.0.3" +object.values@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e" + integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + has "^1.0.3" + octokit-pagination-methods@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4" @@ -12098,7 +12200,7 @@ react-inspector@^4.0.0: prop-types "^15.6.1" storybook-chromatic "^2.2.2" -react-is@^16.12.0: +react-is@^16.12.0, react-is@^16.8.6: version "16.12.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== @@ -12196,6 +12298,16 @@ react-syntax-highlighter@^11.0.2: prismjs "^1.8.4" refractor "^2.4.1" +react-test-renderer@16.12.0: + version "16.12.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.12.0.tgz#11417ffda579306d4e841a794d32140f3da1b43f" + integrity sha512-Vj/teSqt2oayaWxkbhQ6gKis+t5JrknXfPVo+aIJ8QwYAqMPH77uptOdrlphyxl8eQI/rtkOYg86i/UWkpFu0w== + dependencies: + object-assign "^4.1.1" + prop-types "^15.6.2" + react-is "^16.8.6" + scheduler "^0.18.0" + react-textarea-autosize@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-7.1.0.tgz#3132cb77e65d94417558d37c0bfe415a5afd3445" @@ -12768,6 +12880,13 @@ resolve@^1.12.0: dependencies: path-parse "^1.0.6" +resolve@^1.14.2: + version "1.15.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5" + integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw== + dependencies: + path-parse "^1.0.6" + responselike@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"