diff --git a/packages/core/src/document/applyDocumentActions.test.ts b/packages/core/src/document/applyDocumentActions.test.ts index 9b502b907..50b253286 100644 --- a/packages/core/src/document/applyDocumentActions.test.ts +++ b/packages/core/src/document/applyDocumentActions.test.ts @@ -3,6 +3,7 @@ import {type SanityDocument} from '@sanity/types' import {Subject} from 'rxjs' import {describe, expect, it} from 'vitest' +import {sourceFor} from '../config/sanityConfig' import {bindActionByDataset} from '../store/createActionBinder' import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance' import {} from '../store/createStateSourceAction' @@ -31,6 +32,7 @@ const exampleDoc: SanityDocument = { } describe('applyDocumentActions', () => { + const source = sourceFor({projectId: 'p', dataset: 'd'}) let state: StoreState let instance: SanityInstance let eventsSubject: Subject @@ -75,6 +77,7 @@ describe('applyDocumentActions', () => { const applyPromise = applyDocumentActions(instance, { actions: [action], transactionId: 'txn-success', + source, }) const appliedTx: AppliedTransaction = { @@ -132,6 +135,7 @@ describe('applyDocumentActions', () => { const applyPromise = applyDocumentActions(instance, { actions: [action], transactionId: 'txn-error', + source, }) const errorEvent: DocumentEvent = { @@ -165,6 +169,7 @@ describe('applyDocumentActions', () => { const applyPromise = applyDocumentActions(childInstance, { actions: [action], transactionId: 'txn-child-match', + source, }) // Simulate an applied transaction on the parent's instance diff --git a/packages/core/src/document/applyDocumentActions.ts b/packages/core/src/document/applyDocumentActions.ts index 0d6351b8b..b44e873e5 100644 --- a/packages/core/src/document/applyDocumentActions.ts +++ b/packages/core/src/document/applyDocumentActions.ts @@ -2,6 +2,7 @@ import {type SanityClient} from '@sanity/client' import {type SanityDocument} from 'groq' import {distinctUntilChanged, filter, first, firstValueFrom, map, race} from 'rxjs' +import {type DocumentSource} from '../config/sanityConfig' import {bindActionByDataset} from '../store/createActionBinder' import {type SanityInstance} from '../store/createSanityInstance' import {type StoreContext} from '../store/defineStore' @@ -37,6 +38,11 @@ export interface ApplyDocumentActionsOptions { * Set this to true to prevent this action from being batched with others. */ disableBatching?: boolean + + /** + * The source to apply to. + */ + source: DocumentSource } /** @beta */ diff --git a/packages/core/src/document/documentStore.test.ts b/packages/core/src/document/documentStore.test.ts index 6c7ed3069..f6404cc76 100644 --- a/packages/core/src/document/documentStore.test.ts +++ b/packages/core/src/document/documentStore.test.ts @@ -19,6 +19,7 @@ import {afterEach, beforeEach, expect, it, vi} from 'vitest' import {getClientState} from '../client/clientStore' import {createDocumentHandle} from '../config/handles' +import {sourceFor} from '../config/sanityConfig' import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance' import {type StateSource} from '../store/createStateSourceAction' import {getDraftId, getPublishedId} from '../utils/ids' @@ -65,6 +66,10 @@ let instance: SanityInstance let instance1: SanityInstance let instance2: SanityInstance +const source = sourceFor({projectId: 'p', dataset: 'd'}) +const source1 = sourceFor({projectId: 'p', dataset: 'd1'}) +const source2 = sourceFor({projectId: 'p', dataset: 'd2'}) + beforeEach(() => { instance = createSanityInstance({projectId: 'p', dataset: 'd'}) // test uses two instances that share the same in-memory dataset, but separate @@ -90,7 +95,7 @@ it('creates, edits, and publishes a document', async () => { const unsubscribe = documentState.subscribe() // Create a new document - const {appeared} = await applyDocumentActions(instance, {actions: [createDocument(doc)]}) + const {appeared} = await applyDocumentActions(instance, {actions: [createDocument(doc)], source}) expect(appeared).toContain(getDraftId(doc.documentId)) let currentDoc = documentState.getCurrent() @@ -99,6 +104,7 @@ it('creates, edits, and publishes a document', async () => { // Edit the document – add a title await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'My First Article'}})], + source, }) currentDoc = documentState.getCurrent() expect(currentDoc?.title).toEqual('My First Article') @@ -106,6 +112,7 @@ it('creates, edits, and publishes a document', async () => { // Publish the document; the resulting transactionId is used as the new _rev const {transactionId, submitted} = await applyDocumentActions(instance, { actions: [publishDocument(doc)], + source, }) await submitted() currentDoc = documentState.getCurrent() @@ -133,6 +140,7 @@ it('edits existing documents', async () => { await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'updated title'}})], + source, }) expect(state.getCurrent()).toMatchObject({ _id: getDraftId(doc.documentId), @@ -156,10 +164,11 @@ it('sets optimistic changes synchronously', async () => { // then the actions are synchronous expect(state1.getCurrent()).toBeNull() - applyDocumentActions(instance1, {actions: [createDocument(doc)]}) + applyDocumentActions(instance1, {actions: [createDocument(doc)], source: source1}) expect(state1.getCurrent()).toMatchObject({_id: getDraftId(doc.documentId)}) const actionResult1Promise = applyDocumentActions(instance1, { actions: [editDocument(doc, {set: {title: 'initial title'}})], + source: source1, }) expect(state1.getCurrent()?.title).toBe('initial title') @@ -180,6 +189,7 @@ it('sets optimistic changes synchronously', async () => { // synchronous for state 2 const actionResult2Promise = applyDocumentActions(instance2, { actions: [editDocument(doc, {set: {title: 'updated title'}})], + source: source2, }) expect(state2.getCurrent()?.title).toBe('updated title') // async for state 1 @@ -202,7 +212,9 @@ it('propagates changes between two instances', async () => { const state2Unsubscribe = state2.subscribe() // Create the document from instance1. - await applyDocumentActions(instance1, {actions: [createDocument(doc)]}).then((r) => r.submitted()) + await applyDocumentActions(instance1, {actions: [createDocument(doc)], source: source1}).then( + (r) => r.submitted(), + ) const doc1 = state1.getCurrent() const doc2 = state2.getCurrent() @@ -212,6 +224,7 @@ it('propagates changes between two instances', async () => { // Now, edit the document from instance2. await applyDocumentActions(instance2, { actions: [editDocument(doc, {set: {title: 'Hello world!'}})], + source: source2, }).then((r) => r.submitted()) const updated1 = state1.getCurrent() @@ -239,16 +252,19 @@ it('handles concurrent edits and resolves conflicts', async () => { createDocument(doc), editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy dog'}}), ], + source, }).then((res) => res.submitted()) // Both instances now issue an edit simultaneously. const p1 = applyDocumentActions(instance1, { actions: [editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy cat'}})], + source: source1, }).then((r) => r.submitted()) const p2 = applyDocumentActions(instance2, { actions: [ editDocument(doc, {set: {title: 'The quick brown elephant jumps over the lazy dog'}}), ], + source: source2, }).then((r) => r.submitted()) // Wait for both actions to complete (or reject). @@ -270,8 +286,11 @@ it('unpublishes and discards a document', async () => { const unsubscribe = documentState.subscribe() // Create and publish the document. - await applyDocumentActions(instance, {actions: [createDocument(doc)]}) - const afterPublish = await applyDocumentActions(instance, {actions: [publishDocument(doc)]}) + await applyDocumentActions(instance, {actions: [createDocument(doc)], source}) + const afterPublish = await applyDocumentActions(instance, { + actions: [publishDocument(doc)], + source, + }) const publishedDoc = documentState.getCurrent() expect(publishedDoc).toMatchObject({ _id: getPublishedId(doc.documentId), @@ -279,13 +298,13 @@ it('unpublishes and discards a document', async () => { }) // Unpublish the document (which should delete the published version and create a draft). - await applyDocumentActions(instance, {actions: [unpublishDocument(doc)]}) + await applyDocumentActions(instance, {actions: [unpublishDocument(doc)], source}) const afterUnpublish = documentState.getCurrent() // In our mock implementation the _id remains the same but the published copy is removed. expect(afterUnpublish?._id).toEqual(getDraftId(doc.documentId)) // Discard the draft (which deletes the draft version). - await applyDocumentActions(instance, {actions: [discardDocument(doc)]}) + await applyDocumentActions(instance, {actions: [discardDocument(doc)], source}) const afterDiscard = documentState.getCurrent() expect(afterDiscard).toBeNull() @@ -298,12 +317,15 @@ it('deletes a document', async () => { const documentState = getDocumentState(instance, doc) const unsubscribe = documentState.subscribe() - await applyDocumentActions(instance, {actions: [createDocument(doc), publishDocument(doc)]}) + await applyDocumentActions(instance, { + actions: [createDocument(doc), publishDocument(doc)], + source, + }) const docValue = documentState.getCurrent() expect(docValue).toBeDefined() // Delete the document. - await applyDocumentActions(instance, {actions: [deleteDocument(doc)]}) + await applyDocumentActions(instance, {actions: [deleteDocument(doc)], source}) const afterDelete = documentState.getCurrent() expect(afterDelete).toBeNull() @@ -318,7 +340,7 @@ it('cleans up document state when there are no subscribers', async () => { const unsubscribe = documentState.subscribe() // Create a document. - await applyDocumentActions(instance, {actions: [createDocument(doc)]}) + await applyDocumentActions(instance, {actions: [createDocument(doc)], source}) expect(documentState.getCurrent()).toBeDefined() // Unsubscribe from the document. @@ -345,6 +367,7 @@ it('fetches documents if there are no active subscriptions for the actions appli // transaction for this action has been accepted by the server const setNewTitle = applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'new title'}})], + source, }) expect(getCurrent()?.title).toBeUndefined() expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBe(false) @@ -353,9 +376,15 @@ it('fetches documents if there are no active subscriptions for the actions appli expect(getCurrent()?.title).toBe('new title') // there is an active subscriber now so the edits are synchronous - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated title'}})]}) + applyDocumentActions(instance, { + actions: [editDocument(doc, {set: {title: 'updated title'}})], + source, + }) expect(getCurrent()?.title).toBe('updated title') - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated title!'}})]}) + applyDocumentActions(instance, { + actions: [editDocument(doc, {set: {title: 'updated title!'}})], + source, + }) expect(getCurrent()?.title).toBe('updated title!') expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBe(false) @@ -363,6 +392,7 @@ it('fetches documents if there are no active subscriptions for the actions appli // await submitted in order to test that there is no subscriptions const result = await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'updated title'}})], + source, }) await result.submitted() @@ -371,6 +401,7 @@ it('fetches documents if there are no active subscriptions for the actions appli const setNewNewTitle = applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'new new title'}})], + source, }) // now we'll have to await again expect(getCurrent()?.title).toBe(undefined) @@ -385,14 +416,15 @@ it('batches edit transaction into one outgoing transaction', async () => { const unsubscribe = getDocumentState(instance, doc).subscribe() // this creates its own transaction - applyDocumentActions(instance, {actions: [createDocument(doc)]}) + applyDocumentActions(instance, {actions: [createDocument(doc)], source}) // these get batched into one - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!'}})]}) - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!'}})]}) - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!!'}})]}) + applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!'}})], source}) + applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!'}})], source}) + applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!!'}})], source}) const res = await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'name!!!!'}})], + source, }) await res.submitted() @@ -414,7 +446,7 @@ it('provides the consistency status via `getDocumentSyncStatus`', async () => { const unsubscribe = syncStatus.subscribe() expect(syncStatus.getCurrent()).toBe(true) - const applied = applyDocumentActions(instance, {actions: [createDocument(doc)]}) + const applied = applyDocumentActions(instance, {actions: [createDocument(doc)], source}) expect(syncStatus.getCurrent()).toBe(false) const createResult = await applied @@ -423,11 +455,17 @@ it('provides the consistency status via `getDocumentSyncStatus`', async () => { await createResult.submitted() expect(syncStatus.getCurrent()).toBe(true) - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'initial name'}})]}) + applyDocumentActions(instance, { + actions: [editDocument(doc, {set: {title: 'initial name'}})], + source, + }) expect(syncStatus.getCurrent()).toBe(false) - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated name'}})]}) - const publishResult = applyDocumentActions(instance, {actions: [publishDocument(doc)]}) + applyDocumentActions(instance, { + actions: [editDocument(doc, {set: {title: 'updated name'}})], + source, + }) + const publishResult = applyDocumentActions(instance, {actions: [publishDocument(doc)], source}) expect(syncStatus.getCurrent()).toBe(false) await publishResult.then((res) => res.submitted()) expect(syncStatus.getCurrent()).toBe(true) @@ -457,22 +495,28 @@ it('reverts failed outgoing transaction locally', async () => { const {getCurrent, subscribe} = getDocumentState(instance, doc) const unsubscribe = subscribe() - await applyDocumentActions(instance, {actions: [createDocument(doc)]}) - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'the'}})]}) - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'the quick'}})]}) + await applyDocumentActions(instance, {actions: [createDocument(doc)], source}) + applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'the'}})], source}) + applyDocumentActions(instance, { + actions: [editDocument(doc, {set: {title: 'the quick'}})], + source, + }) // this edit action is simulated to fail from the backend and will be reverted const revertedActionResult = applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'the quick brown'}})], + source, transactionId: 'force-revert', disableBatching: true, }) applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'the quick brown fox'}})], + source, }) await applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'the quick brown fox jumps'}})], + source, }).then((e) => e.submitted()) await expect(revertedEventPromise).resolves.toMatchObject({ @@ -492,6 +536,7 @@ it('reverts failed outgoing transaction locally', async () => { // check that we can still edit after recovering from the error applyDocumentActions(instance, { actions: [editDocument(doc, {set: {title: 'TEST the quick fox jumps'}})], + source, }) expect(getCurrent()?.title).toBe('TEST the quick fox jumps') @@ -514,7 +559,10 @@ it('removes a queued transaction if it fails to apply', async () => { const unsubscribe = state.subscribe() await expect( - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: "can't set"}})]}), + applyDocumentActions(instance, { + actions: [editDocument(doc, {set: {title: "can't set"}})], + source, + }), ).rejects.toThrowError(/Cannot edit document/) await expect(actionErrorEventPromise).resolves.toMatchObject({ @@ -524,8 +572,8 @@ it('removes a queued transaction if it fails to apply', async () => { }) // editing should still work after though (no crashing) - await applyDocumentActions(instance, {actions: [createDocument(doc)]}) - applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'can set!'}})]}) + await applyDocumentActions(instance, {actions: [createDocument(doc)], source}) + applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'can set!'}})], source}) expect(state.getCurrent()?.title).toBe('can set!') @@ -545,7 +593,9 @@ it('returns allowed true when no permission errors occur', async () => { }) const state = getDocumentState(instance, doc) const unsubscribe = state.subscribe() - await applyDocumentActions(instance, {actions: [createDocument(doc)]}).then((r) => r.submitted()) + await applyDocumentActions(instance, {actions: [createDocument(doc)], source}).then((r) => + r.submitted(), + ) // Use an action that includes a patch (so that update permission check is bypassed). const permissionsState = getPermissionsState(instance, { @@ -567,9 +617,9 @@ it('returns allowed true when no permission errors occur', async () => { it("should reject applying the action if a precondition isn't met", async () => { const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'}) - await expect(applyDocumentActions(instance, {actions: [deleteDocument(doc)]})).rejects.toThrow( - 'The document you are trying to delete does not exist.', - ) + await expect( + applyDocumentActions(instance, {actions: [deleteDocument(doc)], source}), + ).rejects.toThrow('The document you are trying to delete does not exist.') }) it("should reject applying the action if a permission isn't met", async () => { @@ -578,9 +628,9 @@ it("should reject applying the action if a permission isn't met", async () => { const datasetAcl = [{filter: 'false', permissions: ['create']}] vi.mocked(client.request).mockResolvedValue(datasetAcl) - await expect(applyDocumentActions(instance, {actions: [createDocument(doc)]})).rejects.toThrow( - 'You do not have permission to create a draft for document "does-not-exist".', - ) + await expect( + applyDocumentActions(instance, {actions: [createDocument(doc)], source}), + ).rejects.toThrow('You do not have permission to create a draft for document "does-not-exist".') }) it('returns allowed false with reasons when permission errors occur', async () => { @@ -627,6 +677,7 @@ it('returns a promise that resolves when a document has been loaded in the store const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'}) const result = await applyDocumentActions(oneOffInstance, { actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial title'}})], + source, }) await result.submitted() // wait till submitted to server before resolving @@ -652,6 +703,7 @@ it('emits an event for each action after an outgoing transaction has been accept editDocument(doc, {set: {title: 'new name'}}), publishDocument(doc), ], + source, }).then((e) => e.submitted()) expect(handler).toHaveBeenCalledTimes(4) @@ -662,6 +714,7 @@ it('emits an event for each action after an outgoing transaction has been accept editDocument(doc, {set: {title: 'updated name'}}), discardDocument(doc), ], + source, }).then((e) => e.submitted()) expect(handler).toHaveBeenCalledTimes(9) @@ -677,7 +730,7 @@ it('emits an event for each action after an outgoing transaction has been accept [{type: 'accepted', outgoing: {transactionId: tnx2.transactionId}}], ]) - await applyDocumentActions(instance, {actions: [deleteDocument(doc)]}) + await applyDocumentActions(instance, {actions: [deleteDocument(doc)], source}) unsubscribe() }) @@ -830,8 +883,8 @@ beforeEach(() => { continue } case 'sanity.action.document.edit': { - const source = next[i.draftId] ?? next[i.publishedId] - if (!source) { + const sourceDoc = next[i.draftId] ?? next[i.publishedId] + if (!sourceDoc) { throw new Error( `Could not find a document to edit from \`draftId\` \`${i.draftId}\` or \`publishedId\` ${i.publishedId}`, ) @@ -840,7 +893,7 @@ beforeEach(() => { next = processMutations({ documents: next, mutations: [ - {createIfNotExists: {...source, _id: i.draftId}}, + {createIfNotExists: {...sourceDoc, _id: i.draftId}}, {patch: {id: i.draftId, ...i.patch}}, ], transactionId, diff --git a/packages/react/src/hooks/document/useApplyDocumentActions.ts b/packages/react/src/hooks/document/useApplyDocumentActions.ts index 42ee50011..a1449e795 100644 --- a/packages/react/src/hooks/document/useApplyDocumentActions.ts +++ b/packages/react/src/hooks/document/useApplyDocumentActions.ts @@ -3,10 +3,12 @@ import { applyDocumentActions, type ApplyDocumentActionsOptions, type DocumentAction, + sourceFor, } from '@sanity/sdk' import {type SanityDocument} from 'groq' -import {useSanityInstance} from '../context/useSanityInstance' +import {type SourceOptions} from '../../type' +import {useSanityInstanceAndSource} from '../context/useSanityInstance' // this import is used in an `{@link useEditDocument}` // eslint-disable-next-line unused-imports/no-unused-imports, import/consistent-type-specifier-style import type {useEditDocument} from './useEditDocument' @@ -15,7 +17,9 @@ import type {useEditDocument} from './useEditDocument' * @public */ interface UseApplyDocumentActions { - (): < + ( + sourceOptions?: SourceOptions, + ): < TDocumentType extends string = string, TDataset extends string = string, TProjectId extends string = string, @@ -119,8 +123,8 @@ interface UseApplyDocumentActions { * } * ``` */ -export const useApplyDocumentActions: UseApplyDocumentActions = () => { - const instance = useSanityInstance() +export const useApplyDocumentActions: UseApplyDocumentActions = (hookOptions = {}) => { + const [instance, source] = useSanityInstanceAndSource(hookOptions) return (actionOrActions, options) => { const actions = Array.isArray(actionOrActions) ? actionOrActions : [actionOrActions] @@ -147,18 +151,14 @@ export const useApplyDocumentActions: UseApplyDocumentActions = () => { } } - if (projectId || dataset) { - const actualInstance = instance.match({projectId, dataset}) - if (!actualInstance) { - throw new Error( - `Could not find a matching Sanity instance for the requested action: ${JSON.stringify({projectId, dataset}, null, 2)}. - Please ensure there is a ResourceProvider component with a matching configuration in the component hierarchy.`, - ) - } - - return applyDocumentActions(actualInstance, {actions, ...options}) + if (projectId && dataset) { + return applyDocumentActions(instance, { + actions, + ...options, + source: sourceFor({projectId, dataset}), + }) } - return applyDocumentActions(instance, {actions, ...options}) + return applyDocumentActions(instance, {actions, ...options, source}) } }