Skip to content

Commit

Permalink
feat(core): fetch structure tool settings from backend (#5901)
Browse files Browse the repository at this point in the history
  • Loading branch information
cngonzalez committed Mar 14, 2024
1 parent 96bc72b commit 36565fe
Show file tree
Hide file tree
Showing 11 changed files with 239 additions and 76 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])
}
40 changes: 29 additions & 11 deletions packages/sanity/src/core/store/key-value/KeyValueStore.ts
@@ -1,18 +1,19 @@
import {merge, type Observable, Subject} from 'rxjs'
import {type SanityClient} from '@sanity/client'
import {merge, Observable, Subject} from 'rxjs'
import {filter, map, switchMap} 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(
storageBackend.setKey(event.key, event.value).pipe(
map((nextValue) => ({
key: event.key,
value: nextValue,
Expand All @@ -21,21 +22,38 @@ export function createKeyValueStore(): KeyValueStore {
),
)

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> => {
/*
* 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.
*/
const response = new Observable<KeyValueStoreValue>((subscriber) => {
const subscription = storageBackend.setKey(key, value).subscribe({
next: (nextValue) => {
subscriber.next(nextValue as KeyValueStoreValue)
subscriber.complete()
},
//storageBackend should handle its own errors, we're just passing along the result.
error: (err) => {
subscriber.error(err)
},
})
return () => subscription.unsubscribe()
})

setKey$.next({key, value})
return response
}

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}
Expand Up @@ -4,5 +4,6 @@ import {memoryBackend} from './memory'
import {type Backend} from './types'

export function resolveBackend(): Backend {
//TODO: add check for "server"
return supportsLocalStorage ? localStorageBackend : memoryBackend
}
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, apiVersion: 'vX'})

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 @@ -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
23 changes: 15 additions & 8 deletions packages/sanity/src/structure/useStructureToolSetting.ts
@@ -1,30 +1,37 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
import {startWith} from 'rxjs/operators'
import {map, startWith} from 'rxjs/operators'
import {useKeyValueStore} from 'sanity'

const STRUCTURE_TOOL_NAMESPACE = 'studio.structure-tool'

/**
* @internal
*/
export function useStructureToolSetting<ValueType>(
namespace: string | null,
key: string,
key: string | null,
defaultValue?: ValueType,
): [ValueType | undefined, (_value: ValueType) => void] {
const keyValueStore = useKeyValueStore()
const [value, setValue] = useState<ValueType | undefined>(defaultValue)

const keyValueStoreKey = namespace
? `structure-tool::${namespace}::${key}`
: `structure-tool::${key}`
const keyValueStoreKey = `${STRUCTURE_TOOL_NAMESPACE}.${namespace}.${key}`

const settings = useMemo(() => {
return keyValueStore.getKey(keyValueStoreKey)
}, [keyValueStore, keyValueStoreKey])

useEffect(() => {
const sub = settings.pipe(startWith(defaultValue)).subscribe({
next: setValue as any,
})
const sub = settings
.pipe(
startWith(defaultValue),
map((fetchedValue) => {
return fetchedValue === null ? defaultValue : fetchedValue
}),
)
.subscribe({
next: setValue as any,
})

return () => sub?.unsubscribe()
}, [defaultValue, keyValueStoreKey, settings])
Expand Down

0 comments on commit 36565fe

Please sign in to comment.