Skip to content

Commit

Permalink
feat(corel): releases details screen. (#7092)
Browse files Browse the repository at this point in the history
* fix(corel): update releases router to use bundle name

* feat(corel): add bundle details screen

* chore: update @sanity/icons package

* feat(corel): releases DocumentRow updates

* fix(corel): update BundleIconEditorPicker types

* fix(corel): update bundles table test

* fix(corel): add filter in documents table

* chore(corel): update file location and naming

* feat(corel): add document actions to document row

* fix(core): remove references to bundles in useListener hook

* fix(corel): add search params to IntentLink

* chore(corel): remove router type change and restore previous pnpm-lock file

* fix(corel): update bundlesTable tests
  • Loading branch information
pedrobonamin authored and RitaDias committed Oct 2, 2024
1 parent d37a05c commit 8e6b6ee
Show file tree
Hide file tree
Showing 25 changed files with 1,209 additions and 148 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {DatePicker} from '../../../form/inputs/DateInputs/base/DatePicker'
import {getCalendarLabels} from '../../../form/inputs/DateInputs/utils'
import {type BundleDocument} from '../../../store/bundles/types'
import {isDraftOrPublished} from '../../util/dummyGetters'
import {BundleIconEditorPicker} from './BundleIconEditorPicker'
import {BundleIconEditorPicker, type BundleIconEditorPickerValue} from './BundleIconEditorPicker'

export function BundleForm(props: {
onChange: (params: Partial<BundleDocument>) => void
Expand All @@ -34,7 +34,7 @@ export function BundleForm(props: {
const {t: coreT} = useTranslation()
const calendarLabels: CalendarLabels = useMemo(() => getCalendarLabels(coreT), [coreT])

const iconValue: Partial<BundleDocument> = useMemo(
const iconValue: BundleIconEditorPickerValue = useMemo(
() => ({
icon: icon ?? 'cube',
hue: hue ?? 'gray',
Expand Down Expand Up @@ -98,7 +98,7 @@ export function BundleForm(props: {
)

const handleIconValueChange = useCallback(
(pickedIcon: Partial<BundleDocument>) => {
(pickedIcon: BundleIconEditorPickerValue) => {
onChange({...value, icon: pickedIcon.icon, hue: pickedIcon.hue})
},
[onChange, value],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ const IconPickerFlex = styled(Flex)`
max-width: 269px;
`

export interface BundleIconEditorPickerValue {
hue: BundleDocument['hue']
icon: BundleDocument['icon']
}

export function BundleIconEditorPicker(props: {
onChange: (value: Partial<BundleDocument>) => void
value: Partial<BundleDocument>
onChange: (value: BundleIconEditorPickerValue) => void
value: BundleIconEditorPickerValue
}): JSX.Element {
const {onChange, value} = props

Expand Down
241 changes: 241 additions & 0 deletions packages/sanity/src/core/hooks/useListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import {type ListenEvent, type ListenOptions} from '@sanity/client'
import {type SanityDocument} from '@sanity/types'
import {type Dispatch, useCallback, useMemo, useReducer, useRef, useState} from 'react'
import {useObservable} from 'react-rx'
import {catchError, concatMap, map, of, retry, timeout} from 'rxjs'
import {type SanityClient} from 'sanity'

interface DocumentAddedAction<T> {
payload: T
type: 'DOCUMENT_ADDED'
}

interface DocumentDeletedAction {
id: string
type: 'DOCUMENT_DELETED'
}

interface DocumentUpdatedAction<T> {
payload: T
type: 'DOCUMENT_UPDATED'
}

interface DocumentSetAction<T> {
payload: T[]
type: 'DOCUMENTS_SET'
}

interface DocumentReceivedAction<T> {
payload: T
type: 'DOCUMENT_RECEIVED'
}

export type documentsReducerAction<T> =
| DocumentAddedAction<T>
| DocumentDeletedAction
| DocumentUpdatedAction<T>
| DocumentSetAction<T>
| DocumentReceivedAction<T>

export interface documentsReducerState<T> {
documents: Map<string, T>
}

function createDocumentsSet<T extends SanityDocument>(documents: T[]) {
return documents.reduce((acc, doc) => {
acc.set(doc._id, doc)
return acc
}, new Map<string, T>())
}

export function documentsReducer<T extends SanityDocument>(
state: {documents: Map<string, T>},
action: documentsReducerAction<T>,
): documentsReducerState<T> {
switch (action.type) {
case 'DOCUMENTS_SET': {
const documentsById = createDocumentsSet(action.payload)

return {
...state,
documents: documentsById,
}
}

case 'DOCUMENT_ADDED': {
const addedDocument = action.payload as T
const currentDocuments = new Map(state.documents)
currentDocuments.set(addedDocument._id, addedDocument)

return {
...state,
documents: currentDocuments,
}
}

case 'DOCUMENT_RECEIVED': {
const receivedDocument = action.payload as T
const currentDocuments = new Map(state.documents)
currentDocuments.set(receivedDocument._id, receivedDocument)

return {
...state,
documents: currentDocuments,
}
}

case 'DOCUMENT_DELETED': {
const currentDocuments = new Map(state.documents)
currentDocuments.delete(action.id)

return {
...state,
documents: currentDocuments,
}
}

case 'DOCUMENT_UPDATED': {
const updateDocument = action.payload
const id = updateDocument._id as string
const currentDocuments = new Map(state.documents)
currentDocuments.set(id, updateDocument)

return {
...state,
documents: currentDocuments,
}
}

default:
return state
}
}

interface ListenerOptions<T> {
/**
* Groq query to listen to
*/
query: string
client: SanityClient | null
}
const INITIAL_STATE = {documents: new Map()}
const LISTEN_OPTIONS: ListenOptions = {
events: ['welcome', 'mutation', 'reconnect'],
includeResult: true,
visibility: 'query',
}

export function useListener<T extends SanityDocument>({
query,
client,
}: ListenerOptions<T>): {
documents: T[]
error: Error | null
loading: boolean
dispatch: Dispatch<documentsReducerAction<T>>
} {
const [state, dispatch] = useReducer(documentsReducer, INITIAL_STATE)
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<Error | null>(null)

const didInitialFetch = useRef<boolean>(false)

const initialFetch$ = useCallback(() => {
if (!client) {
return of(null) // emits null and completes if no client
}
return client.observable.fetch<T[]>(query).pipe(
timeout(10000), // 10s timeout
map((res) => {
dispatch({type: 'DOCUMENTS_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, query])

const handleListenerEvent = useCallback(
(event: ListenEvent<Record<string, T>>) => {
// Fetch all documents 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 documents again (above)
if (event.type === 'reconnect') {
setLoading(true)
didInitialFetch.current = false
}

// Handle mutations (create, update, delete) from the realtime listener
// and update the documents store accordingly
if (event.type === 'mutation' && didInitialFetch.current) {
if (event.transition === 'disappear') {
dispatch({type: 'DOCUMENT_DELETED', id: event.documentId})
}

if (event.transition === 'appear') {
const nextDocument = event.result as T | undefined

if (nextDocument) {
dispatch({type: 'DOCUMENT_RECEIVED', payload: nextDocument})
}
}

if (event.transition === 'update') {
const updatedDocument = event.result as T | undefined

if (updatedDocument) {
dispatch({type: 'DOCUMENT_UPDATED', payload: updatedDocument})
}
}
}
},
[initialFetch$],
)
const listener$ = useMemo(() => {
if (!client) return of()

return client.observable.listen(query, {}, LISTEN_OPTIONS).pipe(
map(handleListenerEvent),
catchError((err) => {
setError(err)
return of(err)
}),
)
}, [client, handleListenerEvent, query])

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 documentsAsArray = useMemo(
() => Array.from(state.documents.values()),
[state.documents],
) as T[]

return {
documents: documentsAsArray,
error,
loading,
dispatch,
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ArchiveIcon, EllipsisHorizontalIcon, TrashIcon} from '@sanity/icons'
import {ArchiveIcon, EllipsisHorizontalIcon, TrashIcon, UnarchiveIcon} from '@sanity/icons'
import {Button, Menu, MenuButton, MenuItem, Spinner, Text, useToast} from '@sanity/ui'
import {useState} from 'react'
import {useRouter} from 'sanity/router'
Expand Down Expand Up @@ -28,9 +28,9 @@ export const BundleMenuButton = ({bundle}: Props) => {
setDiscardStatus('discarding')
await deleteBundle(bundle)
setDiscardStatus('idle')
if (router.state.bundleId) {
if (router.state.bundleName) {
// navigate back to bundle overview
router.navigate({bundleId: undefined})
router.navigate({bundleName: undefined})
}
} catch (e) {
setDiscardStatus('error')
Expand Down Expand Up @@ -76,7 +76,7 @@ export const BundleMenuButton = ({bundle}: Props) => {
<Menu>
<MenuItem
onClick={handleOnToggleArchive}
icon={isBundleArchived ? ArchiveIcon : ArchiveIcon}
icon={isBundleArchived ? UnarchiveIcon : ArchiveIcon}
text={isBundleArchived ? 'Unarchive' : 'Archive'}
/>
<MenuItem onClick={() => setShowDiscardDialog(true)} icon={TrashIcon} text="Delete" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function BundleRow({bundle}: Props) {
<Card
as="a"
// navigate to bundle detail
onClick={() => router.navigate({bundleId: bundle._id})}
onClick={() => router.navigate({bundleName: bundle.name})}
padding={2}
radius={2}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {BundlesTable} from '../BundlesTable'

jest.mock('sanity/router', () => ({
...(jest.requireActual('sanity/router') || {}),
useRouter: jest.fn().mockReturnValue({state: {bundleId: '123'}, navigate: jest.fn()}),
useRouter: jest.fn().mockReturnValue({state: {bundleName: '123'}, navigate: jest.fn()}),
}))

jest.mock('../../../../store/bundles/useBundleOperations', () => ({
Expand Down Expand Up @@ -67,27 +67,32 @@ describe('BundlesTable', () => {

describe('A bundle row', () => {
it('should navigate to the bundle detail when clicked', async () => {
const bundles = [{_id: '123', title: 'Bundle 1'}] as BundleDocument[]
const bundles = [{_id: '123', title: 'Bundle 1', name: 'bundle-1'}] as BundleDocument[]
await renderBundlesTable(bundles)

const bundleRow = screen.getAllByTestId('bundle-row')[0]
fireEvent.click(within(bundleRow).getByText('Bundle 1'))

expect(useRouter().navigate).toHaveBeenCalledWith({bundleId: '123'})
expect(useRouter().navigate).toHaveBeenCalledWith({bundleName: 'bundle-1'})
})

it('should delete bundle when menu button is clicked', async () => {
const bundles = [{_id: '123', title: 'Bundle 1'}] as BundleDocument[]
const bundles = [{_id: '123', title: 'Bundle 1', name: 'bundle-1'}] as BundleDocument[]
await renderBundlesTable(bundles)

const bundleRow = screen.getAllByTestId('bundle-row')[0]
fireEvent.click(within(bundleRow).getByLabelText('Release menu'))

fireEvent.click(screen.getByText('Delete'))
fireEvent.click(screen.getByText('Confirm'))

await waitFor(() => {
expect(useBundleOperations().deleteBundle).toHaveBeenCalledWith('123')
expect(useRouter().navigate).toHaveBeenCalledWith({bundleId: undefined})
expect(useBundleOperations().deleteBundle).toHaveBeenCalledWith({
_id: '123',
name: 'bundle-1',
title: 'Bundle 1',
})
expect(useRouter().navigate).toHaveBeenCalledWith({bundleName: undefined})
})
})
})
Expand Down
29 changes: 29 additions & 0 deletions packages/sanity/src/core/releases/components/Chip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {Box, Card, Flex, Text} from '@sanity/ui'
import {type ReactNode} from 'react'

export function Chip(props: {avatar?: ReactNode; text: ReactNode; icon?: ReactNode}) {
const {avatar, text, icon} = props

return (
<Card muted radius="full">
<Flex align={'center'}>
{icon && (
<Box padding={1} marginLeft={1}>
{icon}
</Box>
)}
{avatar && (
<Box padding={1}>
<div style={{margin: -1}}>{avatar}</div>
</Box>
)}

<Box padding={2} paddingLeft={avatar ? 1 : undefined}>
<Text muted size={1}>
{text}
</Text>
</Box>
</Flex>
</Card>
)
}
Loading

0 comments on commit 8e6b6ee

Please sign in to comment.