diff --git a/packages/studio-server/src/codemods/update-effect-props/update-effect-props.ts b/packages/studio-server/src/codemods/update-effect-props/update-effect-props.ts index f4a2fba912f..f77cd899efe 100644 --- a/packages/studio-server/src/codemods/update-effect-props/update-effect-props.ts +++ b/packages/studio-server/src/codemods/update-effect-props/update-effect-props.ts @@ -33,6 +33,12 @@ export type UpdateEffectPropsResult = { removedProps: PropDelta[]; }; +export type UpdateMultipleEffectPropsResult = { + output: string; + formatted: boolean; + results: Omit[]; +}; + export type PropDelta = { key: string; valueString: string; @@ -373,3 +379,51 @@ export const updateEffectProps = async ({ removedProps, }; }; + +export const updateMultipleEffectProps = async ({ + input, + changes, + prettierConfigOverride, +}: { + input: string; + changes: { + sequenceNodePath: SequenceNodePath; + effectIndex: number; + update: EffectPropUpdate; + schema: SequenceSchema; + }[]; + prettierConfigOverride?: Record | null; +}): Promise => { + let current = input; + const results: UpdateMultipleEffectPropsResult['results'] = []; + + for (const change of changes) { + const {serialized, oldValueString, logLine, effectCallee, removedProps} = + updateEffectPropsAst({ + input: current, + sequenceNodePath: change.sequenceNodePath, + effectIndex: change.effectIndex, + update: change.update, + schema: change.schema, + }); + + current = serialized; + results.push({ + oldValueString, + logLine, + effectCallee, + removedProps, + }); + } + + const {output, formatted} = await formatFileContent({ + input: current, + prettierConfigOverride, + }); + + return { + output, + formatted, + results, + }; +}; diff --git a/packages/studio-server/src/preview-server/routes/save-effect-props.ts b/packages/studio-server/src/preview-server/routes/save-effect-props.ts index 3b3a93bb506..e60ddfc06d7 100644 --- a/packages/studio-server/src/preview-server/routes/save-effect-props.ts +++ b/packages/studio-server/src/preview-server/routes/save-effect-props.ts @@ -1,18 +1,23 @@ import {readFileSync} from 'node:fs'; import {RenderInternals} from '@remotion/renderer'; import type { + SaveEffectPropEdit, SaveEffectPropsRequest, SaveEffectPropsResponse, + SaveEffectPropsResult, } from '@remotion/studio-shared'; import {getAllSchemaKeys} from '@remotion/studio-shared'; import {parseAst} from '../../codemods/parse-ast'; -import {updateEffectProps} from '../../codemods/update-effect-props/update-effect-props'; +import { + type PropDelta, + updateMultipleEffectProps, +} from '../../codemods/update-effect-props/update-effect-props'; import {writeFileAndNotifyFileWatchers} from '../../file-watcher'; import {resolveFileInsideProject} from '../../helpers/resolve-file-inside-project'; import type {ApiHandler} from '../api-types'; import { printUndoHint, - pushToUndoStack, + pushTransactionToUndoStack, suppressUndoStackInvalidation, } from '../undo-stack'; import {suppressBundlerUpdateForFile} from '../watch-ignore-next-change'; @@ -23,73 +28,155 @@ import {logEffectUpdate} from './log-updates/log-effect-update'; import {normalizeQuotes} from './log-updates/log-update'; import {withSavePropsLock} from './save-props-mutex'; +type ResolvedEffectPropEdit = { + index: number; + fileName: SaveEffectPropEdit['fileName']; + sequenceNodePath: SaveEffectPropEdit['sequenceNodePath']; + effectIndex: SaveEffectPropEdit['effectIndex']; + key: SaveEffectPropEdit['key']; + value: unknown; + valueString: string; + defaultValue: unknown | null; + defaultValueString: string | null; + schema: SaveEffectPropEdit['schema']; +}; + +type EffectPropEditGroup = { + fileRelativeToRoot: string; + edits: ResolvedEffectPropEdit[]; +}; + +type EffectPropUndoSnapshot = { + filePath: string; + oldContents: string; + newContents: string; + logLine: number; +}; + +type EffectPropEditResult = { + oldValueString: string; + logLine: number; + effectCallee: string; + removedProps: PropDelta[]; + formatted: boolean; +}; + export const saveEffectPropsHandler: ApiHandler< SaveEffectPropsRequest, SaveEffectPropsResponse > = ({ - input: { - fileName, - sequenceNodePath, - effectIndex, - key, - value, - defaultValue, - schema, - clientId, - }, + input: {edits, clientId, undoLabel, redoLabel}, remotionRoot, logLevel, }) => withSavePropsLock(async () => { + if (edits.length === 0) { + throw new Error('No effect prop edits to save'); + } + RenderInternals.Log.trace( {indent: false, logLevel}, - `[save-effect-props] Received request for fileName="${fileName}" effectIndex=${effectIndex} key="${key}"`, + `[save-effect-props] Received request with ${edits.length} edit(s)`, ); - const {absolutePath, fileRelativeToRoot} = resolveFileInsideProject({ - remotionRoot, - fileName, - action: 'modify', - }); - const fileContents = readFileSync(absolutePath, 'utf-8'); - - const parsedDefault = - defaultValue !== null ? JSON.parse(defaultValue) : null; - - const { - output, - oldValueString, - formatted, - logLine, - effectCallee, - removedProps, - } = await updateEffectProps({ - input: fileContents, - sequenceNodePath: sequenceNodePath.nodePath, - effectIndex, - update: { - key, - value: JSON.parse(value), - defaultValue: parsedDefault, - }, - schema, - }); + const editGroups = new Map(); + for (const [index, edit] of edits.entries()) { + const parsedValue = JSON.parse(edit.value); + const parsedDefaultValue = + edit.defaultValue !== null ? JSON.parse(edit.defaultValue) : null; + const {absolutePath, fileRelativeToRoot} = resolveFileInsideProject({ + remotionRoot, + fileName: edit.fileName, + action: 'modify', + }); + + const group = editGroups.get(absolutePath) ?? { + fileRelativeToRoot, + edits: [], + }; + group.edits.push({ + index, + fileName: edit.fileName, + sequenceNodePath: edit.sequenceNodePath, + effectIndex: edit.effectIndex, + key: edit.key, + value: parsedValue, + valueString: edit.value, + defaultValue: parsedDefaultValue, + defaultValueString: + parsedDefaultValue !== null + ? JSON.stringify(parsedDefaultValue) + : null, + schema: edit.schema, + }); + editGroups.set(absolutePath, group); + } + + const snapshots: EffectPropUndoSnapshot[] = []; + const outputByPath = new Map(); + const resultByIndex = new Map(); + + for (const [absolutePath, group] of editGroups) { + const fileContents = readFileSync(absolutePath, 'utf-8'); + const { + output, + formatted, + results: updateResults, + } = await updateMultipleEffectProps({ + input: fileContents, + changes: group.edits.map((edit) => { + return { + sequenceNodePath: edit.sequenceNodePath.nodePath, + effectIndex: edit.effectIndex, + update: { + key: edit.key, + value: edit.value, + defaultValue: edit.defaultValue, + }, + schema: edit.schema, + }; + }), + prettierConfigOverride: null, + }); + + const [{logLine: firstLogLine}] = updateResults; + outputByPath.set(absolutePath, output); + snapshots.push({ + filePath: absolutePath, + oldContents: fileContents, + newContents: output, + logLine: firstLogLine, + }); - const defaultValueString = - parsedDefault !== null ? JSON.stringify(parsedDefault) : null; + for (const [resultIndex, result] of updateResults.entries()) { + const edit = group.edits[resultIndex]; + resultByIndex.set(edit.index, { + ...result, + formatted, + }); + } + } + + const [firstEdit] = edits; + const firstResult = resultByIndex.get(0); + if (!firstResult) { + throw new Error('Could not compute effect prop edit result'); + } - const normalizedOld = normalizeQuotes(oldValueString); - const normalizedNew = normalizeQuotes(value); + const normalizedOld = normalizeQuotes(firstResult.oldValueString); + const normalizedNew = normalizeQuotes(firstEdit.value); const normalizedDefault = - defaultValueString !== null ? normalizeQuotes(defaultValueString) : null; - const normalizedRemovedProps = removedProps.map((prop) => ({ + firstEdit.defaultValue !== null + ? normalizeQuotes(firstEdit.defaultValue) + : null; + const normalizedRemovedProps = firstResult.removedProps.map((prop) => ({ ...prop, valueString: normalizeQuotes(prop.valueString), })); const undoPropChange = formatEffectPropChange({ - effectName: effectCallee, - key, + effectName: firstResult.effectCallee, + key: firstEdit.key, oldValueString: normalizedNew, newValueString: normalizedOld, defaultValueString: normalizedDefault, @@ -97,62 +184,102 @@ export const saveEffectPropsHandler: ApiHandler< addedProps: normalizedRemovedProps, }); const redoPropChange = formatEffectPropChange({ - effectName: effectCallee, - key, + effectName: firstResult.effectCallee, + key: firstEdit.key, oldValueString: normalizedOld, newValueString: normalizedNew, defaultValueString: normalizedDefault, removedProps: normalizedRemovedProps, addedProps: [], }); + const undoMessage = + undoLabel !== null + ? `↩️ ${undoLabel}` + : edits.length === 1 + ? `↩️ ${undoPropChange}` + : '↩️ Update selected effect props'; + const redoMessage = + redoLabel !== null + ? `↪️ ${redoLabel}` + : edits.length === 1 + ? `↪️ ${redoPropChange}` + : '↪️ Update selected effect props'; - pushToUndoStack({ - filePath: absolutePath, - oldContents: fileContents, - newContents: null, + pushTransactionToUndoStack({ + snapshots, logLevel, remotionRoot, - logLine, - description: { - undoMessage: `↩️ ${undoPropChange}`, - redoMessage: `↪️ ${redoPropChange}`, - }, + description: {undoMessage, redoMessage}, entryType: 'effect-props', suppressHmrOnFileRestore: true, }); - suppressUndoStackInvalidation(absolutePath); - suppressBundlerUpdateForFile(absolutePath); - writeFileAndNotifyFileWatchers(absolutePath, output, clientId); - - logEffectUpdate({ - fileRelativeToRoot, - line: logLine, - effectName: effectCallee, - propKey: key, - oldValueString, - newValueString: value, - defaultValueString, - formatted, - logLevel, - removedProps, - addedProps: [], - }); + + for (const [absolutePath, output] of outputByPath) { + suppressUndoStackInvalidation(absolutePath); + suppressBundlerUpdateForFile(absolutePath); + writeFileAndNotifyFileWatchers(absolutePath, output, clientId); + } + + for (const {edits: groupEdits, fileRelativeToRoot} of editGroups.values()) { + for (const edit of groupEdits) { + const result = resultByIndex.get(edit.index); + if (!result) { + throw new Error('Could not compute effect prop edit result'); + } + + logEffectUpdate({ + fileRelativeToRoot, + line: result.logLine, + effectName: result.effectCallee, + propKey: edit.key, + oldValueString: result.oldValueString, + newValueString: edit.valueString, + defaultValueString: edit.defaultValueString, + formatted: result.formatted, + logLevel, + removedProps: result.removedProps, + addedProps: [], + }); + } + } printUndoHint(logLevel); - const ast = parseAst(readFileSync(absolutePath, 'utf-8')); - const jsx = findJsxElementAtNodePath(ast, sequenceNodePath.nodePath); - if (!jsx) { + const results: SaveEffectPropsResult[] = edits.map((edit) => { + const {absolutePath} = resolveFileInsideProject({ + remotionRoot, + fileName: edit.fileName, + action: 'modify', + }); + const output = outputByPath.get(absolutePath); + if (!output) { + throw new Error('Could not compute effect prop edit status'); + } + + const ast = parseAst(output); + const jsx = findJsxElementAtNodePath(ast, edit.sequenceNodePath.nodePath); + const status = jsx + ? computeEffectPropStatus({ + jsx, + effectIndex: edit.effectIndex, + keys: getAllSchemaKeys(edit.schema), + }) + : ({ + canUpdate: false, + effectIndex: edit.effectIndex, + reason: 'not-found', + } as const); + return { - canUpdate: false, - effectIndex, - reason: 'not-found', + fileName: edit.fileName, + sequenceNodePath: edit.sequenceNodePath, + effectIndex: edit.effectIndex, + status, }; - } - - return computeEffectPropStatus({ - jsx, - effectIndex, - keys: getAllSchemaKeys(schema), }); + + return { + ...results[0].status, + results, + }; }); diff --git a/packages/studio-server/src/test/update-effect-props.test.ts b/packages/studio-server/src/test/update-effect-props.test.ts index 3689772795d..bad615ff745 100644 --- a/packages/studio-server/src/test/update-effect-props.test.ts +++ b/packages/studio-server/src/test/update-effect-props.test.ts @@ -1,5 +1,20 @@ import {expect, test} from 'bun:test'; -import {updateEffectPropsAst} from '../codemods/update-effect-props/update-effect-props'; +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import { + updateEffectPropsAst, + updateMultipleEffectProps, +} from '../codemods/update-effect-props/update-effect-props'; +import { + createFileWatcherRegistry, + setFileWatcherRegistry, +} from '../file-watcher'; +import {saveEffectPropsHandler} from '../preview-server/routes/save-effect-props'; +import { + clearUndoStackForTests, + getUndoStack, +} from '../preview-server/undo-stack'; import {lineColumnToNodePath} from './test-utils'; const tintSchema = { @@ -91,6 +106,99 @@ test('updateEffectProps removes a prop equal to default', () => { expect(serialized).toContain('color: "red"'); }); +test('updateMultipleEffectProps applies several updates to one output', async () => { + const input = buildInput('[tint({color: "red", opacity: 0.5})]'); + const {output, results} = await updateMultipleEffectProps({ + input, + changes: [ + { + sequenceNodePath: lineColumnToNodePath(input, 6), + effectIndex: 0, + update: {key: 'opacity', value: 1, defaultValue: 1}, + schema: tintSchema, + }, + { + sequenceNodePath: lineColumnToNodePath(input, 6), + effectIndex: 0, + update: {key: 'color', value: 'black', defaultValue: 'black'}, + schema: tintSchema, + }, + ], + prettierConfigOverride: null, + }); + + expect(output).not.toContain('opacity:'); + expect(output).not.toContain('color:'); + expect(results).toHaveLength(2); +}); + +test('saveEffectPropsHandler pushes one undo entry for multiple edits', async () => { + clearUndoStackForTests(); + const cleanupFileWatcher = setFileWatcherRegistry( + createFileWatcherRegistry(), + ); + const dir = mkdtempSync(path.join(tmpdir(), 'remotion-effect-props-')); + const input = buildInput('[tint({color: "red", opacity: 0.5})]'); + const fileName = path.join(dir, 'Comp.tsx'); + const sequenceNodePath = { + absolutePath: fileName, + nodePath: lineColumnToNodePath(input, 6), + sequenceKeys: [], + effectKeys: [['color', 'opacity']], + }; + writeFileSync(fileName, input); + + try { + await saveEffectPropsHandler({ + input: { + edits: [ + { + fileName, + sequenceNodePath, + effectIndex: 0, + key: 'opacity', + value: '1', + defaultValue: '1', + schema: tintSchema, + }, + { + fileName, + sequenceNodePath, + effectIndex: 0, + key: 'color', + value: '"black"', + defaultValue: '"black"', + schema: tintSchema, + }, + ], + clientId: 'test-client', + undoLabel: null, + redoLabel: null, + }, + entryPoint: fileName, + remotionRoot: dir, + request: {} as never, + response: {} as never, + logLevel: 'error', + methods: {} as never, + publicDir: dir, + binariesDirectory: null, + }); + + const output = readFileSync(fileName, 'utf-8'); + expect(output).not.toContain('opacity:'); + expect(output).not.toContain('color:'); + expect(getUndoStack()).toHaveLength(1); + expect(getUndoStack()[0].description.undoMessage).toBe( + '↩️ Update selected effect props', + ); + } finally { + clearUndoStackForTests(); + cleanupFileWatcher(); + rmSync(dir, {recursive: true, force: true}); + } +}); + test('updateEffectProps targets the correct effect by index when there are multiple', () => { const input = buildInput( '[tint({color: "red"}), tint({color: "green", opacity: 0.4})]', diff --git a/packages/studio-shared/src/api-requests.ts b/packages/studio-shared/src/api-requests.ts index 3fe9cb7bb4c..7ab91108fa2 100644 --- a/packages/studio-shared/src/api-requests.ts +++ b/packages/studio-shared/src/api-requests.ts @@ -306,7 +306,7 @@ export type SaveSequencePropsResponse = reason: CannotUpdateSequenceReason; }; -export type SaveEffectPropsRequest = { +export type SaveEffectPropEdit = { fileName: string; sequenceNodePath: SequencePropsSubscriptionKey; effectIndex: number; @@ -314,10 +314,25 @@ export type SaveEffectPropsRequest = { value: string; defaultValue: string | null; schema: SequenceSchema; +}; + +export type SaveEffectPropsRequest = { + edits: SaveEffectPropEdit[]; clientId: string; + undoLabel: string | null; + redoLabel: string | null; }; -export type SaveEffectPropsResponse = CanUpdateEffectPropsResponse; +export type SaveEffectPropsResult = { + fileName: string; + sequenceNodePath: SequencePropsSubscriptionKey; + effectIndex: number; + status: CanUpdateEffectPropsResponse; +}; + +export type SaveEffectPropsResponse = CanUpdateEffectPropsResponse & { + results: SaveEffectPropsResult[]; +}; export type DeleteSequenceKeyframeRequest = { fileName: string; @@ -352,7 +367,7 @@ export type DeleteEffectKeyframeRequest = { clientId: string; }; -export type DeleteEffectKeyframeResponse = SaveEffectPropsResponse; +export type DeleteEffectKeyframeResponse = CanUpdateEffectPropsResponse; export type AddEffectKeyframeRequest = { fileName: string; @@ -365,7 +380,7 @@ export type AddEffectKeyframeRequest = { clientId: string; }; -export type AddEffectKeyframeResponse = SaveEffectPropsResponse; +export type AddEffectKeyframeResponse = CanUpdateEffectPropsResponse; type BaseDeleteEffectRequestItem = { fileName: string; diff --git a/packages/studio-shared/src/index.ts b/packages/studio-shared/src/index.ts index 5f282fe5ce2..3631f371dd8 100644 --- a/packages/studio-shared/src/index.ts +++ b/packages/studio-shared/src/index.ts @@ -46,8 +46,10 @@ export { RemoveRenderRequest, RestartStudioRequest, RestartStudioResponse, + SaveEffectPropEdit, SaveEffectPropsRequest, SaveEffectPropsResponse, + SaveEffectPropsResult, SaveSequencePropEdit, SaveSequencePropsRequest, SaveSequencePropsResponse, diff --git a/packages/studio/src/components/Timeline/TimelineEffectPropItem.tsx b/packages/studio/src/components/Timeline/TimelineEffectPropItem.tsx index 07d622fc112..2842d7311cd 100644 --- a/packages/studio/src/components/Timeline/TimelineEffectPropItem.tsx +++ b/packages/studio/src/components/Timeline/TimelineEffectPropItem.tsx @@ -126,14 +126,20 @@ const Value: React.FC<{ }), apiCall: () => callApi('/api/save-effect-props', { - fileName: validatedLocation.source, - sequenceNodePath: nodePath, - effectIndex: field.effectIndex, - key: field.key, - value: stringifiedValue, - defaultValue, - schema: field.effectSchema, + edits: [ + { + fileName: validatedLocation.source, + sequenceNodePath: nodePath, + effectIndex: field.effectIndex, + key: field.key, + value: stringifiedValue, + defaultValue, + schema: field.effectSchema, + }, + ], clientId, + undoLabel: null, + redoLabel: null, }), errorLabel: 'Could not save effect prop', }); diff --git a/packages/studio/src/components/Timeline/reset-selected-timeline-props.ts b/packages/studio/src/components/Timeline/reset-selected-timeline-props.ts index 72a53d8a68f..22ef901793a 100644 --- a/packages/studio/src/components/Timeline/reset-selected-timeline-props.ts +++ b/packages/studio/src/components/Timeline/reset-selected-timeline-props.ts @@ -8,8 +8,8 @@ import type { } from 'remotion'; import {Internals} from 'remotion'; import {findTrackForNodePathInfo} from './find-track-for-node-path-info'; -import {saveEffectProp} from './save-effect-prop'; -import {saveSequenceProp} from './save-sequence-prop'; +import {saveEffectProps} from './save-effect-prop'; +import {saveSequenceProps} from './save-sequence-prop'; import type {SetCodeValues} from './save-sequence-prop'; import type {TimelineSelection} from './TimelineSelection'; @@ -83,7 +83,10 @@ export const getTimelinePropResetTargets = ({ const resetTargets: TimelinePropResetTarget[] = []; for (const selection of selections) { - if (!isPropResetSelection(selection)) { + if ( + !isPropResetSelection(selection) || + selection.type !== firstSelection.type + ) { throw new Error( `Assertion failed: Cannot reset timeline selections of different types (${firstSelection.type}, ${selection.type})`, ); @@ -196,32 +199,30 @@ export const resetSelectedTimelineProps = ({ return null; } - const resetPromises = resetTargets.map((target) => { - if (target.type === 'sequence-prop') { - return saveSequenceProp({ - fileName: target.fileName, - nodePath: target.nodePath, - fieldKey: target.fieldKey, - value: target.value, - defaultValue: target.defaultValue, - schema: target.schema, - setCodeValues, - clientId, - }); - } - - return saveEffectProp({ - fileName: target.fileName, - nodePath: target.nodePath, - effectIndex: target.effectIndex, - fieldKey: target.fieldKey, - value: target.value, - defaultValue: target.defaultValue, - schema: target.schema, + const sequencePropTargets = resetTargets.filter( + (target): target is SequencePropResetTarget => + target.type === 'sequence-prop', + ); + const effectPropTargets = resetTargets.filter( + (target): target is EffectPropResetTarget => target.type === 'effect-prop', + ); + + const resetPromises = [ + saveSequenceProps({ + changes: sequencePropTargets, setCodeValues, clientId, - }); - }); + undoLabel: null, + redoLabel: null, + }), + saveEffectProps({ + changes: effectPropTargets, + setCodeValues, + clientId, + undoLabel: null, + redoLabel: null, + }), + ]; return Promise.all(resetPromises).then(() => undefined); }; diff --git a/packages/studio/src/components/Timeline/save-effect-prop.ts b/packages/studio/src/components/Timeline/save-effect-prop.ts index 9dd76502b00..c8cd3f3df18 100644 --- a/packages/studio/src/components/Timeline/save-effect-prop.ts +++ b/packages/studio/src/components/Timeline/save-effect-prop.ts @@ -4,6 +4,70 @@ import {callApi} from '../call-api'; import {enqueueSavePropChange} from './save-prop-queue'; import type {SetCodeValues} from './save-sequence-prop'; +export type SaveEffectPropChange = { + fileName: string; + nodePath: SequencePropsSubscriptionKey; + effectIndex: number; + fieldKey: string; + value: unknown; + defaultValue: string | null; + schema: SequenceSchema; +}; + +type SaveEffectPropsOptions = { + changes: SaveEffectPropChange[]; + setCodeValues: SetCodeValues; + clientId: string; + undoLabel: string | null; + redoLabel: string | null; +}; + +type SaveEffectPropOptions = SaveEffectPropChange & { + setCodeValues: SetCodeValues; + clientId: string; +}; + +export const saveEffectProps = ({ + changes, + setCodeValues, + clientId, + undoLabel, + redoLabel, +}: SaveEffectPropsOptions): Promise => { + if (changes.length === 0) { + return Promise.resolve(); + } + + for (const change of changes) { + setCodeValues(change.nodePath, (prev) => + optimisticUpdateForEffectCodeValues({ + previous: prev, + effectIndex: change.effectIndex, + fieldKey: change.fieldKey, + value: change.value, + schema: change.schema, + }), + ); + } + + return callApi('/api/save-effect-props', { + edits: changes.map((change) => { + return { + fileName: change.fileName, + sequenceNodePath: change.nodePath, + effectIndex: change.effectIndex, + key: change.fieldKey, + value: JSON.stringify(change.value), + defaultValue: change.defaultValue, + schema: change.schema, + }; + }), + clientId, + undoLabel, + redoLabel, + }).then(() => undefined); +}; + export const saveEffectProp = ({ fileName, nodePath, @@ -14,17 +78,7 @@ export const saveEffectProp = ({ schema, setCodeValues, clientId, -}: { - fileName: string; - nodePath: SequencePropsSubscriptionKey; - effectIndex: number; - fieldKey: string; - value: unknown; - defaultValue: string | null; - schema: SequenceSchema; - setCodeValues: SetCodeValues; - clientId: string; -}): Promise => { +}: SaveEffectPropOptions): Promise => { return enqueueSavePropChange({ nodePath, setCodeValues, @@ -38,14 +92,20 @@ export const saveEffectProp = ({ }), apiCall: () => callApi('/api/save-effect-props', { - fileName, - sequenceNodePath: nodePath, - effectIndex, - key: fieldKey, - value: JSON.stringify(value), - defaultValue, - schema, + edits: [ + { + fileName, + sequenceNodePath: nodePath, + effectIndex, + key: fieldKey, + value: JSON.stringify(value), + defaultValue, + schema, + }, + ], clientId, + undoLabel: null, + redoLabel: null, }), errorLabel: 'Could not save effect prop', }); diff --git a/packages/studio/src/test/timeline-selection.test.ts b/packages/studio/src/test/timeline-selection.test.ts index 164bb0a654f..8566e871e66 100644 --- a/packages/studio/src/test/timeline-selection.test.ts +++ b/packages/studio/src/test/timeline-selection.test.ts @@ -14,7 +14,10 @@ import { } from '../components/SelectedOutlineOverlay'; import {deleteSelectedTimelineItems} from '../components/Timeline/delete-selected-timeline-item'; import {isDuplicatableSequenceRowSelection} from '../components/Timeline/duplicate-selected-timeline-item'; -import {getTimelinePropResetTargets} from '../components/Timeline/reset-selected-timeline-props'; +import { + getTimelinePropResetTargets, + resetSelectedTimelineProps, +} from '../components/Timeline/reset-selected-timeline-props'; import { ENABLE_OUTLINES, getSelectableTimelineSequenceSelections, @@ -75,6 +78,30 @@ const makeTimelineSequence = ({ effects, }) as TSequence; +const mockSaveRequests = () => { + const originalFetch = globalThis.fetch; + const requests: {endpoint: string; body: unknown}[] = []; + globalThis.fetch = ((input, init) => { + requests.push({ + endpoint: String(input), + body: JSON.parse(String(init?.body)), + }); + + return Promise.resolve( + new Response(JSON.stringify({success: true, data: {}}), { + headers: {'content-type': 'application/json'}, + }), + ); + }) as typeof fetch; + + return { + requests, + restore: () => { + globalThis.fetch = originalFetch; + }, + }; +}; + test('Timeline selection should stay disabled until released publicly', () => { expect(SELECTION_ENABLED).toBe(false); }); @@ -322,6 +349,139 @@ test('Backspace reset targets selected effect props', () => { ]); }); +test('Backspace reset saves multiple selected sequence props in one request', async () => { + const schema = { + opacity: {type: 'number', default: 1}, + 'style.rotate': {type: 'rotation', default: '0deg'}, + } satisfies SequenceSchema; + const opacityNodePathInfo = makeNodePathInfo( + ['body', 0], + ['controls', 'opacity'], + ); + const rotateNodePathInfo = makeNodePathInfo( + ['body', 0], + ['controls', 'style.rotate'], + ); + const nodePath = opacityNodePathInfo.sequenceSubscriptionKey; + const codeValues = { + [Internals.makeSequencePropsSubscriptionKey(nodePath)]: { + canUpdate: true, + props: { + opacity: {canUpdate: true, codeValue: 0.5}, + 'style.rotate': {canUpdate: true, codeValue: '45deg'}, + }, + effects: [], + }, + } satisfies CodeValues; + const {requests, restore} = mockSaveRequests(); + + try { + await resetSelectedTimelineProps({ + selections: [ + { + type: 'sequence-prop', + nodePathInfo: opacityNodePathInfo, + key: 'opacity', + }, + { + type: 'sequence-prop', + nodePathInfo: rotateNodePathInfo, + key: 'style.rotate', + }, + ], + sequences: [makeTimelineSequence({schema})], + overrideIdsToNodePaths: {override: nodePath}, + codeValues, + setCodeValues: () => undefined, + clientId: 'test-client', + }); + } finally { + restore(); + } + + expect(requests).toHaveLength(1); + expect(requests[0].endpoint).toBe('/api/save-sequence-props'); + expect( + (requests[0].body as {edits: {key: string}[]}).edits.map( + (edit) => edit.key, + ), + ).toEqual(['opacity', 'style.rotate']); +}); + +test('Backspace reset saves multiple selected effect props in one request', async () => { + const schema = {} satisfies SequenceSchema; + const effectSchema = { + intensity: {type: 'number', default: 0}, + radius: {type: 'number', default: 10}, + } satisfies SequenceSchema; + const intensityNodePathInfo = makeNodePathInfo( + ['body', 0], + ['effects', '0', 'intensity'], + ); + const radiusNodePathInfo = makeNodePathInfo( + ['body', 0], + ['effects', '0', 'radius'], + ); + const nodePath = intensityNodePathInfo.sequenceSubscriptionKey; + const codeValues = { + [Internals.makeSequencePropsSubscriptionKey(nodePath)]: { + canUpdate: true, + props: {}, + effects: [ + { + canUpdate: true, + callee: 'effect', + effectIndex: 0, + props: { + intensity: {canUpdate: true, codeValue: 1}, + radius: {canUpdate: true, codeValue: 20}, + }, + }, + ], + }, + } satisfies CodeValues; + const {requests, restore} = mockSaveRequests(); + + try { + await resetSelectedTimelineProps({ + selections: [ + { + type: 'sequence-effect-prop', + nodePathInfo: intensityNodePathInfo, + i: 0, + key: 'intensity', + }, + { + type: 'sequence-effect-prop', + nodePathInfo: radiusNodePathInfo, + i: 0, + key: 'radius', + }, + ], + sequences: [ + makeTimelineSequence({ + schema, + effects: [{schema: effectSchema}], + }), + ], + overrideIdsToNodePaths: {override: nodePath}, + codeValues, + setCodeValues: () => undefined, + clientId: 'test-client', + }); + } finally { + restore(); + } + + expect(requests).toHaveLength(1); + expect(requests[0].endpoint).toBe('/api/save-effect-props'); + expect( + (requests[0].body as {edits: {key: string}[]}).edits.map( + (edit) => edit.key, + ), + ).toEqual(['intensity', 'radius']); +}); + test('Deleting mixed timeline selection types throws an assertion error', () => { const sequenceNodePathInfo = makeNodePathInfo(['body', 0], []); const effectNodePathInfo = makeNodePathInfo(['body', 1], ['effects', '0']);