Skip to content

Commit

Permalink
feat(core): store and fetch user settings from backend (#5939)
Browse files Browse the repository at this point in the history
* feat(core): fetch structure tool settings from backend (#5901)

* feat(core): store recent search history in backend (#5940)

* feat(core): fetch inspect mode from backend (#5938)

* chore: update studio version for production-ready cellar (#5993)

* fix: prevent key value store from firing setKey for each open subscription (#5997)

* fix: update tests to match api (#6000)

* fix: update tests to match api

* fix: comment out flaky tests for now

* chore: remove unused resolve logic
  • Loading branch information
cngonzalez committed Mar 14, 2024
1 parent 8e63552 commit ecb3495
Show file tree
Hide file tree
Showing 16 changed files with 331 additions and 131 deletions.
5 changes: 3 additions & 2 deletions packages/sanity/src/core/store/_legacy/datastores.ts
Expand Up @@ -233,13 +233,14 @@ export function useProjectStore(): ProjectStore {
export function useKeyValueStore(): KeyValueStore {
const resourceCache = useResourceCache()
const workspace = useWorkspace()
const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)

return useMemo(() => {
const keyValueStore =
resourceCache.get<KeyValueStore>({
dependencies: [workspace],
namespace: 'KeyValueStore',
}) || createKeyValueStore()
}) || createKeyValueStore({client})

resourceCache.set({
dependencies: [workspace],
Expand All @@ -248,5 +249,5 @@ export function useKeyValueStore(): KeyValueStore {
})

return keyValueStore
}, [resourceCache, workspace])
}, [client, resourceCache, workspace])
}
38 changes: 24 additions & 14 deletions packages/sanity/src/core/store/key-value/KeyValueStore.ts
@@ -1,41 +1,51 @@
import {type SanityClient} from '@sanity/client'
import {merge, type Observable, Subject} from 'rxjs'
import {filter, map, switchMap} from 'rxjs/operators'
import {filter, map, shareReplay, switchMap, take} from 'rxjs/operators'

import {resolveBackend} from './backends/resolve'
import {serverBackend} from './backends/server'
import {type KeyValueStore, type KeyValueStoreValue} from './types'

/** @internal */
export function createKeyValueStore(): KeyValueStore {
const storageBackend = resolveBackend()
export function createKeyValueStore({client}: {client: SanityClient}): KeyValueStore {
const storageBackend = serverBackend({client})

const setKey$ = new Subject<{key: string; value: KeyValueStoreValue}>()

const updates$ = setKey$.pipe(
switchMap((event) =>
storageBackend.set(event.key, event.value).pipe(
switchMap((event) => {
return storageBackend.setKey(event.key, event.value).pipe(
map((nextValue) => ({
key: event.key,
value: nextValue,
})),
),
),
)
}),
shareReplay(1),
)

const getKey = (
key: string,
defaultValue: KeyValueStoreValue,
): Observable<KeyValueStoreValue> => {
const getKey = (key: string): Observable<KeyValueStoreValue> => {
return merge(
storageBackend.get(key, defaultValue),
storageBackend.getKey(key),
updates$.pipe(
filter((update) => update.key === key),
map((update) => update.value),
),
) as Observable<KeyValueStoreValue>
}

const setKey = (key: string, value: KeyValueStoreValue) => {
const setKey = (key: string, value: KeyValueStoreValue): Observable<KeyValueStoreValue> => {
setKey$.next({key, value})

/*
* The backend returns the result of the set operation, so we can just pass that along.
* Most utils do not use it (they will take advantage of local state first) but it reflects the
* backend function and could be useful for debugging.
*/
return updates$.pipe(
filter((update) => update.key === key),
map((update) => update.value as KeyValueStoreValue),
take(1),
)
}

return {getKey, setKey}
Expand Down
34 changes: 27 additions & 7 deletions packages/sanity/src/core/store/key-value/backends/localStorage.ts
@@ -1,24 +1,24 @@
import {type Observable, of as observableOf} from 'rxjs'

import {type Backend} from './types'
import {type Backend, type KeyValuePair} from './types'

const tryParse = (val: string, defValue: unknown) => {
const tryParse = (val: string) => {
try {
return JSON.parse(val)
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`Failed to parse settings: ${err.message}`)
return defValue
return null
}
}

const get = (key: string, defValue: unknown): Observable<unknown> => {
const getKey = (key: string): Observable<unknown> => {
const val = localStorage.getItem(key)

return observableOf(val === null ? defValue : tryParse(val, defValue))
return observableOf(val === null ? null : tryParse(val))
}

const set = (key: string, nextValue: unknown): Observable<unknown> => {
const setKey = (key: string, nextValue: unknown): Observable<unknown> => {
// Can't stringify undefined, and nulls are what
// `getItem` returns when key does not exist
if (typeof nextValue === 'undefined' || nextValue === null) {
Expand All @@ -30,4 +30,24 @@ const set = (key: string, nextValue: unknown): Observable<unknown> => {
return observableOf(nextValue)
}

export const localStorageBackend: Backend = {get, set}
const getKeys = (keys: string[]): Observable<unknown[]> => {
const values = keys.map((key, i) => {
const val = localStorage.getItem(key)
return val === null ? null : tryParse(val)
})

return observableOf(values)
}

const setKeys = (keyValuePairs: KeyValuePair[]): Observable<unknown[]> => {
keyValuePairs.forEach((pair) => {
if (pair.value === undefined || pair.value === null) {
localStorage.removeItem(pair.key)
} else {
localStorage.setItem(pair.key, JSON.stringify(pair.value))
}
})
return observableOf(keyValuePairs.map((pair) => pair.value))
}

export const localStorageBackend: Backend = {getKey, setKey, getKeys, setKeys}
28 changes: 17 additions & 11 deletions packages/sanity/src/core/store/key-value/backends/memory.ts
@@ -1,20 +1,26 @@
import {type Observable, of as observableOf} from 'rxjs'

import {type Backend} from './types'
import {type Backend, type KeyValuePair} from './types'

const DB = Object.create(null)

const get = (key: string, defValue: unknown): Observable<unknown> =>
observableOf(key in DB ? DB[key] : defValue)

const set = (key: string, nextValue: unknown): Observable<unknown> => {
if (typeof nextValue === 'undefined' || nextValue === null) {
delete DB[key]
} else {
DB[key] = nextValue
}
const getKey = (key: string): Observable<unknown> => observableOf(key in DB ? DB[key] : null)

const setKey = (key: string, nextValue: unknown): Observable<unknown> => {
DB[key] = nextValue
return observableOf(nextValue)
}

export const memoryBackend: Backend = {get, set}
const getKeys = (keys: string[]): Observable<unknown[]> => {
return observableOf(keys.map((key, i) => (key in DB ? DB[key] : null)))
}

const setKeys = (keyValuePairs: KeyValuePair[]): Observable<unknown[]> => {
keyValuePairs.forEach((pair) => {
DB[pair.key] = pair.value
})

return observableOf(keyValuePairs.map((pair) => pair.value))
}

export const memoryBackend: Backend = {getKey, setKey, getKeys, setKeys}
8 changes: 0 additions & 8 deletions packages/sanity/src/core/store/key-value/backends/resolve.ts

This file was deleted.

89 changes: 89 additions & 0 deletions packages/sanity/src/core/store/key-value/backends/server.ts
@@ -0,0 +1,89 @@
import {type SanityClient} from '@sanity/client'
import DataLoader from 'dataloader'
import {catchError, from, map, of} from 'rxjs'

import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../studioClient'
import {type KeyValueStoreValue} from '../types'
import {type Backend, type KeyValuePair} from './types'

/** @internal */
export interface ServerBackendOptions {
client: SanityClient
}

/**
* One of serveral possible backends for KeyValueStore. This backend uses the
* Sanity client to store and retrieve key-value pairs from the /users/me/keyvalue endpoint.
* @internal
*/
export function serverBackend({client: _client}: ServerBackendOptions): Backend {
const client = _client.withConfig(DEFAULT_STUDIO_CLIENT_OPTIONS)

const keyValueLoader = new DataLoader<string, KeyValueStoreValue | null>(async (keys) => {
const value = await client
.request<KeyValuePair[]>({
uri: `/users/me/keyvalue/${keys.join(',')}`,
withCredentials: true,
})
.catch((error) => {
console.error('Error fetching data:', error)
return Array(keys.length).fill(null)
})

const keyValuePairs = value.reduce(
(acc, next) => {
if (next?.key) {
acc[next.key] = next.value
}
return acc
},
{} as Record<string, KeyValueStoreValue | null>,
)

const result = keys.map((key) => keyValuePairs[key] || null)
return result
})

const getKeys = (keys: string[]) => {
return from(keyValueLoader.loadMany(keys))
}

const setKeys = (keyValuePairs: KeyValuePair[]) => {
return from(
client.request<KeyValuePair[]>({
method: 'PUT',
uri: `/users/me/keyvalue`,
body: keyValuePairs,
withCredentials: true,
}),
).pipe(
map((response) => {
return response.map((pair) => {
keyValueLoader.clear(pair.key)
keyValueLoader.prime(pair.key, pair.value)

return pair.value
})
}),
catchError((error) => {
console.error('Error setting data:', error)
return of(Array(keyValuePairs.length).fill(null))
}),
)
}

const getKey = (key: string) => {
return getKeys([key]).pipe(map((values) => values[0]))
}

const setKey = (key: string, nextValue: unknown) => {
return setKeys([{key, value: nextValue as KeyValueStoreValue}]).pipe(map((values) => values[0]))
}

return {
getKey,
setKey,
getKeys,
setKeys,
}
}
13 changes: 11 additions & 2 deletions packages/sanity/src/core/store/key-value/backends/types.ts
@@ -1,6 +1,15 @@
import {type Observable} from 'rxjs'

import {type KeyValueStoreValue} from '../types'

export interface KeyValuePair {
key: string
value: KeyValueStoreValue | null
}

export interface Backend {
get: (key: string, defValue: unknown) => Observable<unknown>
set: (key: string, nextValue: unknown) => Observable<unknown>
getKey: (key: string) => Observable<unknown>
setKey: (key: string, nextValue: unknown) => Observable<unknown>
getKeys: (keys: string[]) => Observable<unknown[]>
setKeys: (keyValuePairs: KeyValuePair[]) => Observable<unknown[]>
}
4 changes: 2 additions & 2 deletions packages/sanity/src/core/store/key-value/types.ts
Expand Up @@ -11,6 +11,6 @@ export type KeyValueStoreValue = JsonPrimitive | JsonObject | JsonArray

/** @internal */
export interface KeyValueStore {
getKey(key: string, defaultValue?: KeyValueStoreValue): Observable<KeyValueStoreValue | undefined>
setKey(key: string, value: KeyValueStoreValue): void
getKey(key: string): Observable<KeyValueStoreValue | null>
setKey(key: string, value: KeyValueStoreValue): Observable<KeyValueStoreValue>
}
Expand Up @@ -2,11 +2,11 @@ import {useCallback, useEffect, useMemo, useState} from 'react'
import {map, startWith} from 'rxjs/operators'

import {useClient} from '../../../../../hooks'
import {useCurrentUser, useKeyValueStore} from '../../../../../store'
import {useKeyValueStore} from '../../../../../store'
import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../../../../studioClient'

export const RECENT_SEARCH_VERSION = 2
const STORED_SEARCHES_NAMESPACE = 'search::recent'
const STORED_SEARCHES_NAMESPACE = 'studio.search.recent'

interface StoredSearch {
version: number
Expand All @@ -21,13 +21,9 @@ const defaultValue: StoredSearch = {
export function useStoredSearch(): [StoredSearch, (_value: StoredSearch) => void] {
const keyValueStore = useKeyValueStore()
const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)
const currentUser = useCurrentUser()
const {dataset, projectId} = client.config()
const {dataset} = client.config()

const keyValueStoreKey = useMemo(
() => `${STORED_SEARCHES_NAMESPACE}__${projectId}:${dataset}:${currentUser?.id}`,
[currentUser, dataset, projectId],
)
const keyValueStoreKey = useMemo(() => `${STORED_SEARCHES_NAMESPACE}.${dataset}`, [dataset])

const [value, setValue] = useState<StoredSearch>(defaultValue)

Expand Down
2 changes: 1 addition & 1 deletion packages/sanity/src/core/studioClient.ts
Expand Up @@ -10,5 +10,5 @@ import {type SourceClientOptions} from './config'
* @internal
*/
export const DEFAULT_STUDIO_CLIENT_OPTIONS: SourceClientOptions = {
apiVersion: '2023-11-13',
apiVersion: '2024-03-12',
}
Expand Up @@ -27,8 +27,8 @@ export function InspectDialog(props: InspectDialogProps) {
where the inspect dialog lives.
This also means that when a page is loaded, the state of the tabs remains and doesn't revert to the pane tab */
const [viewModeId, onViewModeChange] = useStructureToolSetting(
'structure-tool',
`inspect-view-preferred-view-mode-${paneKey}`,
'inspect-view-mode',
null,
'parsed',
)

Expand Down
Expand Up @@ -109,8 +109,8 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi
const typeName = useMemo(() => getTypeNameFromSingleTypeFilter(filter, params), [filter, params])
const showIcons = displayOptions?.showIcons !== false
const [layout, setLayout] = useStructureToolSetting<GeneralPreviewLayoutKey>(
typeName,
'layout',
typeName,
defaultLayout,
)

Expand All @@ -132,8 +132,8 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi
}, [defaultOrdering])

const [sortOrderRaw, setSortOrder] = useStructureToolSetting<SortOrder>(
'sort-order',
typeName,
'sortOrder',
defaultSortOrder,
)

Expand Down

0 comments on commit ecb3495

Please sign in to comment.