Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor LocalStorage and associated hooks #11625

Open
wants to merge 28 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9d2ae55
WIP: Refactor LocalStorage
somebody1234 Nov 20, 2024
9005f5e
Implement `defineLocalStorageKey`
somebody1234 Nov 22, 2024
5e83384
Switch to `defineLocalStorageKey` for `LocalStorage` keys in `App.tsx`
somebody1234 Nov 22, 2024
7a0b1c3
Fix errors in `EnsoDevtools`
somebody1234 Nov 22, 2024
aa9d26d
Use named imports in `EnsoDevtools`
somebody1234 Nov 22, 2024
a25c29b
Switch to `defineLocalStorageKey` for `LocalStorage` keys in `AssetsT…
somebody1234 Nov 22, 2024
2b23794
Rename `AssetsTable` hooks files from `.tsx` to `.ts`
somebody1234 Nov 22, 2024
880ddf3
Use `useLocalRootDirectoryState` in `directoryIdsHooks`
somebody1234 Nov 22, 2024
d72b605
Switch to `defineLocalStorageKey` for `LocalStorage` keys in `Categor…
somebody1234 Nov 22, 2024
723a9eb
Switch to `defineLocalStorageKey` for `LocalStorage` keys in `AssetPa…
somebody1234 Nov 22, 2024
17cc6d3
Rename `AssetPanel` `Visible`/`Expanded` state to `Open` state
somebody1234 Nov 22, 2024
6f68b87
Switch to `defineLocalStorageKey` for `LocalStorage` keys in `Registr…
somebody1234 Nov 22, 2024
0c1a84d
Remove some section headings
somebody1234 Nov 22, 2024
2ccfa79
Switch to `defineLocalStorageKey` for `LocalStorage` keys in `Feature…
somebody1234 Nov 22, 2024
8eb8b06
Switch to `defineLocalStorageKey` for `LocalStorage` keys in `Project…
somebody1234 Nov 22, 2024
d1abf4d
Use named imports in `ProjectsProvider`
somebody1234 Nov 22, 2024
2e4a37b
Fix type errors
somebody1234 Nov 22, 2024
fedf407
Switch more files to named imports
somebody1234 Nov 22, 2024
57ef0e9
Fix type errors
somebody1234 Nov 22, 2024
518e120
Fix lint errors
somebody1234 Nov 22, 2024
14ad291
Fix loading of `localStorage` data
somebody1234 Nov 22, 2024
cba46f1
Fix import paths
somebody1234 Nov 25, 2024
89994dc
Combine return values of `defineLocalStorageKey`
somebody1234 Nov 25, 2024
fd3bfd3
Check localStorage value against schema in `LocalStorage` class
somebody1234 Nov 26, 2024
2182117
Call `LocalStorage.subscribe` and `LocalStorage.subscribeAll` callbac…
somebody1234 Nov 26, 2024
ebfd7c3
oops
somebody1234 Nov 26, 2024
e2ee4a5
Reactive updates for `LocalStorage` `useState`
somebody1234 Nov 26, 2024
0165dda
Fix `subscribeAll` not calling with different reference, causing Reac…
somebody1234 Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 5 additions & 56 deletions app/common/src/utilities/data/object.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
/** @file Functions related to manipulating objects. */

// ===============
// === Mutable ===
// ===============

/** Remove the `readonly` modifier from all fields in a type. */
export type Mutable<T> = {
-readonly [K in keyof T]: T[K]
}

// =============
// === merge ===
// =============

/** Prevents generic parameter inference by hiding the type parameter behind a conditional type. */
type NoInfer<T> = [T][T extends T ? 0 : never]

Expand All @@ -35,28 +27,16 @@ export function merger<T extends object>(update: Partial<NoInfer<T>>): (object:
return object => merge(object, update)
}

// ================
// === readonly ===
// ================

/** Makes all properties readonly at the type level. They are still mutable at the runtime level. */
export function readonly<T extends object>(object: T): Readonly<T> {
return object
}

// =====================
// === unsafeMutable ===
// =====================

/** Removes the readonly modifier from all properties on the object. UNSAFE. */
export function unsafeMutable<T extends object>(object: T): { -readonly [K in keyof T]: T[K] } {
return object
}

// =====================
// === unsafeEntries ===
// =====================

/**
* Return the entries of an object. UNSAFE only when it is possible for an object to have
* extra keys.
Expand All @@ -77,10 +57,6 @@ export function unsafeEntries<T extends object>(
return Object.entries(object)
}

// =============================
// === unsafeRemoveUndefined ===
// =============================

/** A the object with `undefined` unsafely removed from the value types of all of its keys. */
export function unsafeRemoveUndefined<T extends object>(
object: T,
Expand All @@ -89,10 +65,6 @@ export function unsafeRemoveUndefined<T extends object>(
return object as never
}

// ==================
// === mapEntries ===
// ==================

/**
* Return the entries of an object. UNSAFE only when it is possible for an object to have
* extra keys.
Expand All @@ -111,28 +83,16 @@ export function mapEntries<K extends PropertyKey, V, W>(
)
}

// ================
// === asObject ===
// ================

/** Either return the object unchanged, if the input was an object, or `null`. */
export function asObject(value: unknown): object | null {
return typeof value === 'object' && value != null ? value : null
}

// =============================
// === singletonObjectOrNull ===
// =============================

/** Either return a singleton object, if the input was an object, or an empty array. */
export function singletonObjectOrNull(value: unknown): [] | [object] {
return typeof value === 'object' && value != null ? [value] : []
}

// ============
// === omit ===
// ============

/** UNSAFE when `Ks` contains strings that are not in the runtime array. */
export function omit<T, Ks extends readonly [string & keyof T, ...(string & keyof T)[]]>(
object: T,
Expand All @@ -145,10 +105,6 @@ export function omit<T, Ks extends readonly [string & keyof T, ...(string & keyo
) as Omit<T, Ks[number]>
}

// ============
// === pick ===
// ============

/** UNSAFE when `Ks` contains strings that are not in the runtime array. */
export function pick<T, Ks extends readonly [string & keyof T, ...(string & keyof T)[]]>(
object: T,
Expand All @@ -161,26 +117,14 @@ export function pick<T, Ks extends readonly [string & keyof T, ...(string & keyo
) as Pick<T, Ks[number]>
}

// ===================
// === ExtractKeys ===
// ===================

/** Filter a type `T` to include only the properties extending the given type `U`. */
export type ExtractKeys<T, U> = {
[K in keyof T]: T[K] extends U ? K : never
}[keyof T]

// ================
// === MethodOf ===
// ================

/** An instance method of the given type. */
export type MethodOf<T> = (this: T, ...args: never) => unknown

// ===================
// === useObjectId ===
// ===================

/** Composable providing support for managing object identities. */
export function useObjectId() {
let lastId = 0
Expand All @@ -197,3 +141,8 @@ export function useObjectId() {
}
return { objectId }
}

/** Create an object given its prototype. */
export function createObject<T extends object>(parent: T): T {
return Object.create(parent)
}
2 changes: 1 addition & 1 deletion app/gui/e2e/dashboard/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import type * as remoteBackend from '#/services/RemoteBackend'
import * as remoteBackendPaths from '#/services/remoteBackendPaths'

import * as dateTime from '#/utilities/dateTime'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import * as object from 'enso-common/src/utilities/data/object'
import * as uniqueString from 'enso-common/src/utilities/uniqueString'

import * as actions from './actions'
Expand Down
79 changes: 20 additions & 59 deletions app/gui/src/dashboard/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router-dom'
import * as toastify from 'react-toastify'
import * as z from 'zod'

import * as detect from 'enso-common/src/detect'

Expand All @@ -53,7 +52,7 @@ import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider'
import DriveProvider from '#/providers/DriveProvider'
import { useHttpClient } from '#/providers/HttpClientProvider'
import InputBindingsProvider from '#/providers/InputBindingsProvider'
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
import LocalStorageProvider from '#/providers/LocalStorageProvider'
import { useLogger } from '#/providers/LoggerProvider'
import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
Expand Down Expand Up @@ -93,18 +92,19 @@ import RemoteBackend from '#/services/RemoteBackend'
import { FeatureFlagsProvider } from '#/providers/FeatureFlagsProvider'
import * as appBaseUrl from '#/utilities/appBaseUrl'
import * as eventModule from '#/utilities/event'
import LocalStorage from '#/utilities/LocalStorage'
import * as object from '#/utilities/object'
import { Path } from '#/utilities/path'
import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery'

import * as object from 'enso-common/src/utilities/data/object'

import {
useAcceptedPrivacyPolicyVersionState,
useAcceptedTermsOfServiceVersionState,
useInputBindings,
useLocalRootDirectoryState,
} from '#/appLocalStorage'
import { useInitAuthService } from '#/authentication/service'
import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'

// ============================
// === Global configuration ===
// ============================

const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
Expand All @@ -115,44 +115,13 @@ const DEFAULT_TRANSITION_OPTIONS: Spring = {
velocity: 0,
}

declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly inputBindings: Readonly<Record<string, readonly string[]>>
readonly localRootDirectory: string
}
}

LocalStorage.registerKey('inputBindings', {
schema: z.record(z.string().array().readonly()).transform((value) =>
Object.fromEntries(
Object.entries<unknown>({ ...value }).flatMap((kv) => {
const [k, v] = kv
return Array.isArray(v) && v.every((item): item is string => typeof item === 'string') ?
[[k, v]]
: []
}),
),
),
})

LocalStorage.registerKey('localRootDirectory', { schema: z.string() })

// ======================
// === getMainPageUrl ===
// ======================

/** Returns the URL to the main page. This is the current URL, with the current route removed. */
function getMainPageUrl() {
const mainPageUrl = new URL(window.location.href)
mainPageUrl.pathname = mainPageUrl.pathname.replace(appUtils.ALL_PATHS_REGEX, '')
return mainPageUrl
}

// ===========
// === App ===
// ===========

/** Global configuration for the `App` component. */
export interface AppProps {
readonly vibrancy: boolean
Expand Down Expand Up @@ -277,10 +246,6 @@ export default function App(props: AppProps) {
)
}

// =================
// === AppRouter ===
// =================

/** Props for an {@link AppRouter}. */
export interface AppRouterProps extends AppProps {
readonly projectManagerRootDirectory: projectManager.Path | null
Expand All @@ -302,7 +267,6 @@ function AppRouter(props: AppRouterProps) {
const navigate = router.useNavigate()

const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
const { setModal } = modalProvider.useSetModal()

const navigator2D = navigator2DProvider.useNavigator2D()
Expand All @@ -324,8 +288,10 @@ function AppRouter(props: AppRouterProps) {

const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())

const { get: getInputBindings, set: setInputBindings } = useInputBindings()

React.useEffect(() => {
const savedInputBindings = localStorage.get('inputBindings')
const savedInputBindings = getInputBindings()
if (savedInputBindings != null) {
const filteredInputBindings = object.mapEntries(
inputBindingsRaw.metadata,
Expand All @@ -340,12 +306,11 @@ function AppRouter(props: AppRouterProps) {
}
}
}
}, [localStorage, inputBindingsRaw])
}, [inputBindingsRaw, getInputBindings])

const inputBindings = React.useMemo(() => {
const updateLocalStorage = () => {
localStorage.set(
'inputBindings',
setInputBindings(
Object.fromEntries(
Object.entries(inputBindingsRaw.metadata).map((kv) => {
const [k, v] = kv
Expand Down Expand Up @@ -388,14 +353,14 @@ function AppRouter(props: AppRouterProps) {
return inputBindingsRaw.unregister.bind(inputBindingsRaw)
},
}
}, [localStorage, inputBindingsRaw])
}, [inputBindingsRaw, setInputBindings])

const mainPageUrl = getMainPageUrl()

// Subscribe to `localStorage` updates to trigger a rerender when the terms of service
// or privacy policy have been accepted.
localStorageProvider.useLocalStorageState('termsOfService')
localStorageProvider.useLocalStorageState('privacyPolicy')
// Subscribe to `localStorage` updates (and ignore the value)
// to trigger a rerender when the terms of service or privacy policy have been accepted.
useAcceptedTermsOfServiceVersionState()
useAcceptedPrivacyPolicyVersionState()

const authService = useInitAuthService(props)

Expand Down Expand Up @@ -572,13 +537,9 @@ function AppRouter(props: AppRouterProps) {
)
}

// ====================================
// === LocalBackendPathSynchronizer ===
// ====================================

/** Keep `localBackend.rootPath` in sync with the saved root path state. */
function LocalBackendPathSynchronizer() {
const [localRootDirectory] = localStorageProvider.useLocalStorageState('localRootDirectory')
const [localRootDirectory] = useLocalRootDirectoryState()
const localBackend = useLocalBackend()
if (localBackend) {
if (localRootDirectory != null) {
Expand Down
42 changes: 42 additions & 0 deletions app/gui/src/dashboard/appLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/** @file App-wide local storage keys. */
import { defineLocalStorageKey } from '#/providers/LocalStorageProvider'

export const { use: useInputBindings, useState: useInputBindingsState } = defineLocalStorageKey(
'inputBindings',
{
schema: (z) =>
z
.record(z.string().array().readonly())
.transform((value): { readonly [k: string]: readonly string[] } =>
Object.fromEntries(
Object.entries<unknown>({ ...value }).flatMap((kv) => {
const [k, v] = kv
return (
Array.isArray(v) && v.every((item): item is string => typeof item === 'string')
) ?
[[k, v]]
: []
}),
),
),
},
)

export const { use: useLocalRootDirectory, useState: useLocalRootDirectoryState } =
defineLocalStorageKey('localRootDirectory', {
schema: (z) => z.string(),
})

export const {
use: useAcceptedTermsOfServiceVersion,
useState: useAcceptedTermsOfServiceVersionState,
} = defineLocalStorageKey('termsOfService', {
schema: (z) => z.object({ versionHash: z.string() }),
})

export const {
use: useAcceptedPrivacyPolicyVersion,
useState: useAcceptedPrivacyPolicyVersionState,
} = defineLocalStorageKey('privacyPolicy', {
schema: (z) => z.object({ versionHash: z.string() }),
})
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ export const BUTTON_STYLES = tv({
{ variant: 'link', size: 'medium', class: 'font-medium' },
{ variant: 'link', size: 'large', class: 'font-medium' },
{ variant: 'link', size: 'hero', class: 'font-medium' },

{ variant: 'icon', isDisabled: true, class: { icon: 'opacity-50' } },
],
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import type { CheckboxGroupProps as AriaCheckboxGroupProps } from '#/components/aria'
import { CheckboxGroup as AriaCheckboxGroup, mergeProps } from '#/components/aria'
import { mergeRefs } from '#/utilities/mergeRefs'
import { omit } from '#/utilities/object'
import { forwardRef } from '#/utilities/react'
import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
import { omit } from 'enso-common/src/utilities/data/object'
import type { CSSProperties, ForwardedRef, ReactElement } from 'react'
import type { FieldVariantProps } from '../Form'
import { Form, type FieldPath, type FieldProps, type FieldStateProps, type TSchema } from '../Form'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import * as aria from '#/components/aria'
import * as mergeRefs from '#/utilities/mergeRefs'
import * as twv from '#/utilities/tailwindVariants'

import { omit } from '#/utilities/object'
import { forwardRef } from '#/utilities/react'
import { omit } from 'enso-common/src/utilities/data/object'
import type { FieldVariantProps } from '../Form'
import * as formComponent from '../Form'
import * as radioGroupContext from './RadioGroupContext'
Expand Down
Loading
Loading