changed presentations saving functionality

This commit is contained in:
Dmitri 2026-07-30 11:11:45 +02:00
parent c0863f974c
commit 80fe56c6f9
43 changed files with 936 additions and 294 deletions

View File

@ -1228,7 +1228,9 @@ Publish from stage to production.
"publishEventId": "event-uuid", "publishEventId": "event-uuid",
"summary": { "summary": {
"pages_copied": 10, "pages_copied": 10,
"audios_copied": 3 "audios_copied": 3,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
} }
} }
``` ```
@ -1258,15 +1260,24 @@ Copy dev content to stage environment.
"publishEventId": "event-uuid", "publishEventId": "event-uuid",
"summary": { "summary": {
"pages_copied": 10, "pages_copied": 10,
"audios_copied": 3 "audios_copied": 3,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
} }
} }
``` ```
The response is returned only after the Dev → Stage transaction commits.
`summary` therefore describes the Stage snapshot available to preview.
**Errors:** **Errors:**
- `400`: Publish already in progress - `400`: Invalid request or publish already in progress
- `401`: Authentication required
- `403`: Missing `CREATE_PUBLISH_EVENTS`
- `404`: Project not found - `404`: Project not found
- `429`: Rate limit exceeded
- `500`: Copy failed
--- ---

View File

@ -286,7 +286,7 @@ const sampleDataSeeder: SequelizeSeeder = {
| Presigned URL Requests | 3 | Upload/download requests | | Presigned URL Requests | 3 | Upload/download requests |
| Tour Pages | 3 | Sample tour pages | | Tour Pages | 3 | Sample tour pages |
| Project Audio Tracks | 3 | Background audio | | Project Audio Tracks | 3 | Background audio |
| Publish Events | 3 | Deployment history | | Publish Events | 3 | Valid Dev → Stage and Stage → Production lifecycle examples |
| PWA Caches | 3 | Offline cache configs | | PWA Caches | 3 | Offline cache configs |
| Access Logs | 3 | Visitor tracking | | Access Logs | 3 | Visitor tracking |

View File

@ -323,6 +323,25 @@ Publishing workflow for Dev → Stage → Production.
} }
``` ```
**Save to Stage Response:**
```json
{
"success": true,
"publishEventId": "uuid",
"summary": {
"pages_copied": 5,
"audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
}
}
```
The route validates the UUID and requires publish-event permissions. It returns
after the Dev → Stage transaction commits; failures are handled by the shared
error middleware and do not produce a success response.
--- ---
### 4. search.ts (64 lines) ### 4. search.ts (64 lines)

View File

@ -430,7 +430,7 @@ module.exports = class PublishService {
// Copy stage content to production (blocking) // Copy stage content to production (blocking)
static async publishToProduction(projectId, currentUser, title, description) static async publishToProduction(projectId, currentUser, title, description)
// Copy dev content to stage (non-blocking, returns immediately) // Copy dev content to stage (completion-confirmed)
static async saveToStage(projectId, currentUser) static async saveToStage(projectId, currentUser)
// Generic environment copy // Generic environment copy
@ -438,11 +438,14 @@ module.exports = class PublishService {
} }
``` ```
**Non-Blocking vs Blocking:** **Completion behavior:**
- `saveToStage()` - Returns immediately, copy runs in background via `setImmediate()` - `saveToStage()` - Waits for the Dev → Stage transaction and returns its copy summary
- `publishToProduction()` - Waits for entire copy operation before returning - `publishToProduction()` - Waits for entire copy operation before returning
Waiting for Save to Stage prevents a subsequent production publish from
overtaking a queued background copy and publishing the previous Stage snapshot.
**Publishing Flow:** **Publishing Flow:**
``` ```

View File

@ -23,10 +23,11 @@ function isRuntimeAssetModel(value: unknown): value is ModelStatic<Model> {
); );
} }
function getRuntimeAssetModel(value: unknown, name: string): ModelStatic<Model> { function getRuntimeAssetModel(
if ( value: unknown,
!isRuntimeAssetModel(value) name: string,
) { ): ModelStatic<Model> {
if (!isRuntimeAssetModel(value)) {
throw new Error(`Database model '${name}' is unavailable.`); throw new Error(`Database model '${name}' is unavailable.`);
} }

View File

@ -457,15 +457,19 @@ const PublishEventsData = [
from_environment: 'dev', from_environment: 'dev',
to_environment: 'dev', to_environment: 'stage',
title: 'Save to Stage',
description: 'Copy dev content to stage environment',
started_at: new Date('2026-03-01T10:00:00Z'), started_at: new Date('2026-03-01T10:00:00Z'),
finished_at: new Date('2026-03-01T10:01:10Z'), finished_at: new Date('2026-03-01T10:01:10Z'),
status: 'queued', status: 'success',
error_message: '', error_message: null,
pages_copied: 6, pages_copied: 6,
@ -479,24 +483,28 @@ const PublishEventsData = [
// type code here for "relation_one" field // type code here for "relation_one" field
from_environment: 'production', from_environment: 'stage',
to_environment: 'production', to_environment: 'production',
title: 'Spring Release',
description: 'Publish the reviewed stage snapshot',
started_at: new Date('2026-03-15T18:00:00Z'), started_at: new Date('2026-03-15T18:00:00Z'),
finished_at: new Date('2026-03-15T18:02:40Z'), finished_at: new Date('2026-03-15T18:02:40Z'),
status: 'queued', status: 'failed',
error_message: error_message:
'Asset preload list generation failed due to missing CDN URL.', 'Asset preload list generation failed due to missing CDN URL.',
pages_copied: 6, pages_copied: 0,
transitions_copied: 2, transitions_copied: 0,
audios_copied: 1, audios_copied: 0,
}, },
{ {
@ -506,21 +514,25 @@ const PublishEventsData = [
from_environment: 'dev', from_environment: 'dev',
to_environment: 'production', to_environment: 'stage',
title: 'Save to Stage',
description: 'Copy dev content to stage environment',
started_at: new Date('2026-02-05T09:00:00Z'), started_at: new Date('2026-02-05T09:00:00Z'),
finished_at: new Date('2026-02-05T09:01:05Z'), finished_at: null,
status: 'running', status: 'running',
error_message: '', error_message: null,
pages_copied: 4, pages_copied: 0,
transitions_copied: 1, transitions_copied: 0,
audios_copied: 1, audios_copied: 0,
}, },
]; ];

View File

@ -918,12 +918,30 @@ const schemas: Record<string, OpenApiSchema> = {
projectId: uuidSchema, projectId: uuidSchema,
}, },
}, },
PublishSummary: {
type: 'object',
required: [
'pages_copied',
'audios_copied',
'transition_settings_copied',
'ui_control_settings_copied',
],
additionalProperties: false,
properties: {
pages_copied: { type: 'integer', minimum: 0 },
audios_copied: { type: 'integer', minimum: 0 },
transition_settings_copied: { type: 'integer', minimum: 0 },
ui_control_settings_copied: { type: 'integer', minimum: 0 },
},
},
PublishResult: { PublishResult: {
type: 'object', type: 'object',
additionalProperties: true, required: ['success', 'publishEventId', 'summary'],
additionalProperties: false,
properties: { properties: {
success: { type: 'boolean' }, success: { type: 'boolean', enum: [true] },
event: ref('PublishEvent'), publishEventId: uuidSchema,
summary: ref('PublishSummary'),
}, },
}, },
SearchRequest: { SearchRequest: {
@ -1810,6 +1828,8 @@ const customPaths: OpenApiPaths = {
post: { post: {
tags: ['Publish'], tags: ['Publish'],
summary: 'Publish staged content to production', summary: 'Publish staged content to production',
description:
'Waits for the Stage-to-Production transaction to commit, then returns the committed copy summary.',
security: bearerSecurity, security: bearerSecurity,
requestBody: jsonRequest(ref('PublishRequest')), requestBody: jsonRequest(ref('PublishRequest')),
responses: { responses: {
@ -1822,6 +1842,8 @@ const customPaths: OpenApiPaths = {
post: { post: {
tags: ['Publish'], tags: ['Publish'],
summary: 'Copy dev content to stage', summary: 'Copy dev content to stage',
description:
'Waits for the Dev-to-Stage transaction to commit, then returns the committed copy summary.',
security: bearerSecurity, security: bearerSecurity,
requestBody: jsonRequest(ref('SaveToStageRequest')), requestBody: jsonRequest(ref('SaveToStageRequest')),
responses: { responses: {

View File

@ -69,14 +69,17 @@ const presignHandler = async (
const currentUser = getCurrentUser(req); const currentUser = getCurrentUser(req);
const runtimeContext = getRuntimeContext(req); const runtimeContext = getRuntimeContext(req);
const authorization = const authorization = await RuntimeAssetAccessService.authorizePresignRequest(
await RuntimeAssetAccessService.authorizePresignRequest({ {
currentUser, currentUser,
runtimeContext, runtimeContext,
urls, urls,
}); },
);
if (authorization === 'authentication_required') { if (authorization === 'authentication_required') {
return res.status(401).json( return res
.status(401)
.json(
services.createErrorResponse( services.createErrorResponse(
'Authentication or public presentation context is required', 'Authentication or public presentation context is required',
'PRESIGN_AUTH_REQUIRED', 'PRESIGN_AUTH_REQUIRED',
@ -84,7 +87,9 @@ const presignHandler = async (
); );
} }
if (authorization === 'denied') { if (authorization === 'denied') {
return res.status(403).json( return res
.status(403)
.json(
services.createErrorResponse( services.createErrorResponse(
'Asset access denied', 'Asset access denied',
'PRESIGN_ACCESS_DENIED', 'PRESIGN_ACCESS_DENIED',

View File

@ -66,7 +66,7 @@ router.post('/', validateRequest(publishSchemas.publish), publishHandler);
* - bearerAuth: [] * - bearerAuth: []
* tags: [Publish] * tags: [Publish]
* summary: Save dev content to stage * summary: Save dev content to stage
* description: Copies all dev environment content (pages, elements, transitions, audio) to stage environment * description: Copies Dev pages (including UI schemas), project audio tracks, transition settings, and UI-control settings to Stage. Returns only after the transaction commits.
* requestBody: * requestBody:
* required: true * required: true
* content: * content:
@ -82,8 +82,55 @@ router.post('/', validateRequest(publishSchemas.publish), publishHandler);
* responses: * responses:
* 200: * 200:
* description: Successfully saved to stage * description: Successfully saved to stage
* content:
* application/json:
* schema:
* type: object
* additionalProperties: false
* required:
* - success
* - publishEventId
* - summary
* properties:
* success:
* type: boolean
* enum: [true]
* publishEventId:
* type: string
* format: uuid
* summary:
* type: object
* additionalProperties: false
* required:
* - pages_copied
* - audios_copied
* - transition_settings_copied
* - ui_control_settings_copied
* properties:
* pages_copied:
* type: integer
* minimum: 0
* audios_copied:
* type: integer
* minimum: 0
* transition_settings_copied:
* type: integer
* minimum: 0
* ui_control_settings_copied:
* type: integer
* minimum: 0
* 400: * 400:
* description: Invalid request or publish already in progress * description: Invalid request or publish already in progress
* 401:
* description: Authentication required
* 403:
* description: Insufficient publish-events permission
* 404:
* description: Project not found
* 429:
* description: Rate limit exceeded
* 500:
* description: Stage copy failed
*/ */
router.post( router.post(
'/save-to-stage', '/save-to-stage',

View File

@ -1,12 +1,10 @@
import type { Transaction } from 'sequelize'; import type { Transaction } from 'sequelize';
import db from '../db/models/index.ts'; import db from '../db/models/index.ts';
import { logger } from '../utils/logger.ts';
import type { import type {
PublishCloneData, PublishCloneData,
PublishClonePayload, PublishClonePayload,
PublishCloneSource, PublishCloneSource,
PublishEventRecord,
PublishEventStatus, PublishEventStatus,
PublishLockCallback, PublishLockCallback,
PublishServiceCurrentUser, PublishServiceCurrentUser,
@ -216,26 +214,6 @@ export default class PublishService {
updatedById: actorId, updatedById: actorId,
}); });
setImmediate(() => {
this.processSaveToStage(projectId, currentUser, publishEvent).catch(
(error: unknown) => {
logger.error(
{ err: error, projectId, publishEventId: publishEvent.id },
'Save to stage background job failed',
);
},
);
});
return { success: true, publishEventId: publishEvent.id };
}
private static async processSaveToStage(
projectId: string,
currentUser: PublishServiceCurrentUser | undefined,
publishEvent: PublishEventRecord,
): Promise<void> {
const actorId = currentUser?.id || null;
try { try {
const summary = await this.withProjectPublishLock( const summary = await this.withProjectPublishLock(
projectId, projectId,
@ -259,6 +237,12 @@ export default class PublishService {
audios_copied: summary.audios_copied, audios_copied: summary.audios_copied,
updatedById: actorId, updatedById: actorId,
}); });
return {
success: true,
publishEventId: publishEvent.id,
summary,
};
} catch (error) { } catch (error) {
await publishEvent.update({ await publishEvent.update({
status: EVENT_STATUS.FAILED, status: EVENT_STATUS.FAILED,

View File

@ -4,10 +4,7 @@ import type { AccessPolicyUser, RuntimeContext } from '../types/index.ts';
import { UI_SCHEMA_ASSET_FIELDS } from '../utils/ui-schema-assets.ts'; import { UI_SCHEMA_ASSET_FIELDS } from '../utils/ui-schema-assets.ts';
import AccessPolicy from './access-policy.ts'; import AccessPolicy from './access-policy.ts';
type PresignAuthorization = type PresignAuthorization = 'allowed' | 'authentication_required' | 'denied';
| 'allowed'
| 'authentication_required'
| 'denied';
interface AuthorizePresignRequestOptions { interface AuthorizePresignRequestOptions {
currentUser: AccessPolicyUser; currentUser: AccessPolicyUser;
@ -76,7 +73,10 @@ function normalizeStorageReference(value: string): string | null {
return stripStoragePrefix(trimmed.split(/[?#]/, 1)[0] ?? trimmed); return stripStoragePrefix(trimmed.split(/[?#]/, 1)[0] ?? trimmed);
} }
function collectStringReferences(value: unknown, references: Set<string>): void { function collectStringReferences(
value: unknown,
references: Set<string>,
): void {
if (typeof value === 'string') { if (typeof value === 'string') {
const normalized = normalizeStorageReference(value); const normalized = normalizeStorageReference(value);
if (normalized) references.add(normalized); if (normalized) references.add(normalized);
@ -149,10 +149,7 @@ export default class RuntimeAssetAccessService {
const projectSlug = AccessPolicy.normalizeSlug( const projectSlug = AccessPolicy.normalizeSlug(
runtimeContext?.headerProjectSlug, runtimeContext?.headerProjectSlug,
); );
if ( if (runtimeContext?.headerEnvironment !== 'production' || !projectSlug) {
runtimeContext?.headerEnvironment !== 'production' ||
!projectSlug
) {
return 'authentication_required'; return 'authentication_required';
} }
@ -192,11 +189,7 @@ export default class RuntimeAssetAccessService {
collectRecordFields(variants, ['storage_key', 'cdn_url'], references); collectRecordFields(variants, ['storage_key', 'cdn_url'], references);
collectRecordFields( collectRecordFields(
pages, pages,
[ ['background_image_url', 'background_video_url', 'background_audio_url'],
'background_image_url',
'background_video_url',
'background_audio_url',
],
references, references,
); );
for (const page of pages) { for (const page of pages) {

View File

@ -1072,7 +1072,8 @@ class TourPagesService extends BaseService {
const reversedUrl = const reversedUrl =
await TourPagesService.getExistingReversedVariant(storageKey); await TourPagesService.getExistingReversedVariant(storageKey);
if (reversedUrl && reversedUrl !== element.reverseVideoUrl) { if (reversedUrl) {
if (reversedUrl !== element.reverseVideoUrl) {
element.reverseVideoUrl = reversedUrl; element.reverseVideoUrl = reversedUrl;
wasModified = true; wasModified = true;
logger.info( logger.info(
@ -1084,6 +1085,7 @@ class TourPagesService extends BaseService {
}, },
'Added existing reversed video URL to element', 'Added existing reversed video URL to element',
); );
}
continue; continue;
} }

View File

@ -29,6 +29,7 @@ export interface PublishToProductionResult {
export interface SaveToStageResult { export interface SaveToStageResult {
success: true; success: true;
publishEventId: string; publishEventId: string;
summary: PublishSummary;
} }
export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed'; export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed';

View File

@ -167,11 +167,7 @@ function toValidatedEnvironment(
DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'), DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'),
DB_USER: readString(values, 'DB_USER', 'postgres'), DB_USER: readString(values, 'DB_USER', 'postgres'),
DB_PASS: readString(values, 'DB_PASS', ''), DB_PASS: readString(values, 'DB_PASS', ''),
SECRET_KEY: readString( SECRET_KEY: readString(values, 'SECRET_KEY', ''),
values,
'SECRET_KEY',
'',
),
ADMIN_PASS: readString(values, 'ADMIN_PASS', ''), ADMIN_PASS: readString(values, 'ADMIN_PASS', ''),
USER_PASS: readString(values, 'USER_PASS', ''), USER_PASS: readString(values, 'USER_PASS', ''),
ADMIN_EMAIL: readString(values, 'ADMIN_EMAIL', 'admin@flatlogic.com'), ADMIN_EMAIL: readString(values, 'ADMIN_EMAIL', 'admin@flatlogic.com'),

View File

@ -67,6 +67,7 @@ void test('OpenAPI document exposes comprehensive route coverage', () => {
'/api/runtime-access/me', '/api/runtime-access/me',
'/api/project-ui-control-settings/project/{projectId}/env/{environment}', '/api/project-ui-control-settings/project/{projectId}/env/{environment}',
'/api/tour_pages/reverse-video-status', '/api/tour_pages/reverse-video-status',
'/api/publish/save-to-stage',
]; ];
assert.equal(document.openapi, '3.0.0'); assert.equal(document.openapi, '3.0.0');
@ -90,6 +91,49 @@ void test('OpenAPI document resolves all internal refs', () => {
); );
}); });
void test('OpenAPI documents the completion-confirmed publishing result', () => {
const document = createTestDocument();
const publishResult = document.components.schemas.PublishResult;
const publishSummary = document.components.schemas.PublishSummary;
const saveToStageResponse =
document.paths['/api/publish/save-to-stage']?.post?.responses;
assert.ok(publishResult);
assert.ok(publishSummary);
assert.deepEqual(publishResult.required, [
'success',
'publishEventId',
'summary',
]);
assert.deepEqual(publishResult.properties, {
success: { type: 'boolean', enum: [true] },
publishEventId: { type: 'string', format: 'uuid' },
summary: { $ref: '#/components/schemas/PublishSummary' },
});
assert.deepEqual(publishSummary.required, [
'pages_copied',
'audios_copied',
'transition_settings_copied',
'ui_control_settings_copied',
]);
assert.deepEqual(saveToStageResponse, {
200: {
description: 'Save-to-stage result',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/PublishResult' },
},
},
},
400: { $ref: '#/components/responses/BadRequestError' },
401: { $ref: '#/components/responses/UnauthorizedError' },
403: { $ref: '#/components/responses/ForbiddenError' },
404: { $ref: '#/components/responses/NotFoundError' },
429: { $ref: '#/components/responses/RateLimitError' },
500: { $ref: '#/components/responses/ServerError' },
});
});
void test('OpenAPI factory CRUD paths are generated consistently', () => { void test('OpenAPI factory CRUD paths are generated consistently', () => {
const document = createTestDocument(); const document = createTestDocument();
const resourcePath = '/api/assets'; const resourcePath = '/api/assets';

View File

@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import db from '../src/db/models/index.ts';
import PublishService from '../src/services/publish.ts';
import type { PublishEventRecord, PublishSummary } from '../src/types/index.ts';
void test('saveToStage resolves only after the stage copy completes', async () => {
const summary: PublishSummary = {
pages_copied: 3,
audios_copied: 1,
transition_settings_copied: 1,
ui_control_settings_copied: 1,
};
const updates: Array<Record<string, unknown>> = [];
let releaseCopy: (() => void) | undefined;
const copyGate = new Promise<void>((resolve) => {
releaseCopy = resolve;
});
const publishEvent: PublishEventRecord = {
id: 'publish-event-1',
update(data) {
updates.push(data);
return Promise.resolve();
},
};
const originalCreate: unknown = Reflect.get(db.publish_events, 'create');
const originalWithProjectPublishLock: unknown = Reflect.get(
PublishService,
'withProjectPublishLock',
);
Reflect.set(db.publish_events, 'create', () => Promise.resolve(publishEvent));
Reflect.set(PublishService, 'withProjectPublishLock', () =>
copyGate.then(() => summary),
);
try {
let resolved = false;
const savePromise = PublishService.saveToStage('project-1', undefined).then(
(result) => {
resolved = true;
return result;
},
);
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
assert.equal(resolved, false);
releaseCopy?.();
assert.deepEqual(await savePromise, {
success: true,
publishEventId: 'publish-event-1',
summary,
});
assert.deepEqual(
updates.map((update) => update.status),
['success'],
);
} finally {
Reflect.set(db.publish_events, 'create', originalCreate);
Reflect.set(
PublishService,
'withProjectPublishLock',
originalWithProjectPublishLock,
);
}
});
void test('saveToStage records a failed event and rejects when the copy fails', async () => {
const copyError = new Error('Stage transaction failed');
const updates: Array<Record<string, unknown>> = [];
const publishEvent: PublishEventRecord = {
id: 'publish-event-2',
update(data) {
updates.push(data);
return Promise.resolve();
},
};
const originalCreate: unknown = Reflect.get(db.publish_events, 'create');
const originalWithProjectPublishLock: unknown = Reflect.get(
PublishService,
'withProjectPublishLock',
);
Reflect.set(db.publish_events, 'create', () => Promise.resolve(publishEvent));
Reflect.set(PublishService, 'withProjectPublishLock', () =>
Promise.reject(copyError),
);
try {
await assert.rejects(
PublishService.saveToStage('project-1', undefined),
copyError,
);
assert.equal(updates.length, 1);
assert.equal(updates[0]?.status, 'failed');
assert.equal(updates[0]?.error_message, copyError.message);
assert.ok(updates[0]?.finished_at instanceof Date);
} finally {
Reflect.set(db.publish_events, 'create', originalCreate);
Reflect.set(
PublishService,
'withProjectPublishLock',
originalWithProjectPublishLock,
);
}
});

View File

@ -75,26 +75,28 @@ void test('runtime page schemas collect media fields without treating labels as
}); });
void test('presigning without staff access or public runtime context requires authentication', async () => { void test('presigning without staff access or public runtime context requires authentication', async () => {
const authorization = const authorization = await RuntimeAssetAccessService.authorizePresignRequest(
await RuntimeAssetAccessService.authorizePresignRequest({ {
currentUser: undefined, currentUser: undefined,
runtimeContext: undefined, runtimeContext: undefined,
urls: ['assets/project/image.webp'], urls: ['assets/project/image.webp'],
}); },
);
assert.equal(authorization, 'authentication_required'); assert.equal(authorization, 'authentication_required');
}); });
void test('staff permissions authorize presigning without public runtime context', async () => { void test('staff permissions authorize presigning without public runtime context', async () => {
const authorization = const authorization = await RuntimeAssetAccessService.authorizePresignRequest(
await RuntimeAssetAccessService.authorizePresignRequest({ {
currentUser: { currentUser: {
id: 'staff-user', id: 'staff-user',
app_role_permissions: ['READ_ASSETS'], app_role_permissions: ['READ_ASSETS'],
}, },
runtimeContext: undefined, runtimeContext: undefined,
urls: ['assets/project/image.webp'], urls: ['assets/project/image.webp'],
}); },
);
assert.equal(authorization, 'allowed'); assert.equal(authorization, 'allowed');
}); });

View File

@ -21,6 +21,7 @@ import type {
RuntimeEnvironment, RuntimeEnvironment,
TourPageCreateOptions, TourPageCreateOptions,
TourPageRecord, TourPageRecord,
TourPageReverseGenerationTask,
TourPageUpdateOptions, TourPageUpdateOptions,
} from '../src/types/index.ts'; } from '../src/types/index.ts';
@ -268,6 +269,25 @@ function replaceAssetsDbApiFindBy(
}; };
} }
function replaceSingleReverseGenerationEnqueue(
value: (task: TourPageReverseGenerationTask) => void,
): () => void {
const original =
TourPagesService.enqueueSingleReverseGeneration.bind(TourPagesService);
Object.defineProperty(TourPagesService, 'enqueueSingleReverseGeneration', {
configurable: true,
value,
});
return () => {
Object.defineProperty(TourPagesService, 'enqueueSingleReverseGeneration', {
configurable: true,
value: original,
});
};
}
function createServiceDbApi( function createServiceDbApi(
calls: UpdateContractCalls, calls: UpdateContractCalls,
): EntityServiceDbApi<TestEntity, TestEntityData, TestEntityData> { ): EntityServiceDbApi<TestEntity, TestEntityData, TestEntityData> {
@ -866,6 +886,58 @@ void test('TourPagesService clears targeted back transition when incoming forwar
}); });
}); });
void test('TourPagesService does not enqueue generation when the reversed variant is already linked', async () => {
const enqueuedTasks: TourPageReverseGenerationTask[] = [];
const restoreAssetsFindBy = replaceAssetsDbApiFindBy(() =>
Promise.resolve({
id: 'asset-1',
storage_key: 'assets/transition.mp4',
asset_variants_asset: [
{
id: 'variant-1',
variant_type: 'reversed',
storage_key: 'assets/transition-reversed.mp4',
},
],
}),
);
const restoreEnqueue = replaceSingleReverseGenerationEnqueue((task) => {
enqueuedTasks.push(task);
});
try {
const pageData = {
id: 'page-1',
projectId: 'project-1',
ui_schema_json: {
elements: [
{
id: 'forward',
type: 'navigation_next',
navType: 'forward',
targetPageSlug: 'destination',
transitionVideoUrl: 'assets/transition.mp4',
transitionReverseMode: 'auto_reverse',
reverseVideoUrl: 'assets/transition-reversed.mp4',
},
],
},
};
const result = await TourPagesService.processReversedVideosAndUpdateSchema(
pageData,
undefined,
{ _skipHistoryModeCheck: true },
);
assert.equal(result, pageData);
assert.deepEqual(enqueuedTasks, []);
} finally {
restoreEnqueue();
restoreAssetsFindBy();
}
});
void test('TourPagesService.update clears stale targeted back transition before auto-reverse validation', async () => { void test('TourPagesService.update clears stale targeted back transition before auto-reverse validation', async () => {
const calls: UpdateContractCalls = {}; const calls: UpdateContractCalls = {};
const transaction: TestManagedTransaction = { const transaction: TestManagedTransaction = {

View File

@ -633,7 +633,7 @@ Clone project with all related entities.
Copy all `dev` environment content to `stage` for preview. Copy all `dev` environment content to `stage` for preview.
**Auth:** Required **Auth:** Required. **Permission:** `CREATE_PUBLISH_EVENTS`
**Request:** **Request:**
```json ```json
@ -649,12 +649,21 @@ Copy all `dev` environment content to `stage` for preview.
"publishEventId": "event-uuid", "publishEventId": "event-uuid",
"summary": { "summary": {
"pages_copied": 10, "pages_copied": 10,
"audios_copied": 2 "audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
} }
} }
``` ```
**Note:** This is part of the dev → stage → production workflow. Content is edited in `dev` (Constructor), previewed in `stage`, then published to `production`. Page elements, navigation, and transitions are stored in `tour_pages.ui_schema_json` and copied with pages. **Note:** The response is sent after the Dev → Stage transaction commits, so
the summary describes the Stage snapshot that is ready for preview. Page
elements, navigation, and transitions are stored in
`tour_pages.ui_schema_json` and copied with pages.
**Errors:** `400` invalid/concurrent request, `401` unauthenticated, `403`
permission denied, `404` project missing, `429` rate limited, or `500` copy
failure.
## Tour Pages Endpoints ## Tour Pages Endpoints
@ -1187,9 +1196,9 @@ The platform uses a three-tier publishing workflow:
Copy `dev` content to `stage` for preview. See [Projects Endpoints](#post-apipublishsave-to-stage). Copy `dev` content to `stage` for preview. See [Projects Endpoints](#post-apipublishsave-to-stage).
### POST /api/publish (or POST /api/publish/publish) ### POST /api/publish
Publish project from `stage` to `production` environment. Both endpoints are aliases and perform the same action. Publish project from `stage` to `production` environment.
**Auth:** Required **Auth:** Required
@ -1209,7 +1218,9 @@ Publish project from `stage` to `production` environment. Both endpoints are ali
"publishEventId": "event-uuid", "publishEventId": "event-uuid",
"summary": { "summary": {
"pages_copied": 10, "pages_copied": 10,
"audios_copied": 2 "audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
} }
} }
``` ```

View File

@ -278,7 +278,9 @@ Key functions:
**Generation Pattern:** **Generation Pattern:**
- Reversed videos are always generated for all navigation elements with transitions - Reversed videos are always generated for all navigation elements with transitions
- Generated on-demand when page is saved (create/update) - Generated on-demand when page is saved (create/update)
- Checked before generation to avoid duplication - Checked before generation to avoid duplication. If the stored reversed
variant is already linked from the element, the save path skips the
generation queue entirely.
- Different transition videos are processed sequentially through the global - Different transition videos are processed sequentially through the global
FFmpeg queue; the backend does not run multiple FFmpeg reversals in parallel FFmpeg queue; the backend does not run multiple FFmpeg reversals in parallel
- Background processing keeps save requests fast - Background processing keeps save requests fast

View File

@ -267,16 +267,23 @@ Authorization: Bearer {token}
```json ```json
{ {
"success": true, "success": true,
"publishEventId": "uuid" "publishEventId": "uuid",
"summary": {
"pages_copied": 5,
"audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
}
} }
``` ```
**Note:** Save to Stage is **non-blocking** - the API returns immediately after creating the publish event, and the actual copy operation continues in the background. Check the `publish_events` table for final status (`success` or `failed`). **Note:** Save to Stage returns only after the Dev → Stage transaction completes.
The response therefore confirms that Stage contains the saved snapshot and
includes the number of pages and audio tracks copied. Copy failures are returned
to the Constructor instead of being acknowledged as a successful queued job.
**Publish to Production (Stage → Production):** **Publish to Production (Stage → Production):**
*Note: Both `/api/publish` and `/api/publish/publish` route to the same handler.*
```http ```http
POST /api/publish POST /api/publish
Content-Type: application/json Content-Type: application/json
@ -296,7 +303,9 @@ Authorization: Bearer {token}
"publishEventId": "uuid", "publishEventId": "uuid",
"summary": { "summary": {
"pages_copied": 5, "pages_copied": 5,
"audios_copied": 2 "audios_copied": 2,
"transition_settings_copied": 1,
"ui_control_settings_copied": 1
} }
} }
``` ```
@ -316,10 +325,12 @@ GET /api/publish_events?project=id - Filter by project
| Operation | Blocking | Behavior | | Operation | Blocking | Behavior |
|-----------|----------|----------| |-----------|----------|----------|
| **Save to Stage** | No | Returns immediately, copy runs in background via `setImmediate()` | | **Save to Stage** | Yes | Waits for the Dev → Stage transaction and returns the copy summary |
| **Publish to Production** | Yes | Waits for entire copy operation before returning | | **Publish to Production** | Yes | Waits for entire copy operation before returning |
**Save to Stage** uses background processing because it's a frequent operation during development and shouldn't block the UI. **Publish to Production** remains blocking because it's a deliberate action that users expect to complete before seeing results. Both operations wait for their database copy to finish. This guarantees that a
subsequent Publish to Production cannot overtake a queued Save to Stage request
after the Constructor reports success.
### Complete Flow (Publish to Production) ### Complete Flow (Publish to Production)
@ -566,27 +577,50 @@ The Constructor uses the `useConstructorPageActions` hook which provides the `sa
const saveToStage = useCallback(async () => { const saveToStage = useCallback(async () => {
if (!projectId) { if (!projectId) {
onError?.('Project ID is required to save to stage.'); onError?.('Project ID is required to save to stage.');
return; return false;
} }
// First save current state, then copy to stage
await saveConstructor();
try {
setIsSavingToStage(true); setIsSavingToStage(true);
try {
// First persist current state to Dev, then copy the committed snapshot.
const didSave = await saveConstructor();
if (!didSave) return false;
// Note: axios baseURL adds '/api' prefix automatically // Note: axios baseURL adds '/api' prefix automatically
// Non-blocking: returns immediately, copy runs in background const response = await axios.post('/publish/save-to-stage', { projectId });
await axios.post('/publish/save-to-stage', { projectId }); const { pages_copied: pagesCopied } = response.data.summary;
onSuccess?.('Saved to stage.'); onSuccess?.(
`Saved to stage: ${pagesCopied} pages copied.`,
);
return true;
} catch (error: any) { } catch (error: any) {
onError?.(error?.response?.data?.message || 'Failed to save to stage'); onError?.(error?.response?.data?.message || 'Failed to save to stage');
return false;
} finally { } finally {
setIsSavingToStage(false); setIsSavingToStage(false);
} }
}, [projectId, saveConstructor, onError, onSuccess]); }, [projectId, saveConstructor, onError, onSuccess]);
``` ```
**Note:** The Save to Stage operation is non-blocking - the button returns to normal immediately while the actual copy operation continues in the background. The user sees a brief "Saved to stage" confirmation. **Note:** The Save to Stage button remains busy until the copy transaction
finishes. Its user-facing success message reports the committed page count; the
API summary retains audio and settings counts, while the publish event persists
page and audio counts for history. A failed copy keeps Stage unchanged and
displays an error.
The Constructor exposes the two sequential phases on the Stage button:
1. `Saving page...` while the current constructor page is committed to Dev.
2. `Copying to Stage...` while the project snapshot transaction is running.
Save, Stage, Exit, and page-level mutation controls are disabled during this
workflow so another request cannot alter or replace the snapshot in progress.
After the page update succeeds, the Stage copy starts immediately. The
successful `PUT` response updates the constructor's TanStack Query page cache
directly, so no page reload sits between persistence and the Stage request. The
publish-status timestamps refresh in the background and do not extend the
button's busy state. Other constructor page mutations refetch page data only;
unchanged project, asset, and element-default queries remain cached.
// constructor.tsx - Hook usage // constructor.tsx - Hook usage
const { const {
@ -964,7 +998,7 @@ This ensures smooth transitions regardless of environment (dev preview, stage, o
| **Purpose** | Active editing | Preview/testing | Public access | | **Purpose** | Active editing | Preview/testing | Public access |
| **Data Source** | `environment='dev'` | `environment='stage'` | `environment='production'` | | **Data Source** | `environment='dev'` | `environment='stage'` | `environment='production'` |
| **Editing** | Full editing | Read-only | Read-only | | **Editing** | Full editing | Read-only | Read-only |
| **Publish Action** | "Save to Stage" (non-blocking) → | "Publish to Production" (blocking) → | Final destination | | **Publish Action** | "Save to Stage" (completion-confirmed) → | "Publish to Production" (completion-confirmed) → | Final destination |
| **PWA Cache** | Not applicable | Can be generated | Primary target | | **PWA Cache** | Not applicable | Can be generated | Primary target |
| **Visibility** | Constructor only | Stage URL | Public URL | | **Visibility** | Constructor only | Stage URL | Public URL |
@ -986,8 +1020,10 @@ This ensures smooth transitions regardless of environment (dev preview, stage, o
1. Verify publish event completed with `status='success'` 1. Verify publish event completed with `status='success'`
2. Check `pages_copied` count is non-zero 2. Check `pages_copied` count is non-zero
3. Clear browser cache and reload presentation 3. Confirm the latest successful Dev → Stage event finished before the latest
4. Verify correct project slug in URL Stage → Production event started
4. Clear browser cache and reload presentation
5. Verify correct project slug in URL
### Stage/Production Mismatch ### Stage/Production Mismatch

View File

@ -702,6 +702,11 @@ background_embed_url: string;
The constructor background dropdown includes **Background 360**, sourced from `asset_type='embed'` assets. Selecting a 360/embed background clears image and video background URLs; background audio remains independent. The constructor background dropdown includes **Background 360**, sourced from `asset_type='embed'` assets. Selecting a 360/embed background clears image and video background URLs; background audio remains independent.
Asset dropdown options are deduplicated by their resolved storage key or embed
URL. If multiple asset records reference the same URL, the constructor shows the
first matching label once because identical select values cannot represent
distinct choices.
Constructor asset selectors load the full project asset list through Constructor asset selectors load the full project asset list through
`useConstructorData()` and then filter options client-side by `asset_type` and `useConstructorData()` and then filter options client-side by `asset_type` and
`type` for image, background image, video, audio, transition, icon, and embed `type` for image, background image, video, audio, transition, icon, and embed
@ -1173,24 +1178,20 @@ const saveConstructor = async () => {
setSaving(true); setSaving(true);
try { try {
// Serialize elements to JSON const payload = buildConstructorPageSavePayload({
const ui_schema_json = JSON.stringify({ activePageId,
elements: elements, activePage,
elementsToSave: elements,
pageBackground,
uiControlsSettings,
project,
}); });
// Update tour page via API (always saves to dev environment) // The mutation updates both detail and list query caches from the response.
await dispatch(tourPagesActions.update({ await updatePage({
id: activePageId, id: activePageId,
data: { data: payload.data,
ui_schema_json, });
background_image_url: backgroundImageUrl,
background_video_url: backgroundVideoUrl,
background_audio_url: backgroundAudioUrl,
},
}));
// Reload data to refresh
await loadData();
setSuccessMessage('Saved successfully'); setSuccessMessage('Saved successfully');
} catch (error) { } catch (error) {
@ -1203,28 +1204,47 @@ const saveConstructor = async () => {
### Save to Stage Function ### Save to Stage Function
**Note:** Save to Stage is **non-blocking** - the API returns immediately while the copy operation continues in the background. **Note:** Save to Stage returns after the Dev → Stage transaction completes, so
the success message confirms the reported pages are available in Stage.
The Stage button reports `Saving page...` during the initial Dev save and
`Copying to Stage...` during the environment copy. Save, Stage, Exit, page
selection, page ordering, page creation/deletion/duplication, and background
actions remain disabled until the operation finishes. This prevents overlapping
requests from changing the snapshot while it is being copied.
The Dev page `PUT` is the persistence boundary. Once it succeeds, the Stage copy
starts without waiting for a constructor data reload. The successful response
updates the TanStack Query page cache directly, including the displayed save
timestamp, without a follow-up GET that could race with the user's next edit.
The latest publish timestamp refreshes in the background, and other page
mutations refetch only the pages query because they do not change project
metadata, assets, or element defaults.
```typescript ```typescript
const saveToStage = async () => { const saveToStage = async () => {
if (!projectId) { if (!projectId) {
onError?.('Project ID is required to save to stage.'); onError?.('Project ID is required to save to stage.');
return; return false;
} }
// First save current work to dev
await saveConstructor();
try {
setIsSavingToStage(true); setIsSavingToStage(true);
try {
// First persist current work to Dev.
const didSave = await saveConstructor();
if (!didSave) return false;
// Non-blocking: returns immediately, copy runs in background const response = await axios.post('/publish/save-to-stage', { projectId });
await axios.post('/publish/save-to-stage', { projectId }); const pagesCopied = response.data.summary.pages_copied;
onSuccess?.('Saved to stage.'); onSuccess?.(
`Saved to stage: ${pagesCopied} page${pagesCopied === 1 ? '' : 's'} copied.`,
);
return true;
} catch (error) { } catch (error) {
const message = error?.response?.data?.message || error?.message || 'Failed to save to stage.'; const message = error?.response?.data?.message || error?.message || 'Failed to save to stage.';
onError?.(message); onError?.(message);
return false;
} finally { } finally {
setIsSavingToStage(false); setIsSavingToStage(false);
} }
@ -1275,8 +1295,8 @@ selects the next page in display order, or clears the editor when no pages
remain. remain.
**Backend Publish Flow:** **Backend Publish Flow:**
- Save to Stage (non-blocking): `POST /publish/save-to-stage``PublishService.saveToStage()``copyEnvironment(dev, stage)` (runs in background) - Save to Stage (completion-confirmed): `POST /publish/save-to-stage``PublishService.saveToStage()``copyEnvironment(dev, stage)`
- Publish to Prod (blocking): `POST /publish``PublishService.publishToProduction()``copyEnvironment(stage, production)` - Publish to Prod (completion-confirmed): `POST /publish``PublishService.publishToProduction()``copyEnvironment(stage, production)`
The `copyEnvironment` method: The `copyEnvironment` method:
1. Fetches all `tour_pages` and `project_audio_tracks` from source environment 1. Fetches all `tour_pages` and `project_audio_tracks` from source environment

View File

@ -1179,6 +1179,17 @@ const { isDragging, onDragStart, onDragEnd } = useCanvasElementDrag({
--- ---
#### useConstructorData
**File:** `useConstructorData.ts`
Loads the constructor's project, Dev pages, assets, and element defaults through
TanStack Query. Its explicit `refetchPages()` callback refreshes only the mutable
page list used by constructor page operations. Project metadata, assets, and
element defaults remain cached until their owning mutations invalidate them.
---
#### useConstructorPageActions #### useConstructorPageActions
**File:** `useConstructorPageActions.ts` (~361 LOC) **File:** `useConstructorPageActions.ts` (~361 LOC)
@ -1187,7 +1198,9 @@ Supporting boundary:
- `useConstructorPageActions.helpers.ts`: pending reverse-video key selection, save/create/duplicate payload builders, validation helpers, and API error fallback. - `useConstructorPageActions.helpers.ts`: pending reverse-video key selection, save/create/duplicate payload builders, validation helpers, and API error fallback.
**Purpose:** Page create/save/publish operations in constructor, including page **Purpose:** Page create/save/publish operations in constructor, including page
duplication orchestration. The hook owns React state, API calls, reload callbacks, duplication orchestration. Page saving uses the TanStack Query mutation so the
successful response updates cached page detail/list data without a follow-up
GET. The hook also owns operation state, reload callbacks for page mutations,
and reverse-video polling. and reverse-video polling.
```typescript ```typescript
@ -1196,7 +1209,7 @@ interface UseConstructorPageActionsOptions {
elements: CanvasElement[]; elements: CanvasElement[];
getElements?: () => CanvasElement[]; getElements?: () => CanvasElement[];
pageBackground: PageBackgroundState; pageBackground: PageBackgroundState;
onReload: () => Promise<void>; onReload: (preservePageId?: string) => Promise<void>;
} }
interface UseConstructorPageActionsResult { interface UseConstructorPageActionsResult {
@ -1205,7 +1218,7 @@ interface UseConstructorPageActionsResult {
isCreatingPage: boolean; isCreatingPage: boolean;
isDuplicatingPage: boolean; isDuplicatingPage: boolean;
saveConstructor: () => Promise<boolean>; saveConstructor: () => Promise<boolean>;
saveToStage: () => Promise<void>; saveToStage: () => Promise<boolean>;
createPage: (name: string, slug: string) => Promise<void>; createPage: (name: string, slug: string) => Promise<void>;
duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>; duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>;
} }
@ -1749,10 +1762,13 @@ const lastProjectSaveAt = useMemo(() => {
}, null as string | null); }, null as string | null);
}, [pages]); }, [pages]);
// Wrap saveToStage to refresh status // Refresh timestamps only after a successful Stage commit. The status GETs do
// not extend the Stage button's busy state.
const handleSaveToStage = useCallback(async () => { const handleSaveToStage = useCallback(async () => {
await saveToStage(); const didSaveToStage = await saveToStage();
await refreshPublishStatus(); if (didSaveToStage) {
void refreshPublishStatus();
}
}, [saveToStage, refreshPublishStatus]); }, [saveToStage, refreshPublishStatus]);
// Pass timestamps to ConstructorMenu // Pass timestamps to ConstructorMenu

View File

@ -2042,13 +2042,14 @@ function useConstructorPageActions(
| Option | Type | Description | | Option | Type | Description |
|--------|------|-------------| |--------|------|-------------|
| projectId | `string` | Current project ID | | projectId | `string` | Current project ID |
| project | `ConstructorProjectDimensions \| null` | Design dimensions used in the page snapshot |
| pages | `TourPage[]` | All pages | | pages | `TourPage[]` | All pages |
| activePage | `TourPage \| null` | Current page | | activePage | `TourPage \| null` | Current page |
| activePageId | `string` | Current page ID | | activePageId | `string` | Current page ID |
| elements | `CanvasElement[]` | Current elements | | elements | `CanvasElement[]` | Current elements |
| backgroundImageUrl | `string` | Background image | | getElements | `() => CanvasElement[]` | Read same-tick element state before saving |
| backgroundVideoUrl | `string` | Background video | | pageBackground | `PageBackgroundState` | Background media and playback settings |
| backgroundAudioUrl | `string` | Background audio | | uiControlsSettings | `UiControlsSettings \| null` | Page-level UI-control overrides |
| onReload | `(preservePageId?) => Promise<void>` | Reload callback | | onReload | `(preservePageId?) => Promise<void>` | Reload callback |
| onSetActivePageId | `(id) => void` | Set active page | | onSetActivePageId | `(id) => void` | Set active page |
| onSetMenuOpen | `(open) => void` | Set menu open | | onSetMenuOpen | `(open) => void` | Set menu open |
@ -2062,11 +2063,11 @@ function useConstructorPageActions(
| isSaving | `boolean` | Save in progress | | isSaving | `boolean` | Save in progress |
| isSavingToStage | `boolean` | Stage save in progress | | isSavingToStage | `boolean` | Stage save in progress |
| isCreatingPage | `boolean` | Page creation in progress | | isCreatingPage | `boolean` | Page creation in progress |
| isCreatingTransition | `boolean` | Transition creation in progress | | isDuplicatingPage | `boolean` | Page duplication in progress |
| saveConstructor | `() => Promise<void>` | Save current state | | saveConstructor | `() => Promise<boolean>` | Save current state and report success |
| saveToStage | `() => Promise<void>` | Save dev → stage | | saveToStage | `() => Promise<boolean>` | Save Dev, commit Dev → Stage, and report success |
| createPage | `() => Promise<void>` | Create new page | | createPage | `(name, slug) => Promise<void>` | Create a Dev page |
| createTransition | `(params) => Promise<void>` | Create transition (legacy) | | duplicatePage | `(sourcePageId, name, slug) => Promise<TourPage \| null>` | Duplicate a Dev page |
**Example:** **Example:**

View File

@ -562,6 +562,7 @@ Visual tour builder with canvas-based element editing.
| Hook | Purpose | | Hook | Purpose |
|------|---------| |------|---------|
| `useConstructorElements` | Element CRUD, selection, nested item helpers, and constructor-local element clipboard | | `useConstructorElements` | Element CRUD, selection, nested item helpers, and constructor-local element clipboard |
| `useConstructorPageWorkflow` | Composes page actions/management and refreshes publish status after successful Stage commits |
| `useConstructorPageActions` | Page save/create/duplicate and Save to Stage operations | | `useConstructorPageActions` | Page save/create/duplicate and Save to Stage operations |
| `useCanvasElementDrag` | Element positioning with percentage coordinates | | `useCanvasElementDrag` | Element positioning with percentage coordinates |
| `useTransitionPreview` | Transition video preview state | | `useTransitionPreview` | Transition video preview state |
@ -603,18 +604,21 @@ const ConstructorPage = () => {
allowedNavigationTypes, allowedNavigationTypes,
}); });
// Page persistence and page creation/duplication // Page persistence, management, and publish-status orchestration
const { const {
saveConstructor, saveConstructor,
saveToStage, handleSaveToStage,
createPage, } = useConstructorPageWorkflow({
duplicatePage, projectId,
} = useConstructorPageActions({ project,
pages,
activePage,
activePageId, activePageId,
elements, elements,
getElements, getElements,
pageBackground, pageBackground,
onReload: handleReload, refetchData,
// ... constructor callbacks
}); });
return ( return (
@ -629,7 +633,7 @@ const ConstructorPage = () => {
canCopyElement={Boolean(selectedElement)} canCopyElement={Boolean(selectedElement)}
canPasteElement={canPasteElement} canPasteElement={canPasteElement}
onSave={saveConstructor} onSave={saveConstructor}
onSaveToStage={saveToStage} onSaveToStage={handleSaveToStage}
/> />
{/* Center: Canvas */} {/* Center: Canvas */}

View File

@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
getCollapsedToolbarPageName, getCollapsedToolbarPageName,
getConstructorSaveControlState,
getConstructorToolbarActionState, getConstructorToolbarActionState,
getConstructorToolbarMaxWidth, getConstructorToolbarMaxWidth,
sortToolbarPages, sortToolbarPages,
@ -46,6 +47,53 @@ test('getCollapsedToolbarPageName returns active page name or fallback', () => {
); );
}); });
test('getConstructorSaveControlState describes direct and staged save phases', () => {
assert.deepEqual(
getConstructorSaveControlState({
isSaving: false,
isSavingToStage: false,
}),
{
isBusy: false,
saveLabel: 'Save',
stageLabel: 'Stage',
},
);
assert.deepEqual(
getConstructorSaveControlState({
isSaving: true,
isSavingToStage: false,
}),
{
isBusy: true,
saveLabel: 'Saving page...',
stageLabel: 'Stage',
},
);
assert.deepEqual(
getConstructorSaveControlState({
isSaving: true,
isSavingToStage: true,
}),
{
isBusy: true,
saveLabel: 'Save',
stageLabel: 'Saving page...',
},
);
assert.deepEqual(
getConstructorSaveControlState({
isSaving: false,
isSavingToStage: true,
}),
{
isBusy: true,
saveLabel: 'Save',
stageLabel: 'Copying to Stage...',
},
);
});
test('getConstructorToolbarActionState derives page and element action flags', () => { test('getConstructorToolbarActionState derives page and element action flags', () => {
const state = getConstructorToolbarActionState({ const state = getConstructorToolbarActionState({
pages: [ pages: [

View File

@ -15,6 +15,28 @@ export interface ConstructorToolbarActionState {
canPasteCurrentElement: boolean; canPasteCurrentElement: boolean;
} }
export interface ConstructorSaveControlState {
isBusy: boolean;
saveLabel: string;
stageLabel: string;
}
export const getConstructorSaveControlState = ({
isSaving,
isSavingToStage,
}: {
isSaving: boolean;
isSavingToStage: boolean;
}): ConstructorSaveControlState => ({
isBusy: isSaving || isSavingToStage,
saveLabel: isSaving && !isSavingToStage ? 'Saving page...' : 'Save',
stageLabel: isSavingToStage
? isSaving
? 'Saving page...'
: 'Copying to Stage...'
: 'Stage',
});
export const getConstructorToolbarMaxWidth = ( export const getConstructorToolbarMaxWidth = (
positionX: number, positionX: number,
viewportMargin = TOOLBAR_VIEWPORT_MARGIN_PX, viewportMargin = TOOLBAR_VIEWPORT_MARGIN_PX,

View File

@ -158,6 +158,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
onSelectMenuItem={onSelectMenuItem} onSelectMenuItem={onSelectMenuItem}
isReorderingPages={isReorderingPages} isReorderingPages={isReorderingPages}
isCreatingPage={isCreatingPage} isCreatingPage={isCreatingPage}
isPresentationSaving={isSaving || isSavingToStage}
canMovePageUp={actionState.canMovePageUp} canMovePageUp={actionState.canMovePageUp}
canMovePageDown={actionState.canMovePageDown} canMovePageDown={actionState.canMovePageDown}
canDuplicatePage={actionState.canDuplicatePage} canDuplicatePage={actionState.canDuplicatePage}

View File

@ -94,6 +94,8 @@ const ConstructorToolbarLayer = ({
return null; return null;
} }
const isPresentationSaving = isSaving || isSavingToStage;
return ( return (
<ConstructorToolbar <ConstructorToolbar
ref={toolbarRef} ref={toolbarRef}
@ -108,10 +110,12 @@ const ConstructorToolbarLayer = ({
onMovePage={onMovePage} onMovePage={onMovePage}
isReorderingPages={isReorderingPages} isReorderingPages={isReorderingPages}
onDuplicatePage={onDuplicatePage} onDuplicatePage={onDuplicatePage}
isDuplicatingPage={isSaving || isDuplicatingPage} isDuplicatingPage={isPresentationSaving || isDuplicatingPage}
onDeletePage={onDeletePage} onDeletePage={onDeletePage}
canDeletePage={canDeletePage} canDeletePage={canDeletePage}
isDeletingPage={isDeletingPage || isSaving || isDuplicatingPage} isDeletingPage={
isDeletingPage || isPresentationSaving || isDuplicatingPage
}
interactionMode={interactionMode} interactionMode={interactionMode}
onModeChange={onModeChange} onModeChange={onModeChange}
onSelectMenuItem={onSelectMenuItem} onSelectMenuItem={onSelectMenuItem}

View File

@ -28,6 +28,7 @@ interface Props {
onSelectMenuItem: (item: EditorMenuItem) => void; onSelectMenuItem: (item: EditorMenuItem) => void;
isReorderingPages: boolean; isReorderingPages: boolean;
isCreatingPage: boolean; isCreatingPage: boolean;
isPresentationSaving: boolean;
canMovePageUp: boolean; canMovePageUp: boolean;
canMovePageDown: boolean; canMovePageDown: boolean;
canDuplicatePage: boolean; canDuplicatePage: boolean;
@ -55,6 +56,7 @@ export default function ConstructorToolbarPageActions({
onSelectMenuItem, onSelectMenuItem,
isReorderingPages, isReorderingPages,
isCreatingPage, isCreatingPage,
isPresentationSaving,
canMovePageUp, canMovePageUp,
canMovePageDown, canMovePageDown,
canDuplicatePage, canDuplicatePage,
@ -78,14 +80,14 @@ export default function ConstructorToolbarPageActions({
pages={pages} pages={pages}
activePageId={activePageId} activePageId={activePageId}
onPageChange={onPageChange} onPageChange={onPageChange}
disabled={isReorderingPages} disabled={isReorderingPages || isPresentationSaving}
className='h-10 min-w-[160px] max-w-[210px] flex-1' className='h-10 min-w-[160px] max-w-[210px] flex-1'
/> />
<div className='flex items-center gap-1'> <div className='flex items-center gap-1'>
<button <button
type='button' type='button'
onClick={() => onMovePage?.('up')} onClick={() => onMovePage?.('up')}
disabled={!canMovePageUp} disabled={!canMovePageUp || isPresentationSaving}
className={iconBtnClass} className={iconBtnClass}
title='Move page up' title='Move page up'
aria-label='Move page up' aria-label='Move page up'
@ -95,7 +97,7 @@ export default function ConstructorToolbarPageActions({
<button <button
type='button' type='button'
onClick={() => onMovePage?.('down')} onClick={() => onMovePage?.('down')}
disabled={!canMovePageDown} disabled={!canMovePageDown || isPresentationSaving}
className={iconBtnClass} className={iconBtnClass}
title='Move page down' title='Move page down'
aria-label='Move page down' aria-label='Move page down'
@ -106,9 +108,15 @@ export default function ConstructorToolbarPageActions({
<button <button
type='button' type='button'
onClick={onCreatePage} onClick={onCreatePage}
disabled={isCreatingPage} disabled={isCreatingPage || isPresentationSaving}
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`} className={`${triggerBtnClass} ${isCreatingPage || isPresentationSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label={isCreatingPage ? 'Creating page' : 'Create page'} aria-label={
isPresentationSaving
? 'Presentation save in progress'
: isCreatingPage
? 'Creating page'
: 'Create page'
}
> >
<BaseIcon path={mdiPlus} size={18} /> <BaseIcon path={mdiPlus} size={18} />
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span> <span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
@ -116,7 +124,7 @@ export default function ConstructorToolbarPageActions({
<button <button
type='button' type='button'
onClick={onDuplicatePage} onClick={onDuplicatePage}
disabled={!canDuplicatePage} disabled={!canDuplicatePage || isPresentationSaving}
className={iconBtnClass} className={iconBtnClass}
title='Duplicate page' title='Duplicate page'
aria-label='Duplicate page' aria-label='Duplicate page'
@ -126,7 +134,7 @@ export default function ConstructorToolbarPageActions({
<button <button
type='button' type='button'
onClick={onDeletePage} onClick={onDeletePage}
disabled={!canDeleteCurrentPage} disabled={!canDeleteCurrentPage || isPresentationSaving}
className='flex h-10 w-10 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35' className='flex h-10 w-10 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
title='Delete page' title='Delete page'
aria-label='Delete page' aria-label='Delete page'
@ -138,14 +146,15 @@ export default function ConstructorToolbarPageActions({
ref={backgroundTriggerRef} ref={backgroundTriggerRef}
type='button' type='button'
onClick={onToggleBackgroundDropdown} onClick={onToggleBackgroundDropdown}
className={triggerBtnClass} disabled={isPresentationSaving}
className={`${triggerBtnClass} ${isPresentationSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label='Background actions' aria-label='Background actions'
> >
<BaseIcon path={mdiImageMultiple} size={18} /> <BaseIcon path={mdiImageMultiple} size={18} />
<span>BG</span> <span>BG</span>
<BaseIcon path={mdiChevronDown} size={16} /> <BaseIcon path={mdiChevronDown} size={16} />
</button> </button>
{isBackgroundDropdownActive && ( {isBackgroundDropdownActive && !isPresentationSaving && (
<ClickOutside <ClickOutside
onClickOutside={onCloseDropdown} onClickOutside={onCloseDropdown}
excludedElements={[backgroundTriggerRef]} excludedElements={[backgroundTriggerRef]}

View File

@ -2,6 +2,7 @@ import { mdiChevronLeft, mdiExitToApp } from '@mdi/js';
import dataFormatter from '../../helpers/dataFormatter'; import dataFormatter from '../../helpers/dataFormatter';
import BaseButton from '../BaseButton'; import BaseButton from '../BaseButton';
import BaseIcon from '../BaseIcon'; import BaseIcon from '../BaseIcon';
import { getConstructorSaveControlState } from './ConstructorToolbar.helpers';
interface Props { interface Props {
isSaving: boolean; isSaving: boolean;
@ -24,40 +25,49 @@ export default function ConstructorToolbarSaveControls({
onExit, onExit,
onCollapse, onCollapse,
}: Props) { }: Props) {
const { isBusy, saveLabel, stageLabel } = getConstructorSaveControlState({
isSaving,
isSavingToStage,
});
return ( return (
<div className='flex min-h-[58px] flex-wrap items-center gap-2 border-l border-white/15 pl-3'> <div
className='flex min-h-[58px] flex-wrap items-center gap-2 border-l border-white/15 pl-3'
aria-busy={isBusy}
>
<BaseButton <BaseButton
small small
color='info' color='info'
className='h-10 w-[86px]' className='h-10 w-[112px]'
label={isSaving ? 'Saving...' : 'Save'} label={saveLabel}
subtitle={ subtitle={
lastSavedAt ? dataFormatter.relativeTimestamp(lastSavedAt) : ' ' lastSavedAt ? dataFormatter.relativeTimestamp(lastSavedAt) : ' '
} }
onClick={onSave} onClick={onSave}
disabled={isSaving} disabled={isBusy}
/> />
<BaseButton <BaseButton
small small
color='success' color='success'
className='h-10 w-[86px]' className='h-10 w-[154px]'
label={isSavingToStage ? 'Saving...' : 'Stage'} label={stageLabel}
subtitle={ subtitle={
lastSavedToStageAt lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt) ? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: ' ' : ' '
} }
onClick={onSaveToStage} onClick={onSaveToStage}
disabled={isSavingToStage} disabled={isBusy}
/> />
<button <button
type='button' type='button'
onClick={onExit} onClick={onExit}
className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300' disabled={isBusy}
title='Exit constructor' className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-35'
aria-label='Exit constructor' title={isBusy ? 'Wait for saving to finish' : 'Exit constructor'}
aria-label={isBusy ? 'Saving in progress' : 'Exit constructor'}
> >
<BaseIcon path={mdiExitToApp} size={26} /> <BaseIcon path={mdiExitToApp} size={26} />
</button> </button>

View File

@ -120,8 +120,10 @@ export function useConstructorPageWorkflow({
}); });
const handleSaveToStage = useCallback(async () => { const handleSaveToStage = useCallback(async () => {
await saveToStage(); const didSaveToStage = await saveToStage();
await refreshPublishStatus(); if (didSaveToStage) {
void refreshPublishStatus();
}
}, [saveToStage, refreshPublishStatus]); }, [saveToStage, refreshPublishStatus]);
return { return {

View File

@ -14,6 +14,16 @@ interface PagesListResponse {
count: number; count: number;
} }
type UpdatePageData = Omit<Partial<TourPage>, 'ui_schema_json'> & {
ui_schema_json?: string | Record<string, unknown>;
};
export const replaceTourPageInList = (
pages: TourPage[] | undefined,
updatedPage: TourPage,
): TourPage[] | undefined =>
pages?.map((page) => (page.id === updatedPage.id ? updatedPage : page));
/** /**
* Fetch tour pages for a project * Fetch tour pages for a project
*/ */
@ -61,7 +71,7 @@ export function useUpdatePageMutation() {
data, data,
}: { }: {
id: string; id: string;
data: Partial<TourPage>; data: UpdatePageData;
}): Promise<TourPage> => { }): Promise<TourPage> => {
const response = await axios.put<TourPage>(`tour_pages/${id}`, { const response = await axios.put<TourPage>(`tour_pages/${id}`, {
id, id,
@ -70,10 +80,11 @@ export function useUpdatePageMutation() {
return response.data; return response.data;
}, },
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
// Update the single page cache
queryClient.setQueryData(queryKeys.tourPages.detail(variables.id), data); queryClient.setQueryData(queryKeys.tourPages.detail(variables.id), data);
// Invalidate list queries queryClient.setQueriesData<TourPage[]>(
queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all }); { queryKey: queryKeys.tourPages.lists() },
(pages) => replaceTourPageInList(pages, data),
);
}, },
}); });
} }

View File

@ -12,6 +12,7 @@ import {
buildIconAssetOptions, buildIconAssetOptions,
buildImageAssetOptions, buildImageAssetOptions,
buildTransitionVideoOptions, buildTransitionVideoOptions,
deduplicateAssetOptions,
getAssetSourceValue, getAssetSourceValue,
} from '../lib/constructorHelpers'; } from '../lib/constructorHelpers';
import type { import type {
@ -68,6 +69,7 @@ export function useAssetOptions({
// Video assets (excluding transition videos) // Video assets (excluding transition videos)
const videoOptions = useMemo( const videoOptions = useMemo(
() => () =>
deduplicateAssetOptions(
assets assets
.filter( .filter(
(asset) => (asset) =>
@ -81,6 +83,7 @@ export function useAssetOptions({
? `${asset.name} · ${getAssetSourceValue(asset)}` ? `${asset.name} · ${getAssetSourceValue(asset)}`
: getAssetSourceValue(asset), : getAssetSourceValue(asset),
})), })),
),
[assets], [assets],
); );
@ -99,14 +102,17 @@ export function useAssetOptions({
// Embed assets (360° panoramas, iframes) - filter by asset_type='embed' // Embed assets (360° panoramas, iframes) - filter by asset_type='embed'
const embedOptions = useMemo( const embedOptions = useMemo(
() => () =>
deduplicateAssetOptions(
assets assets
.filter( .filter(
(asset) => asset.asset_type === 'embed' && getAssetSourceValue(asset), (asset) =>
asset.asset_type === 'embed' && getAssetSourceValue(asset),
) )
.map((asset) => ({ .map((asset) => ({
value: getAssetSourceValue(asset), value: getAssetSourceValue(asset),
label: asset.name || getAssetSourceValue(asset), label: asset.name || getAssetSourceValue(asset),
})), })),
),
[assets], [assets],
); );

View File

@ -5,7 +5,7 @@
* Replaces the manual loadData function with cached, deduplicated queries. * Replaces the manual loadData function with cached, deduplicated queries.
*/ */
import { useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { extractPageLinksAndElements } from '../lib/extractPageLinks'; import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import type { CanvasElement, CanvasElementType } from '../types/constructor'; import type { CanvasElement, CanvasElementType } from '../types/constructor';
import type { Asset, TourPage } from '../types/entities'; import type { Asset, TourPage } from '../types/entities';
@ -54,8 +54,8 @@ interface UseConstructorDataResult {
isError: boolean; isError: boolean;
error: Error | null; error: Error | null;
// Refetch function // Refetch mutable constructor page data
refetch: () => Promise<void>; refetchPages: () => Promise<void>;
} }
export function useConstructorData({ export function useConstructorData({
@ -70,6 +70,7 @@ export function useConstructorData({
// Fetch pages (dev environment for constructor) // Fetch pages (dev environment for constructor)
const pagesQuery = usePagesQuery(enabled ? projectId : undefined, 'dev'); const pagesQuery = usePagesQuery(enabled ? projectId : undefined, 'dev');
const refetchPageQuery = pagesQuery.refetch;
// Fetch assets // Fetch assets
const assetsQuery = useAssetsQuery(enabled ? projectId : undefined); const assetsQuery = useAssetsQuery(enabled ? projectId : undefined);
@ -112,15 +113,12 @@ export function useConstructorData({
assetsQuery.error || assetsQuery.error ||
elementDefaultsQuery.error; elementDefaultsQuery.error;
// Refetch all queries // Constructor mutations in this workflow only change pages. Project,
const refetch = async () => { // asset, and element-default queries remain cached until their own
await Promise.all([ // mutations invalidate them.
projectQuery.refetch(), const refetchPages = useCallback(async () => {
pagesQuery.refetch(), await refetchPageQuery();
assetsQuery.refetch(), }, [refetchPageQuery]);
elementDefaultsQuery.refetch(),
]);
};
return { return {
// Project // Project
@ -145,7 +143,7 @@ export function useConstructorData({
error: error instanceof Error ? error : null, error: error instanceof Error ? error : null,
// Refetch // Refetch
refetch, refetchPages,
}; };
} }

View File

@ -12,6 +12,7 @@ import type { CanvasElement } from '../types/constructor';
import type { TourPage } from '../types/entities'; import type { TourPage } from '../types/entities';
import type { PageBackgroundState } from '../types/pageBackground'; import type { PageBackgroundState } from '../types/pageBackground';
import type { UiControlsSettings } from '../types/uiControls'; import type { UiControlsSettings } from '../types/uiControls';
import { useUpdatePageMutation } from './queries/usePagesQuery';
import { import {
buildConstructorPageSavePayload, buildConstructorPageSavePayload,
buildCreateConstructorPagePayload, buildCreateConstructorPagePayload,
@ -29,6 +30,17 @@ interface Project extends ConstructorProjectDimensions {
name?: string; name?: string;
} }
interface SaveToStageResponse {
success: true;
publishEventId: string;
summary: {
pages_copied: number;
audios_copied: number;
transition_settings_copied: number;
ui_control_settings_copied: number;
};
}
interface UseConstructorPageActionsOptions { interface UseConstructorPageActionsOptions {
/** Current project ID */ /** Current project ID */
projectId: string; projectId: string;
@ -72,7 +84,7 @@ interface UseConstructorPageActionsResult {
/** Save current constructor state */ /** Save current constructor state */
saveConstructor: () => Promise<boolean>; saveConstructor: () => Promise<boolean>;
/** Save dev content to stage environment */ /** Save dev content to stage environment */
saveToStage: () => Promise<void>; saveToStage: () => Promise<boolean>;
/** Create a new page with the given name and slug */ /** Create a new page with the given name and slug */
createPage: (pageName: string, slug: string) => Promise<void>; createPage: (pageName: string, slug: string) => Promise<void>;
/** Duplicate an existing page with the given name and slug */ /** Duplicate an existing page with the given name and slug */
@ -125,6 +137,7 @@ export function useConstructorPageActions({
const [isSavingToStage, setIsSavingToStage] = useState(false); const [isSavingToStage, setIsSavingToStage] = useState(false);
const [isCreatingPage, setIsCreatingPage] = useState(false); const [isCreatingPage, setIsCreatingPage] = useState(false);
const [isDuplicatingPage, setIsDuplicatingPage] = useState(false); const [isDuplicatingPage, setIsDuplicatingPage] = useState(false);
const { mutateAsync: updatePage } = useUpdatePageMutation();
// Polling hook for reverse video generation status // Polling hook for reverse video generation status
const { startPolling } = useReverseVideoPolling({ const { startPolling } = useReverseVideoPolling({
@ -147,22 +160,19 @@ export function useConstructorPageActions({
// These are elements that will trigger async reversed video generation // These are elements that will trigger async reversed video generation
const pendingReverseKeys = getPendingReverseVideoKeys(elementsToSave); const pendingReverseKeys = getPendingReverseVideoKeys(elementsToSave);
await axios.put( const payload = buildConstructorPageSavePayload({
`/tour_pages/${activePageId}`,
buildConstructorPageSavePayload({
activePageId, activePageId,
activePage, activePage,
elementsToSave, elementsToSave,
pageBackground, pageBackground,
uiControlsSettings, uiControlsSettings,
project, project,
}), });
); await updatePage({ id: activePageId, data: payload.data });
onSuccess?.( onSuccess?.(
'Constructor settings saved. Element positions are stored in percentages.', 'Constructor settings saved. Element positions are stored in percentages.',
); );
await onReload(activePageId);
// Start polling for reverse video generation if there are pending keys // Start polling for reverse video generation if there are pending keys
// This will automatically reload page data when all videos are ready // This will automatically reload page data when all videos are ready
@ -196,24 +206,32 @@ export function useConstructorPageActions({
getElements, getElements,
project, project,
onError, onError,
onReload,
onSuccess, onSuccess,
startPolling, startPolling,
updatePage,
]); ]);
const saveToStage = useCallback(async () => { const saveToStage = useCallback(async () => {
if (!projectId) { if (!projectId) {
onError?.('Project ID is required to save to stage.'); onError?.('Project ID is required to save to stage.');
return; return false;
} }
const didSave = await saveConstructor();
if (!didSave) return;
try {
setIsSavingToStage(true); setIsSavingToStage(true);
await axios.post('/publish/save-to-stage', { projectId }); try {
onSuccess?.('Saved to stage.'); const didSave = await saveConstructor();
if (!didSave) return false;
const response = await axios.post<SaveToStageResponse>(
'/publish/save-to-stage',
{ projectId },
);
const { pages_copied: pagesCopied } = response.data.summary;
onSuccess?.(
`Saved to stage: ${pagesCopied} page${pagesCopied === 1 ? '' : 's'} copied.`,
);
return true;
} catch (error: unknown) { } catch (error: unknown) {
const message = getConstructorActionErrorMessage( const message = getConstructorActionErrorMessage(
error, error,
@ -224,6 +242,7 @@ export function useConstructorPageActions({
error instanceof Error ? error : { error }, error instanceof Error ? error : { error },
); );
onError?.(message); onError?.(message);
return false;
} finally { } finally {
setIsSavingToStage(false); setIsSavingToStage(false);
} }

View File

@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { replaceTourPageInList } from './queries/usePagesQuery';
test('replaceTourPageInList updates only the matching cached page', () => {
const unchangedPage = { id: 'page-1', name: 'First' };
const originalPage = { id: 'page-2', name: 'Before' };
const updatedPage = {
id: 'page-2',
name: 'After',
updatedAt: '2026-07-30T10:14:16.022Z',
};
const result = replaceTourPageInList(
[unchangedPage, originalPage],
updatedPage,
);
assert.deepEqual(result, [unchangedPage, updatedPage]);
assert.equal(result?.[0], unchangedPage);
assert.equal(replaceTourPageInList(undefined, updatedPage), undefined);
});

View File

@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
addFallbackAssetOption,
deduplicateAssetOptions,
} from './constructorHelpers';
test('deduplicateAssetOptions keeps one option for duplicate resolved URLs', () => {
const duplicateUrl =
'https://360stories.com/paris/place/eiffel-tower-at-night?mode=2&playerMode=2';
assert.deepEqual(
deduplicateAssetOptions([
{ value: duplicateUrl, label: 'Eiffel Tower' },
{ value: duplicateUrl, label: 'Eiffel Tower duplicate' },
{ value: ' https://example.com/tour ', label: 'Another tour' },
]),
[
{ value: duplicateUrl, label: 'Eiffel Tower' },
{ value: 'https://example.com/tour', label: 'Another tour' },
],
);
});
test('addFallbackAssetOption also normalizes existing duplicate options', () => {
assert.deepEqual(
addFallbackAssetOption(
[
{ value: 'assets/image.jpg', label: 'Image' },
{ value: 'assets/image.jpg', label: 'Duplicate image' },
],
'assets/image.jpg',
),
[{ value: 'assets/image.jpg', label: 'Image' }],
);
});

View File

@ -45,6 +45,27 @@ export const getAssetLabel = (asset: ProjectAsset): string => {
export const getAssetSourceValue = (asset: ProjectAsset): string => export const getAssetSourceValue = (asset: ProjectAsset): string =>
String(asset.storage_key || asset.cdn_url || '').trim(); String(asset.storage_key || asset.cdn_url || '').trim();
/**
* Deduplicate select options by their submitted value.
* Multiple asset records can legitimately resolve to the same storage key or
* embed URL, but a select cannot distinguish options with identical values.
*/
export const deduplicateAssetOptions = (
options: AssetOption[],
): AssetOption[] => {
const seenValues = new Set<string>();
const uniqueOptions: AssetOption[] = [];
for (const option of options) {
const value = option.value.trim();
if (!value || seenValues.has(value)) continue;
seenValues.add(value);
uniqueOptions.push({ ...option, value });
}
return uniqueOptions;
};
/** /**
* Check if an asset is likely a background image based on name/type. * Check if an asset is likely a background image based on name/type.
* Used to filter assets for background image selection. * Used to filter assets for background image selection.
@ -70,11 +91,12 @@ export const addFallbackAssetOption = (
fallbackLabel?: string, fallbackLabel?: string,
): AssetOption[] => { ): AssetOption[] => {
const normalizedValue = String(value || '').trim(); const normalizedValue = String(value || '').trim();
if (!normalizedValue) return options; const uniqueOptions = deduplicateAssetOptions(options);
if (options.some((option) => option.value === normalizedValue)) if (!normalizedValue) return uniqueOptions;
return options; if (uniqueOptions.some((option) => option.value === normalizedValue))
return uniqueOptions;
return [ return [
...options, ...uniqueOptions,
{ {
value: normalizedValue, value: normalizedValue,
label: fallbackLabel || `Custom URL · ${normalizedValue}`, label: fallbackLabel || `Custom URL · ${normalizedValue}`,
@ -122,7 +144,8 @@ export const buildAssetOptions = (
assetType: 'image' | 'video' | 'audio', assetType: 'image' | 'video' | 'audio',
additionalFilter?: (asset: ProjectAsset) => boolean, additionalFilter?: (asset: ProjectAsset) => boolean,
): AssetOption[] => { ): AssetOption[] => {
return assets return deduplicateAssetOptions(
assets
.filter((asset) => { .filter((asset) => {
if (asset.asset_type !== assetType) return false; if (asset.asset_type !== assetType) return false;
if (!getAssetSourceValue(asset)) return false; if (!getAssetSourceValue(asset)) return false;
@ -132,7 +155,8 @@ export const buildAssetOptions = (
.map((asset) => ({ .map((asset) => ({
value: getAssetSourceValue(asset), value: getAssetSourceValue(asset),
label: getAssetLabel(asset), label: getAssetLabel(asset),
})); })),
);
}; };
/** /**
@ -183,7 +207,7 @@ export const buildTransitionVideoOptions = (
label: getAssetLabel(asset), label: getAssetLabel(asset),
})); }));
if (typedAssets.length > 0) return typedAssets; if (typedAssets.length > 0) return deduplicateAssetOptions(typedAssets);
// Fall back to assets with [TRANSITION] tag in name // Fall back to assets with [TRANSITION] tag in name
const taggedAssets = assets const taggedAssets = assets
@ -198,7 +222,7 @@ export const buildTransitionVideoOptions = (
label: getAssetLabel(asset), label: getAssetLabel(asset),
})); }));
if (taggedAssets.length > 0) return taggedAssets; if (taggedAssets.length > 0) return deduplicateAssetOptions(taggedAssets);
// Fall back to all video assets // Fall back to all video assets
return buildVideoAssetOptions(assets); return buildVideoAssetOptions(assets);
@ -211,7 +235,8 @@ export const buildTransitionVideoOptions = (
export const buildIconAssetOptions = ( export const buildIconAssetOptions = (
assets: ProjectAsset[], assets: ProjectAsset[],
): AssetOption[] => { ): AssetOption[] => {
return assets return deduplicateAssetOptions(
assets
.filter( .filter(
(asset) => (asset) =>
asset.type === 'icon' && asset.type === 'icon' &&
@ -221,7 +246,8 @@ export const buildIconAssetOptions = (
.map((asset) => ({ .map((asset) => ({
value: getAssetSourceValue(asset), value: getAssetSourceValue(asset),
label: getAssetLabel(asset), label: getAssetLabel(asset),
})); })),
);
}; };
/** /**

View File

@ -59,8 +59,9 @@ export const queryKeys = {
// Tour Pages // Tour Pages
tourPages: { tourPages: {
all: ['tourPages'] as const, all: ['tourPages'] as const,
lists: () => [...queryKeys.tourPages.all, 'list'] as const,
list: (projectId: string, environment?: string) => list: (projectId: string, environment?: string) =>
[...queryKeys.tourPages.all, 'list', { projectId, environment }] as const, [...queryKeys.tourPages.lists(), { projectId, environment }] as const,
detail: (id: string) => [...queryKeys.tourPages.all, 'detail', id] as const, detail: (id: string) => [...queryKeys.tourPages.all, 'detail', id] as const,
byProject: (projectId: string) => byProject: (projectId: string) =>
[...queryKeys.tourPages.all, 'byProject', projectId] as const, [...queryKeys.tourPages.all, 'byProject', projectId] as const,

View File

@ -131,7 +131,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isLoading: isDataLoading, isLoading: isDataLoading,
isError: isDataError, isError: isDataError,
error: dataError, error: dataError,
refetch: refetchData, refetchPages: refetchData,
} = useConstructorData({ } = useConstructorData({
projectId, projectId,
isAuthReady, isAuthReady,
@ -749,7 +749,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
pagesCount={pages.length} pagesCount={pages.length}
isElementEditMode={isElementEditMode} isElementEditMode={isElementEditMode}
pageElementsListHref={pageElementsListHref} pageElementsListHref={pageElementsListHref}
isSaving={isSaving} isSaving={isSaving || isSavingToStage}
onSave={saveConstructor} onSave={saveConstructor}
/> />

View File

@ -5,6 +5,7 @@ import {
authenticate, authenticate,
collectConsoleFailures, collectConsoleFailures,
mockFrontendApi, mockFrontendApi,
testPages,
testProject, testProject,
} from './fixtures'; } from './fixtures';
@ -96,7 +97,9 @@ test('constructor saves dev page changes and promotes them to stage', async ({
expect(saveToStageRequest.postDataJSON()).toEqual({ expect(saveToStageRequest.postDataJSON()).toEqual({
projectId: TEST_PROJECT_ID, projectId: TEST_PROJECT_ID,
}); });
await expect(page.getByText('Saved to stage.')).toBeVisible(); await expect(
page.getByText(`Saved to stage: ${testPages.length} pages copied.`),
).toBeVisible();
consoleFailures.assertClean(); consoleFailures.assertClean();
}); });

View File

@ -393,19 +393,26 @@ export async function mockFrontendApi(
if (path === '/publish/save-to-stage') { if (path === '/publish/save-to-stage') {
return fulfillJson(route, { return fulfillJson(route, {
success: true,
publishEventId: 'publish-event-stage', publishEventId: 'publish-event-stage',
status: 'success', summary: {
pages_copied: testPages.length,
audios_copied: 0,
transition_settings_copied: 1,
ui_control_settings_copied: 1,
},
}); });
} }
if (path === '/publish') { if (path === '/publish') {
return fulfillJson(route, { return fulfillJson(route, {
success: true,
publishEventId: 'publish-event-production', publishEventId: 'publish-event-production',
status: 'success',
summary: { summary: {
pages_copied: testPages.length, pages_copied: testPages.length,
transitions_copied: 0,
audios_copied: 0, audios_copied: 0,
transition_settings_copied: 1,
ui_control_settings_copied: 1,
}, },
}); });
} }