From 42b8a2cceea0ce532fb8b1955079f67cf562a549 Mon Sep 17 00:00:00 2001
From: Pedro Bonamin <46196328+pedrobonamin@users.noreply.github.com>
Date: Mon, 1 Jul 2024 12:44:59 +0200
Subject: [PATCH] feat(corel): integrate bundles store (#7040)
* feat(corel): add bundles store
---
.../__workshop__/BundlesStoreStory.tsx | 110 +++++++++++++
.../bundles/__workshop__/ReleaseForm.tsx | 133 ++++++++++++++++
.../core/store/bundles/__workshop__/index.ts | 14 ++
.../sanity/src/core/store/bundles/index.ts | 1 +
.../sanity/src/core/store/bundles/reducer.ts | 109 +++++++++++++
.../sanity/src/core/store/bundles/types.ts | 12 ++
.../core/store/bundles/useBundleOperations.ts | 50 ++++++
.../src/core/store/bundles/useBundlesStore.ts | 149 ++++++++++++++++++
8 files changed, 578 insertions(+)
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/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/useBundleOperations.ts
create mode 100644 packages/sanity/src/core/store/bundles/useBundlesStore.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..cd554fe5b86
--- /dev/null
+++ b/packages/sanity/src/core/store/bundles/__workshop__/BundlesStoreStory.tsx
@@ -0,0 +1,110 @@
+import {Card, Flex, Stack, Text} from '@sanity/ui'
+import {type ComponentType, type FormEvent, useCallback, useState} from 'react'
+
+import {Button} from '../../../../ui-components'
+import {LoadingBlock} from '../../../components/loadingBlock/LoadingBlock'
+import {AddonDatasetProvider} from '../../../studio/addonDataset/AddonDatasetProvider'
+import {type BundleDocument} from '../types'
+import {useBundleOperations} from '../useBundleOperations'
+import {useBundlesStore} from '../useBundlesStore'
+import {ReleaseForm} from './ReleaseForm'
+
+const WithAddonDatasetProvider =
(Component: ComponentType
): React.FC
=> {
+ const WrappedComponent: React.FC
= (props) => (
+
+
+
+ )
+ 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 (
+
+
+
+
+
+
+
+
Data
+ {loading ?
:
{JSON.stringify(data, null, 2)}
}
+
+
+
+
+
+ {data?.map((bundle) => (
+
+
+ {bundle.name}
+
+
+ ))}
+
+
+
+ )
+}
+
+export default WithAddonDatasetProvider(BundlesStoreStory)
diff --git a/packages/sanity/src/core/store/bundles/__workshop__/ReleaseForm.tsx b/packages/sanity/src/core/store/bundles/__workshop__/ReleaseForm.tsx
new file mode 100644
index 00000000000..34f601d5880
--- /dev/null
+++ b/packages/sanity/src/core/store/bundles/__workshop__/ReleaseForm.tsx
@@ -0,0 +1,133 @@
+import {COLOR_HUES} from '@sanity/color'
+import {CalendarIcon} from '@sanity/icons'
+import {
+ Box,
+ type ButtonTone,
+ Card,
+ Flex,
+ Select,
+ Stack,
+ Text,
+ TextArea,
+ TextInput,
+} from '@sanity/ui'
+import speakingurl from 'speakingurl'
+
+import {Button} from '../../../../ui-components/button'
+import {type BundleDocument} from '../types'
+
+function toSlug(value: string): string {
+ return speakingurl(value, {truncate: 200, symbols: true})
+}
+
+/**
+ * Copy from Prototype, not a final or complete working implementation.
+ */
+export function ReleaseForm(props: {
+ onChange: (params: Partial) => void
+ value: Partial
+}) {
+ const {onChange, value} = props
+
+ const handleReleaseTitleChange = (event: React.ChangeEvent) => {
+ const v = event.target.value
+
+ onChange({...value, title: v, name: toSlug(v)})
+ }
+
+ const handleReleaseDescriptionChange = (event: React.ChangeEvent) => {
+ const v = event.target.value
+
+ onChange({...value, description: v || undefined})
+ }
+
+ const handleReleaseToneChange = (event: React.ChangeEvent) => {
+ onChange({...value, tone: (event.target.value || undefined) as ButtonTone | undefined})
+ }
+
+ const handleReleasePublishAtChange = (event: React.ChangeEvent) => {
+ const v = event.target.value
+
+ onChange({...value, publishAt: v})
+ }
+
+ return (
+
+
+
+ Title
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+ Schedule for publishing at
+
+
+
+
+ }
+ value={value.publishAt || ''}
+ />
+
+
+
+
+ Color
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/sanity/src/core/store/bundles/__workshop__/index.ts b/packages/sanity/src/core/store/bundles/__workshop__/index.ts
new file mode 100644
index 00000000000..a1ffae0f240
--- /dev/null
+++ b/packages/sanity/src/core/store/bundles/__workshop__/index.ts
@@ -0,0 +1,14 @@
+import {defineScope} from '@sanity/ui-workshop'
+import {lazy} from 'react'
+
+export default defineScope({
+ name: 'core/bundles',
+ title: 'bundles',
+ stories: [
+ {
+ name: 'bundles-store',
+ title: 'BundlesStore',
+ component: lazy(() => import('./BundlesStoreStory')),
+ },
+ ],
+})
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..631664b82e6
--- /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 {
+ payload: 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.payload)
+
+ 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..7a873baf8dc
--- /dev/null
+++ b/packages/sanity/src/core/store/bundles/types.ts
@@ -0,0 +1,12 @@
+import {type SanityDocument} from '@sanity/types'
+import {type ButtonTone} from '@sanity/ui'
+
+export interface BundleDocument extends SanityDocument {
+ _type: 'bundle'
+ title: string
+ name: string
+ description?: string
+ tone?: ButtonTone
+ icon?: string
+ authorId: string
+}
diff --git a/packages/sanity/src/core/store/bundles/useBundleOperations.ts b/packages/sanity/src/core/store/bundles/useBundleOperations.ts
new file mode 100644
index 00000000000..7d37cc1d78b
--- /dev/null
+++ b/packages/sanity/src/core/store/bundles/useBundleOperations.ts
@@ -0,0 +1,50 @@
+import {uuid} from '@sanity/uuid'
+import {useCallback} from 'react'
+
+import {useAddonDataset} from '../../studio/addonDataset/useAddonDataset'
+import {type BundleDocument} from './types'
+
+// WIP - Raw implementation for initial testing purposes
+export function useBundleOperations() {
+ const {client} = useAddonDataset()
+
+ const handleCreateBundle = useCallback(
+ async (bundle: Partial) => {
+ const document = {
+ ...bundle,
+ _type: 'bundle',
+ _id: bundle._id ?? uuid(),
+ } as BundleDocument
+ const res = await client?.createIfNotExists(document)
+ return res
+ },
+ [client],
+ )
+
+ const handleDeleteBundle = useCallback(
+ async (id: string) => {
+ const res = await client?.delete(id)
+ return res
+ },
+ [client],
+ )
+
+ const handleUpdateBundle = useCallback(
+ async (bundle: BundleDocument) => {
+ const document = {
+ ...bundle,
+ _type: 'bundle',
+ } as BundleDocument
+
+ const res = await client?.patch(bundle._id).set(document).commit()
+ return res
+ },
+ [client],
+ )
+
+ return {
+ createBundle: handleCreateBundle,
+ deleteBundle: handleDeleteBundle,
+ updateBundle: handleUpdateBundle,
+ }
+}
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..dc1820638a9
--- /dev/null
+++ b/packages/sanity/src/core/store/bundles/useBundlesStore.ts
@@ -0,0 +1,149 @@
+import {type ListenEvent, type ListenOptions} from '@sanity/client'
+import {useCallback, useMemo, useReducer, useRef, useState} from 'react'
+import {useObservable} from 'react-rx'
+import {catchError, concatMap, map, of, retry, timeout} 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(true)
+ const [error, setError] = useState(null)
+
+ const didInitialFetch = useRef(false)
+
+ const initialFetch$ = useCallback(() => {
+ if (!client) {
+ 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', payload: 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
+ }),
+ )
+ }, [client])
+
+ const handleListenerEvent = useCallback(
+ (event: ListenEvent>) => {
+ // Fetch all bundles on initial connection
+ if (event.type === 'welcome' && !didInitialFetch.current) {
+ // 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.
+ // 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' && 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})
+ }
+ }
+
+ 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(
+ map(handleListenerEvent),
+ catchError((err) => {
+ setError(err)
+ return of(err)
+ }),
+ )
+
+ return events$ // as Observable>>
+ }, [client, handleListenerEvent])
+
+ const observable = useMemo(() => {
+ if (!client) return of(null) // emits null and completes if no client
+ return initialFetch$().pipe(concatMap(() => listener$))
+ }, [initialFetch$, listener$, client])
+
+ useObservable(observable)
+
+ const bundlesAsArray = useMemo(() => Array.from(state.bundles.values()), [state.bundles])
+
+ return {
+ data: bundlesAsArray,
+ dispatch,
+ error,
+ loading,
+ }
+}