From ff0e9e1f0d2c976827f89f75674451487e090df4 Mon Sep 17 00:00:00 2001 From: Pedro Bonamin Date: Fri, 28 Jun 2024 10:01:16 +0200 Subject: [PATCH 1/8] feat(corel): add bundles store --- .../sanity/src/core/store/bundles/index.ts | 1 + .../sanity/src/core/store/bundles/reducer.ts | 109 +++++++++++++ .../sanity/src/core/store/bundles/types.ts | 10 ++ .../store/bundles/useBundlesOperations.ts | 0 .../src/core/store/bundles/useBundlesStore.ts | 147 ++++++++++++++++++ 5 files changed, 267 insertions(+) create mode 100644 packages/sanity/src/core/store/bundles/index.ts create mode 100644 packages/sanity/src/core/store/bundles/reducer.ts create mode 100644 packages/sanity/src/core/store/bundles/types.ts create mode 100644 packages/sanity/src/core/store/bundles/useBundlesOperations.ts create mode 100644 packages/sanity/src/core/store/bundles/useBundlesStore.ts diff --git a/packages/sanity/src/core/store/bundles/index.ts b/packages/sanity/src/core/store/bundles/index.ts new file mode 100644 index 00000000000..9b1c7f01542 --- /dev/null +++ b/packages/sanity/src/core/store/bundles/index.ts @@ -0,0 +1 @@ +export * from './useBundlesStore' diff --git a/packages/sanity/src/core/store/bundles/reducer.ts b/packages/sanity/src/core/store/bundles/reducer.ts new file mode 100644 index 00000000000..f808ff8d0bc --- /dev/null +++ b/packages/sanity/src/core/store/bundles/reducer.ts @@ -0,0 +1,109 @@ +import {type BundleDocument} from './types' + +interface BundleAddedAction { + payload: BundleDocument + type: 'BUNDLE_ADDED' +} + +interface BundleDeletedAction { + id: string + type: 'BUNDLE_DELETED' +} + +interface BundleUpdatedAction { + payload: BundleDocument + type: 'BUNDLE_UPDATED' +} + +interface BundlesSetAction { + bundles: BundleDocument[] + type: 'BUNDLES_SET' +} + +interface BundleReceivedAction { + payload: BundleDocument + type: 'BUNDLE_RECEIVED' +} + +export type bundlesReducerAction = + | BundleAddedAction + | BundleDeletedAction + | BundleUpdatedAction + | BundlesSetAction + | BundleReceivedAction + +export interface bundlesReducerState { + bundles: Map +} + +function createBundlesSet(bundles: BundleDocument[]) { + const bundlesById = bundles.reduce((acc, bundle) => { + acc.set(bundle._id, bundle) + return acc + }, new Map()) + return bundlesById +} + +export function bundlesReducer( + state: bundlesReducerState, + action: bundlesReducerAction, +): bundlesReducerState { + switch (action.type) { + case 'BUNDLES_SET': { + // Create an object with the BUNDLE id as key + const bundlesById = createBundlesSet(action.bundles) + + return { + ...state, + bundles: bundlesById, + } + } + + case 'BUNDLE_ADDED': { + const addedBundle = action.payload as BundleDocument + const currentBundles = new Map(state.bundles) + currentBundles.set(addedBundle._id, addedBundle) + + return { + ...state, + bundles: currentBundles, + } + } + + case 'BUNDLE_RECEIVED': { + const receivedBundle = action.payload as BundleDocument + const currentBundles = new Map(state.bundles) + currentBundles.set(receivedBundle._id, receivedBundle) + + return { + ...state, + bundles: currentBundles, + } + } + + case 'BUNDLE_DELETED': { + const currentBundles = new Map(state.bundles) + currentBundles.delete(action.id) + + return { + ...state, + bundles: currentBundles, + } + } + + case 'BUNDLE_UPDATED': { + const updatedBundle = action.payload + const id = updatedBundle._id as string + const currentBundles = new Map(state.bundles) + currentBundles.set(id, updatedBundle) + + return { + ...state, + bundles: currentBundles, + } + } + + default: + return state + } +} diff --git a/packages/sanity/src/core/store/bundles/types.ts b/packages/sanity/src/core/store/bundles/types.ts new file mode 100644 index 00000000000..293c834e7d2 --- /dev/null +++ b/packages/sanity/src/core/store/bundles/types.ts @@ -0,0 +1,10 @@ +import {type SanityDocument} from '@sanity/types' + +export interface BundleDocument extends SanityDocument { + _type: 'bundle' + title: string + description?: string + color?: string + icon?: string + authorId: string +} diff --git a/packages/sanity/src/core/store/bundles/useBundlesOperations.ts b/packages/sanity/src/core/store/bundles/useBundlesOperations.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/sanity/src/core/store/bundles/useBundlesStore.ts b/packages/sanity/src/core/store/bundles/useBundlesStore.ts new file mode 100644 index 00000000000..151597d8166 --- /dev/null +++ b/packages/sanity/src/core/store/bundles/useBundlesStore.ts @@ -0,0 +1,147 @@ +import {type ListenEvent, type ListenOptions} from '@sanity/client' +import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' +import {catchError, of} from 'rxjs' + +import {useAddonDataset} from '../../studio/addonDataset/useAddonDataset' +import {bundlesReducer, type bundlesReducerAction, type bundlesReducerState} from './reducer' +import {type BundleDocument} from './types' + +interface BundlesStoreReturnType { + data: BundleDocument[] | null + error: Error | null + loading: boolean + dispatch: React.Dispatch +} + +const INITIAL_STATE: bundlesReducerState = { + bundles: new Map(), +} + +const LISTEN_OPTIONS: ListenOptions = { + events: ['welcome', 'mutation', 'reconnect'], + includeResult: true, + visibility: 'query', +} + +export const SORT_FIELD = '_createdAt' +export const SORT_ORDER = 'desc' + +const QUERY_FILTERS = [`_type == "bundle"`] + +// TODO: Extend the projection with the fields needed +const QUERY_PROJECTION = `{ + ..., +}` + +// Newest bundles first +const QUERY_SORT_ORDER = `order(${SORT_FIELD} ${SORT_ORDER})` + +const QUERY = `*[${QUERY_FILTERS.join(' && ')}] ${QUERY_PROJECTION} | ${QUERY_SORT_ORDER}` + +export function useBundlesStore(): BundlesStoreReturnType { + const {client} = useAddonDataset() + + const [state, dispatch] = useReducer(bundlesReducer, INITIAL_STATE) + const [loading, setLoading] = useState(client !== null) + const [error, setError] = useState(null) + + const didInitialFetch = useRef(false) + + const initialFetch = useCallback(async () => { + if (!client) { + setLoading(false) + return + } + + try { + const res = await client.fetch(QUERY) + dispatch({type: 'BUNDLES_SET', bundles: res}) + setLoading(false) + } catch (err) { + setError(err) + } + }, [client]) + + const handleListenerEvent = useCallback( + async (event: ListenEvent>) => { + // Fetch all bundles on initial connection + if (event.type === 'welcome' && !didInitialFetch.current) { + setLoading(true) + await initialFetch() + setLoading(false) + didInitialFetch.current = true + } + + // The reconnect event means that we are trying to reconnect to the realtime listener. + // In this case we set loading to true to indicate that we're trying to + // reconnect. Once a connection has been established, the welcome event + // will be received and we'll fetch all bundles again (above) + if (event.type === 'reconnect') { + setLoading(true) + didInitialFetch.current = false + } + + // Handle mutations (create, update, delete) from the realtime listener + // and update the bundles store accordingly + if (event.type === 'mutation') { + if (event.transition === 'appear') { + const nextBundle = event.result as BundleDocument | undefined + + if (nextBundle) { + dispatch({ + type: 'BUNDLE_RECEIVED', + payload: nextBundle, + }) + } + } + + if (event.transition === 'disappear') { + dispatch({type: 'BUNDLE_DELETED', id: event.documentId}) + } + + if (event.transition === 'update') { + const updatedBundle = event.result as BundleDocument | undefined + + if (updatedBundle) { + dispatch({ + type: 'BUNDLE_UPDATED', + payload: updatedBundle, + }) + } + } + } + }, + [initialFetch], + ) + + const listener$ = useMemo(() => { + if (!client) return of() + + const events$ = client.observable.listen(QUERY, {}, LISTEN_OPTIONS).pipe( + catchError((err) => { + setError(err) + return of(err) + }), + ) + + return events$ + }, [client]) + + useEffect(() => { + const sub = listener$.subscribe(handleListenerEvent) + + return () => { + sub?.unsubscribe() + } + }, [handleListenerEvent, listener$]) + + // Transform bundles object to array + const bundlesAsArray = useMemo(() => Array.from(state.bundles.values()), [state.bundles]) + + return { + data: bundlesAsArray, + dispatch, + error, + loading, + } +} From 01ad6fe9459f0e07c7c3179b70fc21a079906a3b Mon Sep 17 00:00:00 2001 From: Pedro Bonamin Date: Fri, 28 Jun 2024 11:29:00 +0200 Subject: [PATCH 2/8] chore(corel): use rxjs in bundlesStore listener --- .../src/core/store/bundles/useBundlesStore.ts | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/packages/sanity/src/core/store/bundles/useBundlesStore.ts b/packages/sanity/src/core/store/bundles/useBundlesStore.ts index 151597d8166..4c05ae189b9 100644 --- a/packages/sanity/src/core/store/bundles/useBundlesStore.ts +++ b/packages/sanity/src/core/store/bundles/useBundlesStore.ts @@ -42,34 +42,33 @@ export function useBundlesStore(): BundlesStoreReturnType { const {client} = useAddonDataset() const [state, dispatch] = useReducer(bundlesReducer, INITIAL_STATE) - const [loading, setLoading] = useState(client !== null) + const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const didInitialFetch = useRef(false) - const initialFetch = useCallback(async () => { + const initialFetch$ = useCallback(() => { if (!client) { - setLoading(false) - return - } - - try { - const res = await client.fetch(QUERY) - dispatch({type: 'BUNDLES_SET', bundles: res}) - setLoading(false) - } catch (err) { - setError(err) + return of(null) // emits null and completes if no client } + return client.observable.fetch(QUERY).pipe( + map((res) => { + dispatch({type: 'BUNDLES_SET', bundles: res}) + didInitialFetch.current = true + setLoading(false) + }), + catchError((err) => { + setError(err) + return of(null) // ensure stream completion even on error + }), + ) }, [client]) - const handleListenerEvent = useCallback( - async (event: ListenEvent>) => { + (event: ListenEvent>) => { // Fetch all bundles on initial connection if (event.type === 'welcome' && !didInitialFetch.current) { - setLoading(true) - await initialFetch() - setLoading(false) - didInitialFetch.current = true + // Do nothing here, the initial fetch is done in the useEffect below + initialFetch$() } // The reconnect event means that we are trying to reconnect to the realtime listener. @@ -83,59 +82,57 @@ export function useBundlesStore(): BundlesStoreReturnType { // Handle mutations (create, update, delete) from the realtime listener // and update the bundles store accordingly - if (event.type === 'mutation') { + if (event.type === 'mutation' && didInitialFetch.current) { + if (event.transition === 'disappear') { + dispatch({type: 'BUNDLE_DELETED', id: event.documentId}) + } + if (event.transition === 'appear') { const nextBundle = event.result as BundleDocument | undefined if (nextBundle) { - dispatch({ - type: 'BUNDLE_RECEIVED', - payload: nextBundle, - }) + dispatch({type: 'BUNDLE_RECEIVED', payload: nextBundle}) } } - if (event.transition === 'disappear') { - dispatch({type: 'BUNDLE_DELETED', id: event.documentId}) - } - if (event.transition === 'update') { const updatedBundle = event.result as BundleDocument | undefined if (updatedBundle) { - dispatch({ - type: 'BUNDLE_UPDATED', - payload: updatedBundle, - }) + dispatch({type: 'BUNDLE_UPDATED', payload: updatedBundle}) } } } }, - [initialFetch], + [initialFetch$], ) const listener$ = useMemo(() => { if (!client) return of() const events$ = client.observable.listen(QUERY, {}, LISTEN_OPTIONS).pipe( + map(handleListenerEvent), catchError((err) => { setError(err) return of(err) }), ) - return events$ - }, [client]) + return events$ // as Observable>> + }, [client, handleListenerEvent]) useEffect(() => { - const sub = listener$.subscribe(handleListenerEvent) + if (!client) return + const subscription = initialFetch$() + .pipe(concatMap(() => listener$)) + .subscribe() + // eslint-disable-next-line consistent-return return () => { - sub?.unsubscribe() + subscription.unsubscribe() } - }, [handleListenerEvent, listener$]) + }, [initialFetch$, listener$, client]) - // Transform bundles object to array const bundlesAsArray = useMemo(() => Array.from(state.bundles.values()), [state.bundles]) return { From 1fd7376900e1268f4f70981d231f5a21fc2d7dc5 Mon Sep 17 00:00:00 2001 From: Pedro Bonamin Date: Fri, 28 Jun 2024 12:35:59 +0200 Subject: [PATCH 3/8] feat(corel): add retry to initialFetch --- .../sanity/src/core/store/bundles/useBundlesStore.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/sanity/src/core/store/bundles/useBundlesStore.ts b/packages/sanity/src/core/store/bundles/useBundlesStore.ts index 4c05ae189b9..8a5790034e5 100644 --- a/packages/sanity/src/core/store/bundles/useBundlesStore.ts +++ b/packages/sanity/src/core/store/bundles/useBundlesStore.ts @@ -1,6 +1,6 @@ import {type ListenEvent, type ListenOptions} from '@sanity/client' import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' -import {catchError, of} from 'rxjs' +import {catchError, concatMap, map, of, retry, timeout} from 'rxjs' import {useAddonDataset} from '../../studio/addonDataset/useAddonDataset' import {bundlesReducer, type bundlesReducerAction, type bundlesReducerState} from './reducer' @@ -52,12 +52,20 @@ export function useBundlesStore(): BundlesStoreReturnType { return of(null) // emits null and completes if no client } return client.observable.fetch(QUERY).pipe( + timeout(10000), // 10s timeout map((res) => { dispatch({type: 'BUNDLES_SET', bundles: res}) didInitialFetch.current = true setLoading(false) }), + retry({ + count: 2, + delay: 1000, + }), catchError((err) => { + if (err.name === 'TimeoutError') { + console.error('Fetch operation timed out:', err) + } setError(err) return of(null) // ensure stream completion even on error }), From c85934b4f6205a9ba755658a052661741f03a58c Mon Sep 17 00:00:00 2001 From: Pedro Bonamin Date: Fri, 28 Jun 2024 14:09:42 +0200 Subject: [PATCH 4/8] feat(corel): include useBundleOperations and story --- .../__workshop__/BundlesStoreStory.tsx | 112 +++++++++++++++ .../bundles/__workshop__/ReleaseForm.tsx | 135 ++++++++++++++++++ .../core/store/bundles/__workshop__/index.ts | 14 ++ .../sanity/src/core/store/bundles/types.ts | 4 +- .../core/store/bundles/useBundleOperations.ts | 50 +++++++ .../store/bundles/useBundlesOperations.ts | 0 6 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 packages/sanity/src/core/store/bundles/__workshop__/BundlesStoreStory.tsx create mode 100644 packages/sanity/src/core/store/bundles/__workshop__/ReleaseForm.tsx create mode 100644 packages/sanity/src/core/store/bundles/__workshop__/index.ts create mode 100644 packages/sanity/src/core/store/bundles/useBundleOperations.ts delete mode 100644 packages/sanity/src/core/store/bundles/useBundlesOperations.ts diff --git a/packages/sanity/src/core/store/bundles/__workshop__/BundlesStoreStory.tsx b/packages/sanity/src/core/store/bundles/__workshop__/BundlesStoreStory.tsx new file mode 100644 index 00000000000..d49f28fa10a --- /dev/null +++ b/packages/sanity/src/core/store/bundles/__workshop__/BundlesStoreStory.tsx @@ -0,0 +1,112 @@ +import {Card, Flex, Stack, Text} from '@sanity/ui' +import {type ComponentType, type FormEvent, useCallback, useState} from 'react' +import {AddonDatasetProvider, LoadingBlock} from 'sanity' + +import {Button} from '../../../../ui-components' +import {type BundleDocument} from '../types' +import {useBundleOperations} from '../useBundleOperations' +import {useBundlesStore} from '../useBundlesStore' +import {ReleaseForm} from './ReleaseForm' + +const WithAddonDatasetProvider =

(Component: ComponentType

): React.FC

=> { + // Function that returns the wrapped component + const WrappedComponent: React.FC

= (props) => ( + + + + ) + + // Setting a display name for the wrapped component + WrappedComponent.displayName = `WithAddonDatasetProvider(${Component.displayName || Component.name || 'Component'})` + + return WrappedComponent +} + +const initialValue = {name: '', title: '', tone: undefined, publishAt: undefined} +const BundlesStoreStory = () => { + const {data, loading} = useBundlesStore() + const {createBundle, deleteBundle} = useBundleOperations() + const [creating, setCreating] = useState(false) + const [deleting, setDeleting] = useState(null) + const [value, setValue] = useState>(initialValue) + const handleCreateBundle = useCallback( + async (event: FormEvent) => { + try { + event.preventDefault() + setCreating(true) + await createBundle(value) + setValue(initialValue) + } catch (err) { + console.error(err) + } finally { + setCreating(false) + } + }, + [createBundle, value], + ) + + const handleDeleteBundle = useCallback( + async (id: string) => { + try { + setDeleting(id) + await deleteBundle(id) + } catch (err) { + console.error(err) + } finally { + setDeleting(null) + } + }, + [deleteBundle], + ) + + return ( + + + +

+ + Create a new release + + +