diff --git a/packages/sanity/src/core/store/_legacy/datastores.ts b/packages/sanity/src/core/store/_legacy/datastores.ts index c6619b5bd08..f2f254ed05a 100644 --- a/packages/sanity/src/core/store/_legacy/datastores.ts +++ b/packages/sanity/src/core/store/_legacy/datastores.ts @@ -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({ dependencies: [workspace], namespace: 'KeyValueStore', - }) || createKeyValueStore() + }) || createKeyValueStore({client}) resourceCache.set({ dependencies: [workspace], @@ -248,5 +249,5 @@ export function useKeyValueStore(): KeyValueStore { }) return keyValueStore - }, [resourceCache, workspace]) + }, [client, resourceCache, workspace]) } diff --git a/packages/sanity/src/core/store/key-value/KeyValueStore.ts b/packages/sanity/src/core/store/key-value/KeyValueStore.ts index 02e26bf5cc0..b0a6c0e8c13 100644 --- a/packages/sanity/src/core/store/key-value/KeyValueStore.ts +++ b/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, @@ -21,12 +22,9 @@ export function createKeyValueStore(): KeyValueStore { ), ) - const getKey = ( - key: string, - defaultValue: KeyValueStoreValue, - ): Observable => { + const getKey = (key: string): Observable => { return merge( - storageBackend.get(key, defaultValue), + storageBackend.getKey(key), updates$.pipe( filter((update) => update.key === key), map((update) => update.value), @@ -34,8 +32,28 @@ export function createKeyValueStore(): KeyValueStore { ) as Observable } - const setKey = (key: string, value: KeyValueStoreValue) => { + const setKey = (key: string, value: KeyValueStoreValue): Observable => { + /* + * 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((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} diff --git a/packages/sanity/src/core/store/key-value/backends/localStorage.ts b/packages/sanity/src/core/store/key-value/backends/localStorage.ts index aa2b066f499..860a7c6156a 100644 --- a/packages/sanity/src/core/store/key-value/backends/localStorage.ts +++ b/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 => { +const getKey = (key: string): Observable => { 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 => { +const setKey = (key: string, nextValue: unknown): Observable => { // Can't stringify undefined, and nulls are what // `getItem` returns when key does not exist if (typeof nextValue === 'undefined' || nextValue === null) { @@ -30,4 +30,24 @@ const set = (key: string, nextValue: unknown): Observable => { return observableOf(nextValue) } -export const localStorageBackend: Backend = {get, set} +const getKeys = (keys: string[]): Observable => { + 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 => { + 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} diff --git a/packages/sanity/src/core/store/key-value/backends/memory.ts b/packages/sanity/src/core/store/key-value/backends/memory.ts index 5993c11e5e4..3331ffd94ca 100644 --- a/packages/sanity/src/core/store/key-value/backends/memory.ts +++ b/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 => - observableOf(key in DB ? DB[key] : defValue) - -const set = (key: string, nextValue: unknown): Observable => { - if (typeof nextValue === 'undefined' || nextValue === null) { - delete DB[key] - } else { - DB[key] = nextValue - } +const getKey = (key: string): Observable => observableOf(key in DB ? DB[key] : null) +const setKey = (key: string, nextValue: unknown): Observable => { + DB[key] = nextValue return observableOf(nextValue) } -export const memoryBackend: Backend = {get, set} +const getKeys = (keys: string[]): Observable => { + return observableOf(keys.map((key, i) => (key in DB ? DB[key] : null))) +} + +const setKeys = (keyValuePairs: KeyValuePair[]): Observable => { + keyValuePairs.forEach((pair) => { + DB[pair.key] = pair.value + }) + + return observableOf(keyValuePairs.map((pair) => pair.value)) +} + +export const memoryBackend: Backend = {getKey, setKey, getKeys, setKeys} diff --git a/packages/sanity/src/core/store/key-value/backends/resolve.ts b/packages/sanity/src/core/store/key-value/backends/resolve.ts index 8bcae59a236..8bd6ea57acc 100644 --- a/packages/sanity/src/core/store/key-value/backends/resolve.ts +++ b/packages/sanity/src/core/store/key-value/backends/resolve.ts @@ -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 } diff --git a/packages/sanity/src/core/store/key-value/backends/server.ts b/packages/sanity/src/core/store/key-value/backends/server.ts new file mode 100644 index 00000000000..a97c9fe518e --- /dev/null +++ b/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(async (keys) => { + const value = await client + .request({ + 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, + ) + + 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({ + 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, + } +} diff --git a/packages/sanity/src/core/store/key-value/backends/types.ts b/packages/sanity/src/core/store/key-value/backends/types.ts index 889d7404dd5..78ca54e93a0 100644 --- a/packages/sanity/src/core/store/key-value/backends/types.ts +++ b/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 - set: (key: string, nextValue: unknown) => Observable + getKey: (key: string) => Observable + setKey: (key: string, nextValue: unknown) => Observable + getKeys: (keys: string[]) => Observable + setKeys: (keyValuePairs: KeyValuePair[]) => Observable } diff --git a/packages/sanity/src/core/store/key-value/types.ts b/packages/sanity/src/core/store/key-value/types.ts index f2f0dc4b187..a7c18bcdc3b 100644 --- a/packages/sanity/src/core/store/key-value/types.ts +++ b/packages/sanity/src/core/store/key-value/types.ts @@ -11,6 +11,6 @@ export type KeyValueStoreValue = JsonPrimitive | JsonObject | JsonArray /** @internal */ export interface KeyValueStore { - getKey(key: string, defaultValue?: KeyValueStoreValue): Observable - setKey(key: string, value: KeyValueStoreValue): void + getKey(key: string): Observable + setKey(key: string, value: KeyValueStoreValue): Observable } diff --git a/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx b/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx index 142c7725793..48e631b34e5 100644 --- a/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx +++ b/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx @@ -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( - typeName, 'layout', + typeName, defaultLayout, ) @@ -132,8 +132,8 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi }, [defaultOrdering]) const [sortOrderRaw, setSortOrder] = useStructureToolSetting( + 'sort-order', typeName, - 'sortOrder', defaultSortOrder, ) diff --git a/packages/sanity/src/structure/useStructureToolSetting.ts b/packages/sanity/src/structure/useStructureToolSetting.ts index 2ea5d1812f2..67a5f31f9d5 100644 --- a/packages/sanity/src/structure/useStructureToolSetting.ts +++ b/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( namespace: string | null, - key: string, + key: string | null, defaultValue?: ValueType, ): [ValueType | undefined, (_value: ValueType) => void] { const keyValueStore = useKeyValueStore() const [value, setValue] = useState(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]) diff --git a/test/e2e/tests/desk/documentTypeListContextMenu.spec.ts b/test/e2e/tests/desk/documentTypeListContextMenu.spec.ts index 53cd733c592..8aba74e69cd 100644 --- a/test/e2e/tests/desk/documentTypeListContextMenu.spec.ts +++ b/test/e2e/tests/desk/documentTypeListContextMenu.spec.ts @@ -1,51 +1,63 @@ import {expect} from '@playwright/test' import {test} from '@sanity/test' -const SORT_KEY = 'structure-tool::author::sortOrder' -const LAYOUT_KEY = 'structure-tool::author::layout' +const SORT_KEY = 'studio.structure-tool.sort-order.author' +const LAYOUT_KEY = 'studio.structure-tool.layout.author' //we should also check for custom sort orders -test('clicking sort order and direction sets value in storage', async ({page}) => { +test('clicking sort order and direction sets value in storage', async ({page, sanityClient}) => { await page.goto('/test/content/author') await page.getByTestId('pane').getByTestId('pane-context-menu-button').click() await page.getByRole('menuitem', {name: 'Sort by Name'}).click() - const localStorage = await page.evaluate(() => window.localStorage) - - expect(localStorage[SORT_KEY]).toBe( - '{"by":[{"field":"name","direction":"asc"}],"extendedProjection":"name"}', - ) + const nameResult = await sanityClient.request({ + uri: `/users/me/keyvalue/${SORT_KEY}`, + withCredentials: true, + }) + expect(nameResult[0]).toMatchObject({ + key: SORT_KEY, + value: { + by: [{field: 'name', direction: 'asc'}], + extendedProjection: 'name', + }, + }) await page.getByTestId('pane').getByTestId('pane-context-menu-button').click() await page.getByRole('menuitem', {name: 'Sort by Last Edited'}).click() - const lastEditedLocalStorage = await page.evaluate(() => window.localStorage) - - expect(lastEditedLocalStorage[SORT_KEY]).toBe( - '{"by":[{"field":"_updatedAt","direction":"desc"}],"extendedProjection":""}', - ) + const lastEditedResult = await sanityClient.request({ + uri: `/users/me/keyvalue/${SORT_KEY}`, + withCredentials: true, + }) + + expect(lastEditedResult[0]).toMatchObject({ + key: SORT_KEY, + value: { + by: [{field: '_updatedAt', direction: 'desc'}], + extendedProjection: '', + }, + }) }) -test('clicking list view sets value in storage', async ({page}) => { +test('clicking list view sets value in storage', async ({page, sanityClient}) => { await page.goto('/test/content/author') await page.getByTestId('pane').getByTestId('pane-context-menu-button').click() await page.getByRole('menuitem', {name: 'Detailed view'}).click() - const localStorage = await page.evaluate(() => window.localStorage) - - expect(localStorage[LAYOUT_KEY]).toBe('"detail"') + const detailResult = await sanityClient.request({ + uri: `/users/me/keyvalue/${LAYOUT_KEY}`, + withCredentials: true, + }) + expect(detailResult[0]).toMatchObject({ + key: LAYOUT_KEY, + value: 'detail', + }) await page.getByTestId('pane').getByTestId('pane-context-menu-button').click() await page.getByRole('menuitem', {name: 'Compact view'}).click() - const compactLocalStorage = await page.evaluate(() => window.localStorage) - - expect(compactLocalStorage[LAYOUT_KEY]).toBe('"default"') -}) - -test('values persist after navigating away and back', async ({page}) => { - await page.goto('/test/content/author') - await page.getByTestId('pane').getByTestId('pane-context-menu-button').click() - await page.getByRole('menuitem', {name: 'Detailed view'}).click() - await page.goto('https://example.com') - await page.goto('/test/content/author') - const localStorage = await page.evaluate(() => window.localStorage) - - expect(localStorage[LAYOUT_KEY]).toBe('"detail"') + const compactResult = await sanityClient.request({ + uri: `/users/me/keyvalue/${LAYOUT_KEY}`, + withCredentials: true, + }) + expect(detailResult[0]).toMatchObject({ + key: LAYOUT_KEY, + value: 'default', + }) })