UNPKG

sanity-plugin-media

Version:

This version of `sanity-plugin-media` is for Sanity Studio V3.

823 lines (754 loc) 24.8 kB
import {createSelector, createSlice, type PayloadAction} from '@reduxjs/toolkit' import type {ClientError, Patch, Transaction} from '@sanity/client' import type { Asset, AssetItem, AssetType, BrowserView, HttpError, MyEpic, Order, OrderDirection, Tag } from '../../types' import groq from 'groq' import {nanoid} from 'nanoid' import type {Selector} from 'react-redux' import {ofType} from 'redux-observable' import {EMPTY, from, of} from 'rxjs' import { bufferTime, catchError, debounceTime, filter, mergeMap, switchMap, withLatestFrom } from 'rxjs/operators' import {getOrderTitle} from '../../config/orders' import {ORDER_OPTIONS} from '../../constants' import debugThrottle from '../../operators/debugThrottle' import constructFilter from '../../utils/constructFilter' import {searchActions} from '../search' import type {RootReducerState} from '../types' import {UPLOADS_ACTIONS} from '../uploads/actions' import {ASSETS_ACTIONS} from './actions' type ItemError = { description: string id: string referencingIDs: string[] type: string // 'documentHasExistingReferencesError' } export type AssetsReducerState = { allIds: string[] assetTypes: AssetType[] byIds: Record<string, AssetItem> fetchCount: number fetching: boolean fetchingError?: HttpError lastPicked?: string order: Order pageIndex: number pageSize: number view: BrowserView // totalCount: number } const defaultOrder = ORDER_OPTIONS[0] as { direction: OrderDirection field: string } /** * NOTE: * `fetchCount` returns the number of items retrieved in the most recent fetch. * This is a temporary workaround to be able to determine when there are no more items to retrieve. * Typically this would be done by deriving the total number of assets upfront, but currently such * queries in GROQ aren't fast enough to use on large datasets (1000s of entries). * * TODO: * When the query engine has been improved and above queries are faster, remove all instances of * of `fetchCount` and reinstate `totalCount` across the board. */ export const initialState = { allIds: [], assetTypes: [], byIds: {}, fetchCount: -1, fetching: false, fetchingError: undefined, lastPicked: undefined, order: { direction: defaultOrder.direction, field: defaultOrder.field, title: getOrderTitle(defaultOrder.field, defaultOrder.direction) }, pageIndex: 0, pageSize: 100, // totalCount: -1, view: 'grid' } as AssetsReducerState const assetsSlice = createSlice({ name: 'assets', initialState, extraReducers: builder => { builder // .addCase(UPLOADS_ACTIONS.uploadComplete, (state, action) => { const {asset} = action.payload state.byIds[asset._id] = { _type: 'asset', asset: asset as Asset, picked: false, updating: false } }) .addCase(ASSETS_ACTIONS.tagsAddComplete, (state, action) => { const {assets} = action.payload assets.forEach(asset => { state.byIds[asset.asset._id].updating = false }) }) .addCase(ASSETS_ACTIONS.tagsAddError, (state, action) => { const {assets} = action.payload assets.forEach(asset => { state.byIds[asset.asset._id].updating = false }) }) .addCase(ASSETS_ACTIONS.tagsAddRequest, (state, action) => { const {assets} = action.payload assets.forEach(asset => { state.byIds[asset.asset._id].updating = true }) }) .addCase(ASSETS_ACTIONS.tagsRemoveComplete, (state, action) => { const {assets} = action.payload assets.forEach(asset => { state.byIds[asset.asset._id].updating = false }) }) .addCase(ASSETS_ACTIONS.tagsRemoveError, (state, action) => { const {assets} = action.payload assets.forEach(asset => { state.byIds[asset.asset._id].updating = false }) }) .addCase(ASSETS_ACTIONS.tagsRemoveRequest, (state, action) => { const {assets} = action.payload assets.forEach(asset => { state.byIds[asset.asset._id].updating = true }) }) }, reducers: { // Clear asset order clear(state) { state.allIds = [] }, // Remove assets and update page index deleteComplete(state, action: PayloadAction<{assetIds: string[]}>) { const {assetIds} = action.payload assetIds?.forEach(id => { const deleteIndex = state.allIds.indexOf(id) if (deleteIndex >= 0) { state.allIds.splice(deleteIndex, 1) } delete state.byIds[id] }) state.pageIndex = Math.floor(state.allIds.length / state.pageSize) - 1 }, deleteError(state, action: PayloadAction<{assetIds: string[]; error: ClientError}>) { const {assetIds, error} = action.payload const itemErrors: ItemError[] = error?.response?.body?.error?.items?.map( (item: any) => item.error ) assetIds?.forEach(id => { state.byIds[id].updating = false }) itemErrors?.forEach(item => { state.byIds[item.id].error = item.description }) }, deleteRequest(state, action: PayloadAction<{assets: Asset[]; closeDialogId?: string}>) { const {assets} = action.payload assets.forEach(asset => { state.byIds[asset?._id].updating = true }) Object.keys(state.byIds).forEach(key => { delete state.byIds[key].error }) }, fetchComplete(state, action: PayloadAction<{assets: Asset[]}>) { const assets = action.payload?.assets || [] if (assets) { assets.forEach(asset => { if (!state.allIds.includes(asset._id)) { state.allIds.push(asset._id) } state.byIds[asset._id] = { _type: 'asset', asset: asset, picked: false, updating: false } }) } state.fetching = false state.fetchCount = assets.length || 0 delete state.fetchingError }, fetchError(state, action: PayloadAction<HttpError>) { const error = action.payload state.fetching = false state.fetchingError = error }, fetchRequest: { reducer: (state, _action: PayloadAction<{params: Record<string, any>; query: string}>) => { state.fetching = true delete state.fetchingError }, prepare: ({ params = {}, queryFilter, selector = ``, sort = groq`order(_updatedAt desc)` }: { params?: Record<string, any> queryFilter: string replace?: boolean selector?: string sort?: string }) => { const pipe = sort || selector ? '|' : '' // Construct query const query = groq` { "items": *[${queryFilter}] { _id, _type, _createdAt, _updatedAt, altText, creditLine, description, extension, metadata { dimensions, exif, isOpaque, }, mimeType, opt { media }, originalFilename, size, source { name }, title, url } ${pipe} ${sort} ${selector}, } ` return {payload: {params, query}} } }, insertUploads(state, action: PayloadAction<{results: Record<string, string | null>}>) { const {results} = action.payload Object.entries(results).forEach(([hash, assetId]) => { if (assetId && !state.allIds.includes(hash)) { state.allIds.push(assetId) } }) }, listenerCreateQueue(_state, _action: PayloadAction<{asset: Asset}>) { // }, listenerCreateQueueComplete(state, action: PayloadAction<{assets: Asset[]}>) { const {assets} = action.payload assets?.forEach(asset => { if (state.byIds[asset?._id]?.asset) { state.byIds[asset._id].asset = asset } }) }, listenerDeleteQueue(_state, _action: PayloadAction<{assetId: string}>) { // }, listenerDeleteQueueComplete(state, action: PayloadAction<{assetIds: string[]}>) { const {assetIds} = action.payload assetIds?.forEach(assetId => { const deleteIndex = state.allIds.indexOf(assetId) if (deleteIndex >= 0) { state.allIds.splice(deleteIndex, 1) } delete state.byIds[assetId] }) }, listenerUpdateQueue(_state, _action: PayloadAction<{asset: Asset}>) { // }, listenerUpdateQueueComplete(state, action: PayloadAction<{assets: Asset[]}>) { const {assets} = action.payload assets?.forEach(asset => { if (state.byIds[asset?._id]?.asset) { state.byIds[asset._id].asset = asset } }) }, loadNextPage() { // }, loadPageIndex(state, action: PayloadAction<{pageIndex: number}>) { // state.pageIndex = action.payload.pageIndex }, orderSet(state, action: PayloadAction<{order: Order}>) { state.order = action.payload?.order state.pageIndex = 0 }, pick(state, action: PayloadAction<{assetId: string; picked: boolean}>) { const {assetId, picked} = action.payload state.byIds[assetId].picked = picked state.lastPicked = picked ? assetId : undefined }, pickAll(state) { state.allIds.forEach(id => { state.byIds[id].picked = true }) }, pickClear(state) { state.lastPicked = undefined Object.values(state.byIds).forEach(asset => { state.byIds[asset.asset._id].picked = false }) }, pickRange(state, action: PayloadAction<{endId: string; startId: string}>) { const startIndex = state.allIds.findIndex(id => id === action.payload.startId) const endIndex = state.allIds.findIndex(id => id === action.payload.endId) // Sort numerically, ascending order const indices = [startIndex, endIndex].sort((a, b) => a - b) state.allIds.slice(indices[0], indices[1] + 1).forEach(key => { state.byIds[key].picked = true }) state.lastPicked = state.allIds[endIndex] }, sort(state) { state.allIds.sort((a, b) => { const tagA = state.byIds[a].asset[state.order.field] const tagB = state.byIds[b].asset[state.order.field] if (tagA < tagB) { return state.order.direction === 'asc' ? -1 : 1 } else if (tagA > tagB) { return state.order.direction === 'asc' ? 1 : -1 } return 0 }) }, updateComplete(state, action: PayloadAction<{asset: Asset; closeDialogId?: string}>) { const {asset} = action.payload state.byIds[asset._id].updating = false state.byIds[asset._id].asset = asset }, updateError(state, action: PayloadAction<{asset: Asset; error: HttpError}>) { const {asset, error} = action.payload const assetId = asset?._id state.byIds[assetId].error = error.message state.byIds[assetId].updating = false }, updateRequest( state, action: PayloadAction<{asset: Asset; closeDialogId?: string; formData: Record<string, any>}> ) { const assetId = action.payload?.asset?._id state.byIds[assetId].updating = true }, viewSet(state, action: PayloadAction<{view: BrowserView}>) { state.view = action.payload?.view } } }) // Epics export const assetsDeleteEpic: MyEpic = (action$, _state$, {client}) => action$.pipe( filter(assetsActions.deleteRequest.match), mergeMap(action => { const {assets} = action.payload const assetIds = assets.map(asset => asset._id) return of(assets).pipe( mergeMap(() => client.observable.delete({ query: groq`*[_id in ${JSON.stringify(assetIds)}]` }) ), mergeMap(() => of(assetsActions.deleteComplete({assetIds}))), catchError((error: ClientError) => { return of(assetsActions.deleteError({assetIds, error})) }) ) }) ) export const assetsFetchEpic: MyEpic = (action$, state$, {client}) => action$.pipe( filter(assetsActions.fetchRequest.match), withLatestFrom(state$), switchMap(([action, state]) => { const params = action.payload?.params const query = action.payload?.query return of(action).pipe( debugThrottle(state.debug.badConnection), mergeMap(() => client.observable.fetch<{ items: Asset[] }>(query, params) ), mergeMap(result => { const { items // totalCount } = result return of(assetsActions.fetchComplete({assets: items})) }), catchError((error: ClientError) => of( assetsActions.fetchError({ message: error?.message || 'Internal error', statusCode: error?.statusCode || 500 }) ) ) ) }) ) export const assetsFetchPageIndexEpic: MyEpic = (action$, state$) => action$.pipe( filter(assetsActions.loadPageIndex.match), withLatestFrom(state$), switchMap(([action, state]) => { const pageSize = state.assets.pageSize const start = action.payload.pageIndex * pageSize const end = start + pageSize // Document ID can be null when operating on pristine / unsaved drafts const documentId = state?.selected.document?._id const documentAssetIds = state?.selected?.documentAssetIds const constructedFilter = constructFilter({ assetTypes: state.assets.assetTypes, searchFacets: state.search.facets, searchQuery: state.search.query }) const params = { ...(documentId ? {documentId} : {}), documentAssetIds } return of( assetsActions.fetchRequest({ params, queryFilter: constructedFilter, selector: groq`[${start}...${end}]`, sort: groq`order(${state.assets?.order?.field} ${state.assets?.order?.direction})` }) ) }) ) export const assetsFetchNextPageEpic: MyEpic = (action$, state$) => action$.pipe( filter(assetsActions.loadNextPage.match), withLatestFrom(state$), switchMap(([_action, state]) => of(assetsActions.loadPageIndex({pageIndex: state.assets.pageIndex + 1})) ) ) export const assetsFetchAfterDeleteAllEpic: MyEpic = (action$, state$) => action$.pipe( filter(assetsActions.deleteComplete.match), withLatestFrom(state$), switchMap(([_action, state]) => { if (state.assets.allIds.length === 0) { const nextPageIndex = Math.floor(state.assets.allIds.length / state.assets.pageSize) return of(assetsActions.loadPageIndex({pageIndex: nextPageIndex})) } return EMPTY }) ) const filterAssetWithoutTag = (tag: Tag) => (asset: AssetItem) => { const tagIndex = asset?.asset?.opt?.media?.tags?.findIndex(t => t._ref === tag?._id) ?? -1 return tagIndex < 0 } const patchOperationTagAppend = ({tag}: {tag: Tag}) => (patch: Patch) => patch .setIfMissing({opt: {}}) .setIfMissing({'opt.media': {}}) .setIfMissing({'opt.media.tags': []}) .append('opt.media.tags', [{_key: nanoid(), _ref: tag?._id, _type: 'reference', _weak: true}]) const patchOperationTagUnset = ({asset, tag}: {asset: AssetItem; tag: Tag}) => (patch: Patch) => patch.ifRevisionId(asset?.asset?._rev).unset([`opt.media.tags[_ref == "${tag._id}"]`]) export const assetsRemoveTagsEpic: MyEpic = (action$, state$, {client}) => { return action$.pipe( filter(ASSETS_ACTIONS.tagsAddRequest.match), withLatestFrom(state$), mergeMap(([action, state]) => { const {assets, tag} = action.payload return of(action).pipe( // Optionally throttle debugThrottle(state.debug.badConnection), // Add tag references to all picked assets mergeMap(() => { const pickedAssets = selectAssetsPicked(state) // Filter out picked assets which already include tag const pickedAssetsFiltered = pickedAssets?.filter(filterAssetWithoutTag(tag)) const transaction: Transaction = pickedAssetsFiltered.reduce( (tx, pickedAsset) => tx.patch(pickedAsset?.asset?._id, patchOperationTagAppend({tag})), client.transaction() ) return from(transaction.commit()) }), // Dispatch complete action mergeMap(() => of(ASSETS_ACTIONS.tagsAddComplete({assets, tag}))), catchError((error: ClientError) => of( ASSETS_ACTIONS.tagsAddError({ assets, error: { message: error?.message || 'Internal error', statusCode: error?.statusCode || 500 }, tag }) ) ) ) }) ) } export const assetsOrderSetEpic: MyEpic = action$ => action$.pipe( filter(assetsActions.orderSet.match), mergeMap(() => { return of( assetsActions.clear(), // assetsActions.loadPageIndex({pageIndex: 0}) ) }) ) export const assetsSearchEpic: MyEpic = action$ => action$.pipe( ofType( searchActions.facetsAdd.type, searchActions.facetsClear.type, searchActions.facetsRemoveById.type, searchActions.facetsRemoveByName.type, searchActions.facetsRemoveByTag.type, searchActions.facetsUpdate.type, searchActions.facetsUpdateById.type, searchActions.querySet.type ), debounceTime(400), mergeMap(() => { return of( assetsActions.clear(), // assetsActions.loadPageIndex({pageIndex: 0}) ) }) ) export const assetsListenerCreateQueueEpic: MyEpic = action$ => action$.pipe( filter(assetsActions.listenerCreateQueue.match), bufferTime(2000), filter(actions => actions.length > 0), mergeMap(actions => { const assets = actions?.map(action => action.payload.asset) return of(assetsActions.listenerCreateQueueComplete({assets})) }) ) export const assetsListenerDeleteQueueEpic: MyEpic = action$ => action$.pipe( filter(assetsActions.listenerDeleteQueue.match), bufferTime(2000), filter(actions => actions.length > 0), mergeMap(actions => { const assetIds = actions?.map(action => action.payload.assetId) return of(assetsActions.listenerDeleteQueueComplete({assetIds})) }) ) export const assetsListenerUpdateQueueEpic: MyEpic = action$ => action$.pipe( filter(assetsActions.listenerUpdateQueue.match), bufferTime(2000), filter(actions => actions.length > 0), mergeMap(actions => { const assets = actions?.map(action => action.payload.asset) return of(assetsActions.listenerUpdateQueueComplete({assets})) }) ) // Re-sort on all updates (immediate and batched listener events) export const assetsSortEpic: MyEpic = action$ => action$.pipe( ofType( assetsActions.insertUploads.type, assetsActions.listenerUpdateQueueComplete.type, assetsActions.updateComplete.type ), mergeMap(() => of(assetsActions.sort())) ) export const assetsTagsAddEpic: MyEpic = (action$, state$, {client}) => { return action$.pipe( filter(ASSETS_ACTIONS.tagsAddRequest.match), withLatestFrom(state$), mergeMap(([action, state]) => { const {assets, tag} = action.payload return of(action).pipe( // Optionally throttle debugThrottle(state.debug.badConnection), // Add tag references to all picked assets mergeMap(() => { const pickedAssets = selectAssetsPicked(state) // Filter out picked assets which already include tag const pickedAssetsFiltered = pickedAssets?.filter(filterAssetWithoutTag(tag)) const transaction: Transaction = pickedAssetsFiltered.reduce( (tx, pickedAsset) => tx.patch(pickedAsset?.asset?._id, patchOperationTagAppend({tag})), client.transaction() ) return from(transaction.commit()) }), // Dispatch complete action mergeMap(() => of(ASSETS_ACTIONS.tagsAddComplete({assets, tag}))), catchError((error: ClientError) => of( ASSETS_ACTIONS.tagsAddError({ assets, error: { message: error?.message || 'Internal error', statusCode: error?.statusCode || 500 }, tag }) ) ) ) }) ) } export const assetsTagsRemoveEpic: MyEpic = (action$, state$, {client}) => { return action$.pipe( filter(ASSETS_ACTIONS.tagsRemoveRequest.match), withLatestFrom(state$), mergeMap(([action, state]) => { const {assets, tag} = action.payload return of(action).pipe( // Optionally throttle debugThrottle(state.debug.badConnection), // Remove tag references from all picked assets mergeMap(() => { const pickedAssets = selectAssetsPicked(state) const transaction: Transaction = pickedAssets.reduce( (tx, pickedAsset) => tx.patch(pickedAsset?.asset?._id, patchOperationTagUnset({asset: pickedAsset, tag})), client.transaction() ) return from(transaction.commit()) }), // Dispatch complete action mergeMap(() => of(ASSETS_ACTIONS.tagsRemoveComplete({assets, tag}))), catchError((error: ClientError) => of( ASSETS_ACTIONS.tagsRemoveError({ assets, error: { message: error?.message || 'Internal error', statusCode: error?.statusCode || 500 }, tag }) ) ) ) }) ) } export const assetsUnpickEpic: MyEpic = action$ => action$.pipe( ofType( assetsActions.orderSet.type, assetsActions.viewSet.type, searchActions.facetsAdd.type, searchActions.facetsClear.type, searchActions.facetsRemoveById.type, searchActions.facetsRemoveByName.type, searchActions.facetsRemoveByTag.type, searchActions.facetsUpdate.type, searchActions.facetsUpdateById.type, searchActions.querySet.type ), mergeMap(() => { return of(assetsActions.pickClear()) }) ) export const assetsUpdateEpic: MyEpic = (action$, state$, {client}) => action$.pipe( filter(assetsActions.updateRequest.match), withLatestFrom(state$), mergeMap(([action, state]) => { const {asset, closeDialogId, formData} = action.payload return of(action).pipe( debugThrottle(state.debug.badConnection), mergeMap(() => from( client .patch(asset._id) .setIfMissing({opt: {}}) .setIfMissing({'opt.media': {}}) .set(formData) .commit() ) ), mergeMap((updatedAsset: any) => of( assetsActions.updateComplete({ asset: updatedAsset, closeDialogId }) ) ), catchError((error: ClientError) => of( assetsActions.updateError({ asset, error: { message: error?.message || 'Internal error', statusCode: error?.statusCode || 500 } }) ) ) ) }) ) // Selectors const selectAssetsByIds = (state: RootReducerState) => state.assets.byIds const selectAssetsAllIds = (state: RootReducerState) => state.assets.allIds export const selectAssetById = createSelector( [ (state: RootReducerState) => state.assets.byIds, (_state: RootReducerState, assetId: string) => assetId ], (byIds, assetId) => { const asset = byIds[assetId] return asset ? asset : undefined } ) export const selectAssets: Selector<RootReducerState, AssetItem[]> = createSelector( [selectAssetsByIds, selectAssetsAllIds], (byIds, allIds) => allIds.map(id => byIds[id]) ) export const selectAssetsLength = createSelector([selectAssets], assets => assets.length) export const selectAssetsPicked = createSelector([selectAssets], assets => assets.filter(item => item?.picked) ) export const selectAssetsPickedLength = createSelector( [selectAssetsPicked], assetsPicked => assetsPicked.length ) export const assetsActions = {...assetsSlice.actions} export default assetsSlice.reducer