Refactor to just CommandBar and GlobalState (#337)

* Refactor to just CommandBar and GlobalState

* @Irev-Dev review: consolidate uses of useContext
This commit is contained in:
Frank Noirot
2023-08-29 10:48:55 -04:00
committed by GitHub
parent 32d928ae0c
commit 152108f7a5
20 changed files with 297 additions and 255 deletions

View File

@ -2,7 +2,8 @@ import { render, screen } from '@testing-library/react'
import { App } from './App'
import { describe, test, vi } from 'vitest'
import { BrowserRouter } from 'react-router-dom'
import { GlobalStateProvider } from './hooks/useAuthMachine'
import { GlobalStateProvider } from './components/GlobalStateProvider'
import CommandBarProvider from 'components/CommandBar'
let listener: ((rect: any) => void) | undefined = undefined
;(global as any).ResizeObserver = class ResizeObserver {
@ -43,7 +44,9 @@ function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
</CommandBarProvider>
</BrowserRouter>
)
}

View File

@ -5,7 +5,6 @@ import {
useMemo,
useCallback,
MouseEventHandler,
useContext,
} from 'react'
import { DebugPanel } from './components/DebugPanel'
import { v4 as uuidv4 } from 'uuid'
@ -49,8 +48,7 @@ import { writeTextFile } from '@tauri-apps/api/fs'
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
import { IndexLoaderData } from './Router'
import { toast } from 'react-hot-toast'
import { useAuthMachine } from './hooks/useAuthMachine'
import { SettingsContext } from 'components/SettingsCommandProvider'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
export function App() {
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
@ -129,10 +127,15 @@ export function App() {
setStreamDimensions: s.setStreamDimensions,
streamDimensions: s.streamDimensions,
}))
const { showDebugPanel, theme, onboardingStatus } =
useContext(SettingsContext)
const [token] = useAuthMachine((s) => s?.context?.token)
const {
auth: {
context: { token },
},
settings: {
context: { showDebugPanel, theme, onboardingStatus },
},
} = useGlobalStateContext()
const editorTheme = theme === Themes.System ? getSystemTheme() : theme

View File

@ -1,9 +1,12 @@
import Loading from './components/Loading'
import { useAuthMachine } from './hooks/useAuthMachine'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
// Wrapper around protected routes, used in src/Router.tsx
export const Auth = ({ children }: React.PropsWithChildren) => {
const [isLoggedIn] = useAuthMachine((s) => s.matches('checkIfLoggedIn'))
const {
auth: { state },
} = useGlobalStateContext()
const isLoggedIn = state.matches('checkIfLoggedIn')
return isLoggedIn ? (
<Loading>Loading KittyCAD Modeling App...</Loading>

View File

@ -24,16 +24,13 @@ import {
} from './lib/tauriFS'
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
import DownloadAppBanner from './components/DownloadAppBanner'
import {
AuthMachineCommandProvider,
GlobalStateProvider,
} from './hooks/useAuthMachine'
import SettingsCommandProvider from './components/SettingsCommandProvider'
import { GlobalStateProvider } from './components/GlobalStateProvider'
import {
SETTINGS_PERSIST_KEY,
settingsMachine,
} from './machines/settingsMachine'
import { ContextFrom } from 'xstate'
import CommandBarProvider from 'components/CommandBar'
const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => {
@ -78,13 +75,9 @@ const addGlobalContextToElements = (
? {
...route,
element: (
<GlobalStateProvider>
<AuthMachineCommandProvider>
<SettingsCommandProvider>
{route.element}
</SettingsCommandProvider>
</AuthMachineCommandProvider>
</GlobalStateProvider>
<CommandBarProvider>
<GlobalStateProvider>{route.element}</GlobalStateProvider>
</CommandBarProvider>
),
}
: route

View File

@ -2,7 +2,7 @@ import { Toolbar } from '../Toolbar'
import UserSidebarMenu from './UserSidebarMenu'
import { ProjectWithEntryPointMetadata } from '../Router'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { useAuthMachine } from '../hooks/useAuthMachine'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean
@ -18,7 +18,11 @@ export const AppHeader = ({
className = '',
enableMenu = false,
}: AppHeaderProps) => {
const [user] = useAuthMachine((s) => s?.context?.user)
const {
auth: {
context: { user },
},
} = useGlobalStateContext()
return (
<header

View File

@ -4,7 +4,6 @@ import {
Fragment,
SetStateAction,
createContext,
useContext,
useState,
} from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
@ -12,6 +11,7 @@ import { ActionIcon } from './ActionIcon'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import Fuse from 'fuse.js'
import { Command, SubCommand } from '../lib/commands'
import { useCommandsContext } from 'hooks/useCommandsContext'
export type SortedCommand = {
item: Partial<Command | SubCommand> & { name: string }
@ -27,9 +27,41 @@ export const CommandsContext = createContext(
}
)
export const CommandBarProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [commands, internalSetCommands] = useState([] as Command[])
const [commandBarOpen, setCommandBarOpen] = useState(false)
const addCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) => [...newCommands, ...prevCommands])
}
const removeCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) =>
prevCommands.filter((command) => !newCommands.includes(command))
)
}
return (
<CommandsContext.Provider
value={{
commands,
addCommands,
removeCommands,
commandBarOpen,
setCommandBarOpen,
}}
>
{children}
<CommandBar />
</CommandsContext.Provider>
)
}
const CommandBar = () => {
const { commands, commandBarOpen, setCommandBarOpen } =
useContext(CommandsContext)
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
useHotkeys('meta+k', () => {
if (commands.length === 0) return
setCommandBarOpen(!commandBarOpen)
@ -255,4 +287,4 @@ const CommandBar = () => {
)
}
export default CommandBar
export default CommandBarProvider

View File

@ -0,0 +1,147 @@
import { useMachine } from '@xstate/react'
import { useNavigate } from 'react-router-dom'
import { paths } from '../Router'
import {
authCommandBarMeta,
authMachine,
TOKEN_PERSIST_KEY,
} from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect, useRef } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import {
SETTINGS_PERSIST_KEY,
settingsCommandBarMeta,
settingsMachine,
} from 'machines/settingsMachine'
import { toast } from 'react-hot-toast'
import { setThemeClass } from 'lib/theme'
import {
AnyStateMachine,
ContextFrom,
InterpreterFrom,
Prop,
StateFrom,
} from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
context: ContextFrom<T>
send: Prop<InterpreterFrom<T>, 'send'>
}
type GlobalContext = {
auth: MachineContext<typeof authMachine>
settings: MachineContext<typeof settingsMachine>
}
export const GlobalStateContext = createContext({} as GlobalContext)
export const GlobalStateProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const navigate = useNavigate()
const { commands } = useCommandsContext()
// Settings machine setup
const retrievedSettings = useRef(
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
)
const persistedSettings = Object.assign(
settingsMachine.initialState.context,
JSON.parse(retrievedSettings.current) as Partial<
(typeof settingsMachine)['context']
>
)
const [settingsState, settingsSend] = useMachine(settingsMachine, {
context: persistedSettings,
actions: {
toastSuccess: (context, event) => {
const truncatedNewValue =
'data' in event && event.data instanceof Object
? (context[Object.keys(event.data)[0] as keyof typeof context]
.toString()
.substring(0, 28) as any)
: undefined
toast.success(
event.type +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"`
: '')
)
},
},
})
useStateMachineCommands({
state: settingsState,
send: settingsSend,
commands,
owner: 'settings',
commandBarMeta: settingsCommandBarMeta,
})
useEffect(
() => setThemeClass(settingsState.context.theme),
[settingsState.context.theme]
)
// Auth machine setup
const [authState, authSend] = useMachine(authMachine, {
actions: {
goToSignInPage: () => {
navigate(paths.SIGN_IN)
logout()
},
goToIndexPage: () => {
if (window.location.pathname.includes(paths.SIGN_IN)) {
navigate(paths.INDEX)
}
},
},
})
useStateMachineCommands({
state: authState,
send: authSend,
commands,
commandBarMeta: authCommandBarMeta,
owner: 'auth',
})
return (
<GlobalStateContext.Provider
value={{
auth: {
state: authState,
context: authState.context,
send: authSend,
},
settings: {
state: settingsState,
context: settingsState.context,
send: settingsSend,
},
}}
>
{children}
</GlobalStateContext.Provider>
)
}
export default GlobalStateProvider
export function logout() {
const url = withBaseUrl('/logout')
localStorage.removeItem(TOKEN_PERSIST_KEY)
return fetch(url, {
method: 'POST',
credentials: 'include',
})
}

View File

@ -1,76 +0,0 @@
import {
SETTINGS_PERSIST_KEY,
settingsCommandBarMeta,
settingsMachine,
} from '../machines/settingsMachine'
import { useMachine } from '@xstate/react'
import { CommandsContext } from './CommandBar'
import { createContext, useContext, useEffect, useRef } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { setThemeClass } from '../lib/theme'
import { ContextFrom, InterpreterFrom, Prop } from 'xstate'
import { toast } from 'react-hot-toast'
export const SettingsContext = createContext(
{} as {
send: Prop<InterpreterFrom<typeof settingsMachine>, 'send'>
} & ContextFrom<typeof settingsMachine>
)
export default function SettingsCommandProvider({
children,
}: React.PropsWithChildren) {
const retrievedSettings = useRef(
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
)
const persistedSettings = Object.assign(
settingsMachine.initialState.context,
JSON.parse(retrievedSettings.current) as Partial<
(typeof settingsMachine)['context']
>
)
const [state, send] = useMachine(settingsMachine, {
context: persistedSettings,
actions: {
toastSuccess: (context, event) => {
const truncatedNewValue =
'data' in event && event.data instanceof Object
? (context[Object.keys(event.data)[0] as keyof typeof context]
.toString()
.substring(0, 28) as any)
: undefined
toast.success(
event.type +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"`
: '')
)
},
},
})
const { commands } = useContext(CommandsContext)
useStateMachineCommands({
state,
send,
commands,
owner: 'settings',
commandBarMeta: settingsCommandBarMeta,
})
useEffect(() => setThemeClass(state.context.theme), [state.context.theme])
return (
<SettingsContext.Provider
value={{
send,
...state.context,
}}
>
{children}
</SettingsContext.Provider>
)
}

View File

@ -2,7 +2,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
import UserSidebarMenu from './UserSidebarMenu'
import { BrowserRouter } from 'react-router-dom'
import { Models } from '@kittycad/lib'
import { GlobalStateProvider } from '../hooks/useAuthMachine'
import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar'
type User = Models['User_type']
@ -94,7 +95,9 @@ function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
</CommandBarProvider>
</BrowserRouter>
)
}

View File

@ -6,8 +6,8 @@ import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { paths } from '../Router'
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
import { useAuthMachine } from '../hooks/useAuthMachine'
import { Models } from '@kittycad/lib'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
type User = Models['User_type']
@ -15,7 +15,9 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
const displayedName = getDisplayName(user)
const [imageLoadFailed, setImageLoadFailed] = useState(false)
const navigate = useNavigate()
const [_, send] = useAuthMachine()
const {
auth: { send },
} = useGlobalStateContext()
// Fallback logic for displaying user's "name":
// 1. user.name

View File

@ -1,104 +0,0 @@
import { createActorContext } from '@xstate/react'
import { useNavigate } from 'react-router-dom'
import { paths } from '../Router'
import {
authCommandBarMeta,
authMachine,
TOKEN_PERSIST_KEY,
} from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL'
import React, { useContext, useState } from 'react'
import CommandBar, { CommandsContext } from '../components/CommandBar'
import { Command } from '../lib/commands'
import useStateMachineCommands from './useStateMachineCommands'
export const AuthMachineContext = createActorContext(authMachine)
export const GlobalStateProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [commands, internalSetCommands] = useState([] as Command[])
const [commandBarOpen, setCommandBarOpen] = useState(false)
const navigate = useNavigate()
const addCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) => [...newCommands, ...prevCommands])
}
const removeCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) =>
prevCommands.filter((command) => !newCommands.includes(command))
)
}
return (
<AuthMachineContext.Provider
machine={() =>
authMachine.withConfig({
actions: {
goToSignInPage: () => {
navigate(paths.SIGN_IN)
logout()
},
goToIndexPage: () => {
if (window.location.pathname.includes(paths.SIGN_IN)) {
navigate(paths.INDEX)
}
},
},
})
}
>
<CommandsContext.Provider
value={{
commands,
addCommands,
removeCommands,
commandBarOpen,
setCommandBarOpen,
}}
>
{children}
<CommandBar />
</CommandsContext.Provider>
</AuthMachineContext.Provider>
)
}
export function useAuthMachine<T>(
selector: (
state: Parameters<Parameters<typeof AuthMachineContext.useSelector>[0]>[0]
) => T = () => null as T
): [T, ReturnType<typeof AuthMachineContext.useActor>[1]] {
// useActor api normally `[state, send] = useActor`
// we're only interested in send because of the selector
const send = AuthMachineContext.useActor()[1]
const selection = AuthMachineContext.useSelector(selector)
return [selection, send]
}
export function logout() {
const url = withBaseUrl('/logout')
localStorage.removeItem(TOKEN_PERSIST_KEY)
return fetch(url, {
method: 'POST',
credentials: 'include',
})
}
export function AuthMachineCommandProvider(props: React.PropsWithChildren<{}>) {
const [state, send] = AuthMachineContext.useActor()
const { commands } = useContext(CommandsContext)
useStateMachineCommands({
state,
send,
commands,
commandBarMeta: authCommandBarMeta,
owner: 'auth',
})
return <>{props.children}</>
}

View File

@ -0,0 +1,6 @@
import { CommandsContext } from 'components/CommandBar'
import { useContext } from 'react'
export const useCommandsContext = () => {
return useContext(CommandsContext)
}

View File

@ -0,0 +1,6 @@
import { GlobalStateContext } from 'components/GlobalStateProvider'
import { useContext } from 'react'
export const useGlobalStateContext = () => {
return useContext(GlobalStateContext)
}

View File

@ -1,7 +1,7 @@
import { useContext, useEffect } from 'react'
import { useEffect } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate'
import { Command, CommandBarMeta, createMachineCommand } from '../lib/commands'
import { CommandsContext } from '../components/CommandBar'
import { useCommandsContext } from './useCommandsContext'
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
state: StateFrom<T>
@ -17,7 +17,7 @@ export default function useStateMachineCommands<T extends AnyStateMachine>({
commandBarMeta,
owner,
}: UseStateMachineCommandsArgs<T>) {
const { addCommands, removeCommands } = useContext(CommandsContext)
const { addCommands, removeCommands } = useCommandsContext()
useEffect(() => {
const newCommands = state.nextEvents

View File

@ -1,10 +1,5 @@
import { useAuthMachine } from '../hooks/useAuthMachine'
export default async function fetcher<JSON = any>(
input: RequestInfo,
init: RequestInit = {}
): Promise<JSON> {
const [token] = useAuthMachine((s) => s?.context?.token)
export default function fetcher(input: RequestInfo, init: RequestInit = {}) {
const fetcherWithToken = async (token?: string): Promise<JSON> => {
const headers = { ...init.headers } as Record<string, string>
if (token) {
headers.Authorization = `Bearer ${token}`
@ -13,4 +8,6 @@ export default async function fetcher<JSON = any>(
const credentials = 'include' as RequestCredentials
const res = await fetch(input, { ...init, credentials, headers })
return res.json()
}
return fetcherWithToken
}

View File

@ -1,4 +1,4 @@
import { FormEvent, useContext, useEffect } from 'react'
import { FormEvent, useEffect } from 'react'
import { removeDir, renameFile } from '@tauri-apps/api/fs'
import {
createNewProject,
@ -25,17 +25,21 @@ import {
getSortFunction,
getSortIcon,
} from '../lib/sorting'
import { CommandsContext } from '../components/CommandBar'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { SettingsContext } from '../components/SettingsCommandProvider'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useCommandsContext } from 'hooks/useCommandsContext'
// This route only opens in the Tauri desktop context for now,
// as defined in Router.tsx, so we can use the Tauri APIs and types.
const Home = () => {
const { commands, setCommandBarOpen } = useContext(CommandsContext)
const { commands, setCommandBarOpen } = useCommandsContext()
const navigate = useNavigate()
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const { defaultDirectory, defaultProjectName } = useContext(SettingsContext)
const {
settings: {
context: { defaultDirectory, defaultProjectName },
},
} = useGlobalStateContext()
const [state, send] = useMachine(homeMachine, {
context: {

View File

@ -3,14 +3,19 @@ import { BaseUnit, baseUnits } from '../../useStore'
import { ActionButton } from '../../components/ActionButton'
import { SettingsSection } from '../Settings'
import { Toggle } from '../../components/Toggle/Toggle'
import { useContext, useState } from 'react'
import { useState } from 'react'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { SettingsContext } from '../../components/SettingsCommandProvider'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
export default function Units() {
const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.CAMERA)
const { send, unitSystem, baseUnit } = useContext(SettingsContext)
const {
settings: {
send,
context: { unitSystem, baseUnit },
},
} = useGlobalStateContext()
const [tempUnitSystem, setTempUnitSystem] = useState(unitSystem)
const [tempBaseUnit, setTempBaseUnit] = useState(baseUnit)

View File

@ -4,9 +4,9 @@ import Introduction from './Introduction'
import Units from './Units'
import Camera from './Camera'
import Sketching from './Sketching'
import { useCallback, useContext } from 'react'
import { useCallback } from 'react'
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
import { SettingsContext } from '../../components/SettingsCommandProvider'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
export const onboardingPaths = {
INDEX: '/',
@ -35,7 +35,9 @@ export const onboardingRoutes = [
]
export function useNextClick(newStatus: string) {
const { send } = useContext(SettingsContext)
const {
settings: { send },
} = useGlobalStateContext()
const navigate = useNavigate()
return useCallback(() => {
@ -48,7 +50,9 @@ export function useNextClick(newStatus: string) {
}
export function useDismiss() {
const { send } = useContext(SettingsContext)
const {
settings: { send },
} = useGlobalStateContext()
const navigate = useNavigate()
return useCallback(

View File

@ -7,27 +7,32 @@ import { ActionButton } from '../components/ActionButton'
import { AppHeader } from '../components/AppHeader'
import { open } from '@tauri-apps/api/dialog'
import { BaseUnit, baseUnits } from '../useStore'
import { useContext } from 'react'
import { Toggle } from '../components/Toggle/Toggle'
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { IndexLoaderData, paths } from '../Router'
import { Themes } from '../lib/theme'
import { SettingsContext } from '../components/SettingsCommandProvider'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
export const Settings = () => {
const loaderData = useRouteLoaderData(paths.FILE) as IndexLoaderData
const navigate = useNavigate()
useHotkeys('esc', () => navigate('../'))
const {
settings: {
send,
state: {
context: {
defaultProjectName,
showDebugPanel,
defaultDirectory,
unitSystem,
baseUnit,
theme,
send,
} = useContext(SettingsContext)
},
},
},
} = useGlobalStateContext()
async function handleDirectorySelection() {
const newDirectory = await open({

View File

@ -5,13 +5,18 @@ import { invoke } from '@tauri-apps/api/tauri'
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
import { Themes, getSystemTheme } from '../lib/theme'
import { paths } from '../Router'
import { useAuthMachine } from '../hooks/useAuthMachine'
import { useContext } from 'react'
import { SettingsContext } from 'components/SettingsCommandProvider'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
const SignIn = () => {
const { theme } = useContext(SettingsContext)
const [_, send] = useAuthMachine()
const {
auth: { send },
settings: {
state: {
context: { theme },
},
},
} = useGlobalStateContext()
const appliedTheme = theme === Themes.System ? getSystemTheme() : theme
const signInTauri = async () => {
// We want to invoke our command to login via device auth.