changed presentations saving functionality
This commit is contained in:
parent
c0863f974c
commit
80fe56c6f9
@ -1228,7 +1228,9 @@ Publish from stage to production.
|
||||
"publishEventId": "event-uuid",
|
||||
"summary": {
|
||||
"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",
|
||||
"summary": {
|
||||
"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:**
|
||||
|
||||
- `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
|
||||
- `429`: Rate limit exceeded
|
||||
- `500`: Copy failed
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -286,7 +286,7 @@ const sampleDataSeeder: SequelizeSeeder = {
|
||||
| Presigned URL Requests | 3 | Upload/download requests |
|
||||
| Tour Pages | 3 | Sample tour pages |
|
||||
| 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 |
|
||||
| Access Logs | 3 | Visitor tracking |
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -430,7 +430,7 @@ module.exports = class PublishService {
|
||||
// Copy stage content to production (blocking)
|
||||
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)
|
||||
|
||||
// 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
|
||||
|
||||
Waiting for Save to Stage prevents a subsequent production publish from
|
||||
overtaking a queued background copy and publishing the previous Stage snapshot.
|
||||
|
||||
**Publishing Flow:**
|
||||
|
||||
```
|
||||
|
||||
@ -23,10 +23,11 @@ function isRuntimeAssetModel(value: unknown): value is ModelStatic<Model> {
|
||||
);
|
||||
}
|
||||
|
||||
function getRuntimeAssetModel(value: unknown, name: string): ModelStatic<Model> {
|
||||
if (
|
||||
!isRuntimeAssetModel(value)
|
||||
) {
|
||||
function getRuntimeAssetModel(
|
||||
value: unknown,
|
||||
name: string,
|
||||
): ModelStatic<Model> {
|
||||
if (!isRuntimeAssetModel(value)) {
|
||||
throw new Error(`Database model '${name}' is unavailable.`);
|
||||
}
|
||||
|
||||
|
||||
@ -457,15 +457,19 @@ const PublishEventsData = [
|
||||
|
||||
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'),
|
||||
|
||||
finished_at: new Date('2026-03-01T10:01:10Z'),
|
||||
|
||||
status: 'queued',
|
||||
status: 'success',
|
||||
|
||||
error_message: '',
|
||||
error_message: null,
|
||||
|
||||
pages_copied: 6,
|
||||
|
||||
@ -479,24 +483,28 @@ const PublishEventsData = [
|
||||
|
||||
// type code here for "relation_one" field
|
||||
|
||||
from_environment: 'production',
|
||||
from_environment: 'stage',
|
||||
|
||||
to_environment: 'production',
|
||||
|
||||
title: 'Spring Release',
|
||||
|
||||
description: 'Publish the reviewed stage snapshot',
|
||||
|
||||
started_at: new Date('2026-03-15T18:00:00Z'),
|
||||
|
||||
finished_at: new Date('2026-03-15T18:02:40Z'),
|
||||
|
||||
status: 'queued',
|
||||
status: 'failed',
|
||||
|
||||
error_message:
|
||||
'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',
|
||||
|
||||
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'),
|
||||
|
||||
finished_at: new Date('2026-02-05T09:01:05Z'),
|
||||
finished_at: null,
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -918,12 +918,30 @@ const schemas: Record<string, OpenApiSchema> = {
|
||||
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: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
required: ['success', 'publishEventId', 'summary'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
event: ref('PublishEvent'),
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
publishEventId: uuidSchema,
|
||||
summary: ref('PublishSummary'),
|
||||
},
|
||||
},
|
||||
SearchRequest: {
|
||||
@ -1810,6 +1828,8 @@ const customPaths: OpenApiPaths = {
|
||||
post: {
|
||||
tags: ['Publish'],
|
||||
summary: 'Publish staged content to production',
|
||||
description:
|
||||
'Waits for the Stage-to-Production transaction to commit, then returns the committed copy summary.',
|
||||
security: bearerSecurity,
|
||||
requestBody: jsonRequest(ref('PublishRequest')),
|
||||
responses: {
|
||||
@ -1822,6 +1842,8 @@ const customPaths: OpenApiPaths = {
|
||||
post: {
|
||||
tags: ['Publish'],
|
||||
summary: 'Copy dev content to stage',
|
||||
description:
|
||||
'Waits for the Dev-to-Stage transaction to commit, then returns the committed copy summary.',
|
||||
security: bearerSecurity,
|
||||
requestBody: jsonRequest(ref('SaveToStageRequest')),
|
||||
responses: {
|
||||
|
||||
@ -69,27 +69,32 @@ const presignHandler = async (
|
||||
const currentUser = getCurrentUser(req);
|
||||
const runtimeContext = getRuntimeContext(req);
|
||||
|
||||
const authorization =
|
||||
await RuntimeAssetAccessService.authorizePresignRequest({
|
||||
const authorization = await RuntimeAssetAccessService.authorizePresignRequest(
|
||||
{
|
||||
currentUser,
|
||||
runtimeContext,
|
||||
urls,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (authorization === 'authentication_required') {
|
||||
return res.status(401).json(
|
||||
services.createErrorResponse(
|
||||
'Authentication or public presentation context is required',
|
||||
'PRESIGN_AUTH_REQUIRED',
|
||||
),
|
||||
);
|
||||
return res
|
||||
.status(401)
|
||||
.json(
|
||||
services.createErrorResponse(
|
||||
'Authentication or public presentation context is required',
|
||||
'PRESIGN_AUTH_REQUIRED',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (authorization === 'denied') {
|
||||
return res.status(403).json(
|
||||
services.createErrorResponse(
|
||||
'Asset access denied',
|
||||
'PRESIGN_ACCESS_DENIED',
|
||||
),
|
||||
);
|
||||
return res
|
||||
.status(403)
|
||||
.json(
|
||||
services.createErrorResponse(
|
||||
'Asset access denied',
|
||||
'PRESIGN_ACCESS_DENIED',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate paths for security (no traversal, no protocols)
|
||||
|
||||
@ -66,7 +66,7 @@ router.post('/', validateRequest(publishSchemas.publish), publishHandler);
|
||||
* - bearerAuth: []
|
||||
* tags: [Publish]
|
||||
* 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:
|
||||
* required: true
|
||||
* content:
|
||||
@ -82,8 +82,55 @@ router.post('/', validateRequest(publishSchemas.publish), publishHandler);
|
||||
* responses:
|
||||
* 200:
|
||||
* 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:
|
||||
* 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(
|
||||
'/save-to-stage',
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import type { Transaction } from 'sequelize';
|
||||
|
||||
import db from '../db/models/index.ts';
|
||||
import { logger } from '../utils/logger.ts';
|
||||
import type {
|
||||
PublishCloneData,
|
||||
PublishClonePayload,
|
||||
PublishCloneSource,
|
||||
PublishEventRecord,
|
||||
PublishEventStatus,
|
||||
PublishLockCallback,
|
||||
PublishServiceCurrentUser,
|
||||
@ -216,26 +214,6 @@ export default class PublishService {
|
||||
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 {
|
||||
const summary = await this.withProjectPublishLock(
|
||||
projectId,
|
||||
@ -259,6 +237,12 @@ export default class PublishService {
|
||||
audios_copied: summary.audios_copied,
|
||||
updatedById: actorId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
publishEventId: publishEvent.id,
|
||||
summary,
|
||||
};
|
||||
} catch (error) {
|
||||
await publishEvent.update({
|
||||
status: EVENT_STATUS.FAILED,
|
||||
|
||||
@ -4,10 +4,7 @@ import type { AccessPolicyUser, RuntimeContext } from '../types/index.ts';
|
||||
import { UI_SCHEMA_ASSET_FIELDS } from '../utils/ui-schema-assets.ts';
|
||||
import AccessPolicy from './access-policy.ts';
|
||||
|
||||
type PresignAuthorization =
|
||||
| 'allowed'
|
||||
| 'authentication_required'
|
||||
| 'denied';
|
||||
type PresignAuthorization = 'allowed' | 'authentication_required' | 'denied';
|
||||
|
||||
interface AuthorizePresignRequestOptions {
|
||||
currentUser: AccessPolicyUser;
|
||||
@ -76,7 +73,10 @@ function normalizeStorageReference(value: string): string | null {
|
||||
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') {
|
||||
const normalized = normalizeStorageReference(value);
|
||||
if (normalized) references.add(normalized);
|
||||
@ -149,10 +149,7 @@ export default class RuntimeAssetAccessService {
|
||||
const projectSlug = AccessPolicy.normalizeSlug(
|
||||
runtimeContext?.headerProjectSlug,
|
||||
);
|
||||
if (
|
||||
runtimeContext?.headerEnvironment !== 'production' ||
|
||||
!projectSlug
|
||||
) {
|
||||
if (runtimeContext?.headerEnvironment !== 'production' || !projectSlug) {
|
||||
return 'authentication_required';
|
||||
}
|
||||
|
||||
@ -192,11 +189,7 @@ export default class RuntimeAssetAccessService {
|
||||
collectRecordFields(variants, ['storage_key', 'cdn_url'], references);
|
||||
collectRecordFields(
|
||||
pages,
|
||||
[
|
||||
'background_image_url',
|
||||
'background_video_url',
|
||||
'background_audio_url',
|
||||
],
|
||||
['background_image_url', 'background_video_url', 'background_audio_url'],
|
||||
references,
|
||||
);
|
||||
for (const page of pages) {
|
||||
|
||||
@ -1072,18 +1072,20 @@ class TourPagesService extends BaseService {
|
||||
const reversedUrl =
|
||||
await TourPagesService.getExistingReversedVariant(storageKey);
|
||||
|
||||
if (reversedUrl && reversedUrl !== element.reverseVideoUrl) {
|
||||
element.reverseVideoUrl = reversedUrl;
|
||||
wasModified = true;
|
||||
logger.info(
|
||||
{
|
||||
elementType: element.type,
|
||||
isBack,
|
||||
isForward,
|
||||
storageKey,
|
||||
},
|
||||
'Added existing reversed video URL to element',
|
||||
);
|
||||
if (reversedUrl) {
|
||||
if (reversedUrl !== element.reverseVideoUrl) {
|
||||
element.reverseVideoUrl = reversedUrl;
|
||||
wasModified = true;
|
||||
logger.info(
|
||||
{
|
||||
elementType: element.type,
|
||||
isBack,
|
||||
isForward,
|
||||
storageKey,
|
||||
},
|
||||
'Added existing reversed video URL to element',
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ export interface PublishToProductionResult {
|
||||
export interface SaveToStageResult {
|
||||
success: true;
|
||||
publishEventId: string;
|
||||
summary: PublishSummary;
|
||||
}
|
||||
|
||||
export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed';
|
||||
|
||||
@ -167,11 +167,7 @@ function toValidatedEnvironment(
|
||||
DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'),
|
||||
DB_USER: readString(values, 'DB_USER', 'postgres'),
|
||||
DB_PASS: readString(values, 'DB_PASS', ''),
|
||||
SECRET_KEY: readString(
|
||||
values,
|
||||
'SECRET_KEY',
|
||||
'',
|
||||
),
|
||||
SECRET_KEY: readString(values, 'SECRET_KEY', ''),
|
||||
ADMIN_PASS: readString(values, 'ADMIN_PASS', ''),
|
||||
USER_PASS: readString(values, 'USER_PASS', ''),
|
||||
ADMIN_EMAIL: readString(values, 'ADMIN_EMAIL', 'admin@flatlogic.com'),
|
||||
|
||||
@ -67,6 +67,7 @@ void test('OpenAPI document exposes comprehensive route coverage', () => {
|
||||
'/api/runtime-access/me',
|
||||
'/api/project-ui-control-settings/project/{projectId}/env/{environment}',
|
||||
'/api/tour_pages/reverse-video-status',
|
||||
'/api/publish/save-to-stage',
|
||||
];
|
||||
|
||||
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', () => {
|
||||
const document = createTestDocument();
|
||||
const resourcePath = '/api/assets';
|
||||
|
||||
111
backend/tests/publish-service.test.ts
Normal file
111
backend/tests/publish-service.test.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -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 () => {
|
||||
const authorization =
|
||||
await RuntimeAssetAccessService.authorizePresignRequest({
|
||||
const authorization = await RuntimeAssetAccessService.authorizePresignRequest(
|
||||
{
|
||||
currentUser: undefined,
|
||||
runtimeContext: undefined,
|
||||
urls: ['assets/project/image.webp'],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(authorization, 'authentication_required');
|
||||
});
|
||||
|
||||
void test('staff permissions authorize presigning without public runtime context', async () => {
|
||||
const authorization =
|
||||
await RuntimeAssetAccessService.authorizePresignRequest({
|
||||
const authorization = await RuntimeAssetAccessService.authorizePresignRequest(
|
||||
{
|
||||
currentUser: {
|
||||
id: 'staff-user',
|
||||
app_role_permissions: ['READ_ASSETS'],
|
||||
},
|
||||
runtimeContext: undefined,
|
||||
urls: ['assets/project/image.webp'],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(authorization, 'allowed');
|
||||
});
|
||||
|
||||
@ -21,6 +21,7 @@ import type {
|
||||
RuntimeEnvironment,
|
||||
TourPageCreateOptions,
|
||||
TourPageRecord,
|
||||
TourPageReverseGenerationTask,
|
||||
TourPageUpdateOptions,
|
||||
} 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(
|
||||
calls: UpdateContractCalls,
|
||||
): 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 () => {
|
||||
const calls: UpdateContractCalls = {};
|
||||
const transaction: TestManagedTransaction = {
|
||||
|
||||
@ -633,7 +633,7 @@ Clone project with all related entities.
|
||||
|
||||
Copy all `dev` environment content to `stage` for preview.
|
||||
|
||||
**Auth:** Required
|
||||
**Auth:** Required. **Permission:** `CREATE_PUBLISH_EVENTS`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
@ -649,12 +649,21 @@ Copy all `dev` environment content to `stage` for preview.
|
||||
"publishEventId": "event-uuid",
|
||||
"summary": {
|
||||
"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
|
||||
|
||||
@ -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).
|
||||
|
||||
### 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
|
||||
|
||||
@ -1209,7 +1218,9 @@ Publish project from `stage` to `production` environment. Both endpoints are ali
|
||||
"publishEventId": "event-uuid",
|
||||
"summary": {
|
||||
"pages_copied": 10,
|
||||
"audios_copied": 2
|
||||
"audios_copied": 2,
|
||||
"transition_settings_copied": 1,
|
||||
"ui_control_settings_copied": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -278,7 +278,9 @@ Key functions:
|
||||
**Generation Pattern:**
|
||||
- Reversed videos are always generated for all navigation elements with transitions
|
||||
- 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
|
||||
FFmpeg queue; the backend does not run multiple FFmpeg reversals in parallel
|
||||
- Background processing keeps save requests fast
|
||||
|
||||
@ -267,16 +267,23 @@ Authorization: Bearer {token}
|
||||
```json
|
||||
{
|
||||
"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):**
|
||||
|
||||
*Note: Both `/api/publish` and `/api/publish/publish` route to the same handler.*
|
||||
|
||||
```http
|
||||
POST /api/publish
|
||||
Content-Type: application/json
|
||||
@ -296,7 +303,9 @@ Authorization: Bearer {token}
|
||||
"publishEventId": "uuid",
|
||||
"summary": {
|
||||
"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 |
|
||||
|-----------|----------|----------|
|
||||
| **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 |
|
||||
|
||||
**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)
|
||||
|
||||
@ -566,27 +577,50 @@ The Constructor uses the `useConstructorPageActions` hook which provides the `sa
|
||||
const saveToStage = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
onError?.('Project ID is required to save to stage.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// First save current state, then copy to stage
|
||||
await saveConstructor();
|
||||
|
||||
setIsSavingToStage(true);
|
||||
try {
|
||||
setIsSavingToStage(true);
|
||||
// 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
|
||||
// Non-blocking: returns immediately, copy runs in background
|
||||
await axios.post('/publish/save-to-stage', { projectId });
|
||||
onSuccess?.('Saved to stage.');
|
||||
const response = await axios.post('/publish/save-to-stage', { projectId });
|
||||
const { pages_copied: pagesCopied } = response.data.summary;
|
||||
onSuccess?.(
|
||||
`Saved to stage: ${pagesCopied} pages copied.`,
|
||||
);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
onError?.(error?.response?.data?.message || 'Failed to save to stage');
|
||||
return false;
|
||||
} finally {
|
||||
setIsSavingToStage(false);
|
||||
}
|
||||
}, [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
|
||||
const {
|
||||
@ -964,7 +998,7 @@ This ensures smooth transitions regardless of environment (dev preview, stage, o
|
||||
| **Purpose** | Active editing | Preview/testing | Public access |
|
||||
| **Data Source** | `environment='dev'` | `environment='stage'` | `environment='production'` |
|
||||
| **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 |
|
||||
| **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'`
|
||||
2. Check `pages_copied` count is non-zero
|
||||
3. Clear browser cache and reload presentation
|
||||
4. Verify correct project slug in URL
|
||||
3. Confirm the latest successful Dev → Stage event finished before the latest
|
||||
Stage → Production event started
|
||||
4. Clear browser cache and reload presentation
|
||||
5. Verify correct project slug in URL
|
||||
|
||||
### Stage/Production Mismatch
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
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
|
||||
`useConstructorData()` and then filter options client-side by `asset_type` and
|
||||
`type` for image, background image, video, audio, transition, icon, and embed
|
||||
@ -1173,24 +1178,20 @@ const saveConstructor = async () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Serialize elements to JSON
|
||||
const ui_schema_json = JSON.stringify({
|
||||
elements: elements,
|
||||
const payload = buildConstructorPageSavePayload({
|
||||
activePageId,
|
||||
activePage,
|
||||
elementsToSave: elements,
|
||||
pageBackground,
|
||||
uiControlsSettings,
|
||||
project,
|
||||
});
|
||||
|
||||
// Update tour page via API (always saves to dev environment)
|
||||
await dispatch(tourPagesActions.update({
|
||||
// The mutation updates both detail and list query caches from the response.
|
||||
await updatePage({
|
||||
id: activePageId,
|
||||
data: {
|
||||
ui_schema_json,
|
||||
background_image_url: backgroundImageUrl,
|
||||
background_video_url: backgroundVideoUrl,
|
||||
background_audio_url: backgroundAudioUrl,
|
||||
},
|
||||
}));
|
||||
|
||||
// Reload data to refresh
|
||||
await loadData();
|
||||
data: payload.data,
|
||||
});
|
||||
|
||||
setSuccessMessage('Saved successfully');
|
||||
} catch (error) {
|
||||
@ -1203,28 +1204,47 @@ const saveConstructor = async () => {
|
||||
|
||||
### 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
|
||||
const saveToStage = async () => {
|
||||
if (!projectId) {
|
||||
onError?.('Project ID is required to save to stage.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// First save current work to dev
|
||||
await saveConstructor();
|
||||
|
||||
setIsSavingToStage(true);
|
||||
try {
|
||||
setIsSavingToStage(true);
|
||||
// First persist current work to Dev.
|
||||
const didSave = await saveConstructor();
|
||||
if (!didSave) return false;
|
||||
|
||||
// Non-blocking: returns immediately, copy runs in background
|
||||
await axios.post('/publish/save-to-stage', { projectId });
|
||||
const response = 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) {
|
||||
const message = error?.response?.data?.message || error?.message || 'Failed to save to stage.';
|
||||
onError?.(message);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSavingToStage(false);
|
||||
}
|
||||
@ -1275,8 +1295,8 @@ selects the next page in display order, or clears the editor when no pages
|
||||
remain.
|
||||
|
||||
**Backend Publish Flow:**
|
||||
- Save to Stage (non-blocking): `POST /publish/save-to-stage` → `PublishService.saveToStage()` → `copyEnvironment(dev, stage)` (runs in background)
|
||||
- Publish to Prod (blocking): `POST /publish` → `PublishService.publishToProduction()` → `copyEnvironment(stage, production)`
|
||||
- Save to Stage (completion-confirmed): `POST /publish/save-to-stage` → `PublishService.saveToStage()` → `copyEnvironment(dev, stage)`
|
||||
- Publish to Prod (completion-confirmed): `POST /publish` → `PublishService.publishToProduction()` → `copyEnvironment(stage, production)`
|
||||
|
||||
The `copyEnvironment` method:
|
||||
1. Fetches all `tour_pages` and `project_audio_tracks` from source environment
|
||||
|
||||
@ -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
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
```typescript
|
||||
@ -1196,7 +1209,7 @@ interface UseConstructorPageActionsOptions {
|
||||
elements: CanvasElement[];
|
||||
getElements?: () => CanvasElement[];
|
||||
pageBackground: PageBackgroundState;
|
||||
onReload: () => Promise<void>;
|
||||
onReload: (preservePageId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseConstructorPageActionsResult {
|
||||
@ -1205,7 +1218,7 @@ interface UseConstructorPageActionsResult {
|
||||
isCreatingPage: boolean;
|
||||
isDuplicatingPage: boolean;
|
||||
saveConstructor: () => Promise<boolean>;
|
||||
saveToStage: () => Promise<void>;
|
||||
saveToStage: () => Promise<boolean>;
|
||||
createPage: (name: string, slug: string) => Promise<void>;
|
||||
duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>;
|
||||
}
|
||||
@ -1749,10 +1762,13 @@ const lastProjectSaveAt = useMemo(() => {
|
||||
}, null as string | null);
|
||||
}, [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 () => {
|
||||
await saveToStage();
|
||||
await refreshPublishStatus();
|
||||
const didSaveToStage = await saveToStage();
|
||||
if (didSaveToStage) {
|
||||
void refreshPublishStatus();
|
||||
}
|
||||
}, [saveToStage, refreshPublishStatus]);
|
||||
|
||||
// Pass timestamps to ConstructorMenu
|
||||
|
||||
@ -2042,13 +2042,14 @@ function useConstructorPageActions(
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| projectId | `string` | Current project ID |
|
||||
| project | `ConstructorProjectDimensions \| null` | Design dimensions used in the page snapshot |
|
||||
| pages | `TourPage[]` | All pages |
|
||||
| activePage | `TourPage \| null` | Current page |
|
||||
| activePageId | `string` | Current page ID |
|
||||
| elements | `CanvasElement[]` | Current elements |
|
||||
| backgroundImageUrl | `string` | Background image |
|
||||
| backgroundVideoUrl | `string` | Background video |
|
||||
| backgroundAudioUrl | `string` | Background audio |
|
||||
| getElements | `() => CanvasElement[]` | Read same-tick element state before saving |
|
||||
| pageBackground | `PageBackgroundState` | Background media and playback settings |
|
||||
| uiControlsSettings | `UiControlsSettings \| null` | Page-level UI-control overrides |
|
||||
| onReload | `(preservePageId?) => Promise<void>` | Reload callback |
|
||||
| onSetActivePageId | `(id) => void` | Set active page |
|
||||
| onSetMenuOpen | `(open) => void` | Set menu open |
|
||||
@ -2062,11 +2063,11 @@ function useConstructorPageActions(
|
||||
| isSaving | `boolean` | Save in progress |
|
||||
| isSavingToStage | `boolean` | Stage save in progress |
|
||||
| isCreatingPage | `boolean` | Page creation in progress |
|
||||
| isCreatingTransition | `boolean` | Transition creation in progress |
|
||||
| saveConstructor | `() => Promise<void>` | Save current state |
|
||||
| saveToStage | `() => Promise<void>` | Save dev → stage |
|
||||
| createPage | `() => Promise<void>` | Create new page |
|
||||
| createTransition | `(params) => Promise<void>` | Create transition (legacy) |
|
||||
| isDuplicatingPage | `boolean` | Page duplication in progress |
|
||||
| saveConstructor | `() => Promise<boolean>` | Save current state and report success |
|
||||
| saveToStage | `() => Promise<boolean>` | Save Dev, commit Dev → Stage, and report success |
|
||||
| createPage | `(name, slug) => Promise<void>` | Create a Dev page |
|
||||
| duplicatePage | `(sourcePageId, name, slug) => Promise<TourPage \| null>` | Duplicate a Dev page |
|
||||
|
||||
**Example:**
|
||||
|
||||
|
||||
@ -562,6 +562,7 @@ Visual tour builder with canvas-based element editing.
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `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 |
|
||||
| `useCanvasElementDrag` | Element positioning with percentage coordinates |
|
||||
| `useTransitionPreview` | Transition video preview state |
|
||||
@ -603,18 +604,21 @@ const ConstructorPage = () => {
|
||||
allowedNavigationTypes,
|
||||
});
|
||||
|
||||
// Page persistence and page creation/duplication
|
||||
// Page persistence, management, and publish-status orchestration
|
||||
const {
|
||||
saveConstructor,
|
||||
saveToStage,
|
||||
createPage,
|
||||
duplicatePage,
|
||||
} = useConstructorPageActions({
|
||||
handleSaveToStage,
|
||||
} = useConstructorPageWorkflow({
|
||||
projectId,
|
||||
project,
|
||||
pages,
|
||||
activePage,
|
||||
activePageId,
|
||||
elements,
|
||||
getElements,
|
||||
pageBackground,
|
||||
onReload: handleReload,
|
||||
refetchData,
|
||||
// ... constructor callbacks
|
||||
});
|
||||
|
||||
return (
|
||||
@ -629,7 +633,7 @@ const ConstructorPage = () => {
|
||||
canCopyElement={Boolean(selectedElement)}
|
||||
canPasteElement={canPasteElement}
|
||||
onSave={saveConstructor}
|
||||
onSaveToStage={saveToStage}
|
||||
onSaveToStage={handleSaveToStage}
|
||||
/>
|
||||
|
||||
{/* Center: Canvas */}
|
||||
|
||||
@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
getCollapsedToolbarPageName,
|
||||
getConstructorSaveControlState,
|
||||
getConstructorToolbarActionState,
|
||||
getConstructorToolbarMaxWidth,
|
||||
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', () => {
|
||||
const state = getConstructorToolbarActionState({
|
||||
pages: [
|
||||
|
||||
@ -15,6 +15,28 @@ export interface ConstructorToolbarActionState {
|
||||
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 = (
|
||||
positionX: number,
|
||||
viewportMargin = TOOLBAR_VIEWPORT_MARGIN_PX,
|
||||
|
||||
@ -158,6 +158,7 @@ const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
||||
onSelectMenuItem={onSelectMenuItem}
|
||||
isReorderingPages={isReorderingPages}
|
||||
isCreatingPage={isCreatingPage}
|
||||
isPresentationSaving={isSaving || isSavingToStage}
|
||||
canMovePageUp={actionState.canMovePageUp}
|
||||
canMovePageDown={actionState.canMovePageDown}
|
||||
canDuplicatePage={actionState.canDuplicatePage}
|
||||
|
||||
@ -94,6 +94,8 @@ const ConstructorToolbarLayer = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPresentationSaving = isSaving || isSavingToStage;
|
||||
|
||||
return (
|
||||
<ConstructorToolbar
|
||||
ref={toolbarRef}
|
||||
@ -108,10 +110,12 @@ const ConstructorToolbarLayer = ({
|
||||
onMovePage={onMovePage}
|
||||
isReorderingPages={isReorderingPages}
|
||||
onDuplicatePage={onDuplicatePage}
|
||||
isDuplicatingPage={isSaving || isDuplicatingPage}
|
||||
isDuplicatingPage={isPresentationSaving || isDuplicatingPage}
|
||||
onDeletePage={onDeletePage}
|
||||
canDeletePage={canDeletePage}
|
||||
isDeletingPage={isDeletingPage || isSaving || isDuplicatingPage}
|
||||
isDeletingPage={
|
||||
isDeletingPage || isPresentationSaving || isDuplicatingPage
|
||||
}
|
||||
interactionMode={interactionMode}
|
||||
onModeChange={onModeChange}
|
||||
onSelectMenuItem={onSelectMenuItem}
|
||||
|
||||
@ -28,6 +28,7 @@ interface Props {
|
||||
onSelectMenuItem: (item: EditorMenuItem) => void;
|
||||
isReorderingPages: boolean;
|
||||
isCreatingPage: boolean;
|
||||
isPresentationSaving: boolean;
|
||||
canMovePageUp: boolean;
|
||||
canMovePageDown: boolean;
|
||||
canDuplicatePage: boolean;
|
||||
@ -55,6 +56,7 @@ export default function ConstructorToolbarPageActions({
|
||||
onSelectMenuItem,
|
||||
isReorderingPages,
|
||||
isCreatingPage,
|
||||
isPresentationSaving,
|
||||
canMovePageUp,
|
||||
canMovePageDown,
|
||||
canDuplicatePage,
|
||||
@ -78,14 +80,14 @@ export default function ConstructorToolbarPageActions({
|
||||
pages={pages}
|
||||
activePageId={activePageId}
|
||||
onPageChange={onPageChange}
|
||||
disabled={isReorderingPages}
|
||||
disabled={isReorderingPages || isPresentationSaving}
|
||||
className='h-10 min-w-[160px] max-w-[210px] flex-1'
|
||||
/>
|
||||
<div className='flex items-center gap-1'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onMovePage?.('up')}
|
||||
disabled={!canMovePageUp}
|
||||
disabled={!canMovePageUp || isPresentationSaving}
|
||||
className={iconBtnClass}
|
||||
title='Move page up'
|
||||
aria-label='Move page up'
|
||||
@ -95,7 +97,7 @@ export default function ConstructorToolbarPageActions({
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onMovePage?.('down')}
|
||||
disabled={!canMovePageDown}
|
||||
disabled={!canMovePageDown || isPresentationSaving}
|
||||
className={iconBtnClass}
|
||||
title='Move page down'
|
||||
aria-label='Move page down'
|
||||
@ -106,9 +108,15 @@ export default function ConstructorToolbarPageActions({
|
||||
<button
|
||||
type='button'
|
||||
onClick={onCreatePage}
|
||||
disabled={isCreatingPage}
|
||||
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
aria-label={isCreatingPage ? 'Creating page' : 'Create page'}
|
||||
disabled={isCreatingPage || isPresentationSaving}
|
||||
className={`${triggerBtnClass} ${isCreatingPage || isPresentationSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
aria-label={
|
||||
isPresentationSaving
|
||||
? 'Presentation save in progress'
|
||||
: isCreatingPage
|
||||
? 'Creating page'
|
||||
: 'Create page'
|
||||
}
|
||||
>
|
||||
<BaseIcon path={mdiPlus} size={18} />
|
||||
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
|
||||
@ -116,7 +124,7 @@ export default function ConstructorToolbarPageActions({
|
||||
<button
|
||||
type='button'
|
||||
onClick={onDuplicatePage}
|
||||
disabled={!canDuplicatePage}
|
||||
disabled={!canDuplicatePage || isPresentationSaving}
|
||||
className={iconBtnClass}
|
||||
title='Duplicate page'
|
||||
aria-label='Duplicate page'
|
||||
@ -126,7 +134,7 @@ export default function ConstructorToolbarPageActions({
|
||||
<button
|
||||
type='button'
|
||||
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'
|
||||
title='Delete page'
|
||||
aria-label='Delete page'
|
||||
@ -138,14 +146,15 @@ export default function ConstructorToolbarPageActions({
|
||||
ref={backgroundTriggerRef}
|
||||
type='button'
|
||||
onClick={onToggleBackgroundDropdown}
|
||||
className={triggerBtnClass}
|
||||
disabled={isPresentationSaving}
|
||||
className={`${triggerBtnClass} ${isPresentationSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
aria-label='Background actions'
|
||||
>
|
||||
<BaseIcon path={mdiImageMultiple} size={18} />
|
||||
<span>BG</span>
|
||||
<BaseIcon path={mdiChevronDown} size={16} />
|
||||
</button>
|
||||
{isBackgroundDropdownActive && (
|
||||
{isBackgroundDropdownActive && !isPresentationSaving && (
|
||||
<ClickOutside
|
||||
onClickOutside={onCloseDropdown}
|
||||
excludedElements={[backgroundTriggerRef]}
|
||||
|
||||
@ -2,6 +2,7 @@ import { mdiChevronLeft, mdiExitToApp } from '@mdi/js';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import BaseButton from '../BaseButton';
|
||||
import BaseIcon from '../BaseIcon';
|
||||
import { getConstructorSaveControlState } from './ConstructorToolbar.helpers';
|
||||
|
||||
interface Props {
|
||||
isSaving: boolean;
|
||||
@ -24,40 +25,49 @@ export default function ConstructorToolbarSaveControls({
|
||||
onExit,
|
||||
onCollapse,
|
||||
}: Props) {
|
||||
const { isBusy, saveLabel, stageLabel } = getConstructorSaveControlState({
|
||||
isSaving,
|
||||
isSavingToStage,
|
||||
});
|
||||
|
||||
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
|
||||
small
|
||||
color='info'
|
||||
className='h-10 w-[86px]'
|
||||
label={isSaving ? 'Saving...' : 'Save'}
|
||||
className='h-10 w-[112px]'
|
||||
label={saveLabel}
|
||||
subtitle={
|
||||
lastSavedAt ? dataFormatter.relativeTimestamp(lastSavedAt) : ' '
|
||||
}
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
|
||||
<BaseButton
|
||||
small
|
||||
color='success'
|
||||
className='h-10 w-[86px]'
|
||||
label={isSavingToStage ? 'Saving...' : 'Stage'}
|
||||
className='h-10 w-[154px]'
|
||||
label={stageLabel}
|
||||
subtitle={
|
||||
lastSavedToStageAt
|
||||
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
|
||||
: ' '
|
||||
}
|
||||
onClick={onSaveToStage}
|
||||
disabled={isSavingToStage}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
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'
|
||||
title='Exit constructor'
|
||||
aria-label='Exit constructor'
|
||||
disabled={isBusy}
|
||||
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'
|
||||
title={isBusy ? 'Wait for saving to finish' : 'Exit constructor'}
|
||||
aria-label={isBusy ? 'Saving in progress' : 'Exit constructor'}
|
||||
>
|
||||
<BaseIcon path={mdiExitToApp} size={26} />
|
||||
</button>
|
||||
|
||||
@ -120,8 +120,10 @@ export function useConstructorPageWorkflow({
|
||||
});
|
||||
|
||||
const handleSaveToStage = useCallback(async () => {
|
||||
await saveToStage();
|
||||
await refreshPublishStatus();
|
||||
const didSaveToStage = await saveToStage();
|
||||
if (didSaveToStage) {
|
||||
void refreshPublishStatus();
|
||||
}
|
||||
}, [saveToStage, refreshPublishStatus]);
|
||||
|
||||
return {
|
||||
|
||||
@ -14,6 +14,16 @@ interface PagesListResponse {
|
||||
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
|
||||
*/
|
||||
@ -61,7 +71,7 @@ export function useUpdatePageMutation() {
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: Partial<TourPage>;
|
||||
data: UpdatePageData;
|
||||
}): Promise<TourPage> => {
|
||||
const response = await axios.put<TourPage>(`tour_pages/${id}`, {
|
||||
id,
|
||||
@ -70,10 +80,11 @@ export function useUpdatePageMutation() {
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the single page cache
|
||||
queryClient.setQueryData(queryKeys.tourPages.detail(variables.id), data);
|
||||
// Invalidate list queries
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tourPages.all });
|
||||
queryClient.setQueriesData<TourPage[]>(
|
||||
{ queryKey: queryKeys.tourPages.lists() },
|
||||
(pages) => replaceTourPageInList(pages, data),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
buildIconAssetOptions,
|
||||
buildImageAssetOptions,
|
||||
buildTransitionVideoOptions,
|
||||
deduplicateAssetOptions,
|
||||
getAssetSourceValue,
|
||||
} from '../lib/constructorHelpers';
|
||||
import type {
|
||||
@ -68,19 +69,21 @@ export function useAssetOptions({
|
||||
// Video assets (excluding transition videos)
|
||||
const videoOptions = useMemo(
|
||||
() =>
|
||||
assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
asset.asset_type === 'video' &&
|
||||
asset.type !== 'transition' &&
|
||||
getAssetSourceValue(asset),
|
||||
)
|
||||
.map((asset) => ({
|
||||
value: getAssetSourceValue(asset),
|
||||
label: asset.name
|
||||
? `${asset.name} · ${getAssetSourceValue(asset)}`
|
||||
: getAssetSourceValue(asset),
|
||||
})),
|
||||
deduplicateAssetOptions(
|
||||
assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
asset.asset_type === 'video' &&
|
||||
asset.type !== 'transition' &&
|
||||
getAssetSourceValue(asset),
|
||||
)
|
||||
.map((asset) => ({
|
||||
value: getAssetSourceValue(asset),
|
||||
label: asset.name
|
||||
? `${asset.name} · ${getAssetSourceValue(asset)}`
|
||||
: getAssetSourceValue(asset),
|
||||
})),
|
||||
),
|
||||
[assets],
|
||||
);
|
||||
|
||||
@ -99,14 +102,17 @@ export function useAssetOptions({
|
||||
// Embed assets (360° panoramas, iframes) - filter by asset_type='embed'
|
||||
const embedOptions = useMemo(
|
||||
() =>
|
||||
assets
|
||||
.filter(
|
||||
(asset) => asset.asset_type === 'embed' && getAssetSourceValue(asset),
|
||||
)
|
||||
.map((asset) => ({
|
||||
value: getAssetSourceValue(asset),
|
||||
label: asset.name || getAssetSourceValue(asset),
|
||||
})),
|
||||
deduplicateAssetOptions(
|
||||
assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
asset.asset_type === 'embed' && getAssetSourceValue(asset),
|
||||
)
|
||||
.map((asset) => ({
|
||||
value: getAssetSourceValue(asset),
|
||||
label: asset.name || getAssetSourceValue(asset),
|
||||
})),
|
||||
),
|
||||
[assets],
|
||||
);
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
* Replaces the manual loadData function with cached, deduplicated queries.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
||||
import type { CanvasElement, CanvasElementType } from '../types/constructor';
|
||||
import type { Asset, TourPage } from '../types/entities';
|
||||
@ -54,8 +54,8 @@ interface UseConstructorDataResult {
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
|
||||
// Refetch function
|
||||
refetch: () => Promise<void>;
|
||||
// Refetch mutable constructor page data
|
||||
refetchPages: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useConstructorData({
|
||||
@ -70,6 +70,7 @@ export function useConstructorData({
|
||||
|
||||
// Fetch pages (dev environment for constructor)
|
||||
const pagesQuery = usePagesQuery(enabled ? projectId : undefined, 'dev');
|
||||
const refetchPageQuery = pagesQuery.refetch;
|
||||
|
||||
// Fetch assets
|
||||
const assetsQuery = useAssetsQuery(enabled ? projectId : undefined);
|
||||
@ -112,15 +113,12 @@ export function useConstructorData({
|
||||
assetsQuery.error ||
|
||||
elementDefaultsQuery.error;
|
||||
|
||||
// Refetch all queries
|
||||
const refetch = async () => {
|
||||
await Promise.all([
|
||||
projectQuery.refetch(),
|
||||
pagesQuery.refetch(),
|
||||
assetsQuery.refetch(),
|
||||
elementDefaultsQuery.refetch(),
|
||||
]);
|
||||
};
|
||||
// Constructor mutations in this workflow only change pages. Project,
|
||||
// asset, and element-default queries remain cached until their own
|
||||
// mutations invalidate them.
|
||||
const refetchPages = useCallback(async () => {
|
||||
await refetchPageQuery();
|
||||
}, [refetchPageQuery]);
|
||||
|
||||
return {
|
||||
// Project
|
||||
@ -145,7 +143,7 @@ export function useConstructorData({
|
||||
error: error instanceof Error ? error : null,
|
||||
|
||||
// Refetch
|
||||
refetch,
|
||||
refetchPages,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import type { CanvasElement } from '../types/constructor';
|
||||
import type { TourPage } from '../types/entities';
|
||||
import type { PageBackgroundState } from '../types/pageBackground';
|
||||
import type { UiControlsSettings } from '../types/uiControls';
|
||||
import { useUpdatePageMutation } from './queries/usePagesQuery';
|
||||
import {
|
||||
buildConstructorPageSavePayload,
|
||||
buildCreateConstructorPagePayload,
|
||||
@ -29,6 +30,17 @@ interface Project extends ConstructorProjectDimensions {
|
||||
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 {
|
||||
/** Current project ID */
|
||||
projectId: string;
|
||||
@ -72,7 +84,7 @@ interface UseConstructorPageActionsResult {
|
||||
/** Save current constructor state */
|
||||
saveConstructor: () => Promise<boolean>;
|
||||
/** Save dev content to stage environment */
|
||||
saveToStage: () => Promise<void>;
|
||||
saveToStage: () => Promise<boolean>;
|
||||
/** Create a new page with the given name and slug */
|
||||
createPage: (pageName: string, slug: string) => Promise<void>;
|
||||
/** Duplicate an existing page with the given name and slug */
|
||||
@ -125,6 +137,7 @@ export function useConstructorPageActions({
|
||||
const [isSavingToStage, setIsSavingToStage] = useState(false);
|
||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||
const [isDuplicatingPage, setIsDuplicatingPage] = useState(false);
|
||||
const { mutateAsync: updatePage } = useUpdatePageMutation();
|
||||
|
||||
// Polling hook for reverse video generation status
|
||||
const { startPolling } = useReverseVideoPolling({
|
||||
@ -147,22 +160,19 @@ export function useConstructorPageActions({
|
||||
// These are elements that will trigger async reversed video generation
|
||||
const pendingReverseKeys = getPendingReverseVideoKeys(elementsToSave);
|
||||
|
||||
await axios.put(
|
||||
`/tour_pages/${activePageId}`,
|
||||
buildConstructorPageSavePayload({
|
||||
activePageId,
|
||||
activePage,
|
||||
elementsToSave,
|
||||
pageBackground,
|
||||
uiControlsSettings,
|
||||
project,
|
||||
}),
|
||||
);
|
||||
const payload = buildConstructorPageSavePayload({
|
||||
activePageId,
|
||||
activePage,
|
||||
elementsToSave,
|
||||
pageBackground,
|
||||
uiControlsSettings,
|
||||
project,
|
||||
});
|
||||
await updatePage({ id: activePageId, data: payload.data });
|
||||
|
||||
onSuccess?.(
|
||||
'Constructor settings saved. Element positions are stored in percentages.',
|
||||
);
|
||||
await onReload(activePageId);
|
||||
|
||||
// Start polling for reverse video generation if there are pending keys
|
||||
// This will automatically reload page data when all videos are ready
|
||||
@ -196,24 +206,32 @@ export function useConstructorPageActions({
|
||||
getElements,
|
||||
project,
|
||||
onError,
|
||||
onReload,
|
||||
onSuccess,
|
||||
startPolling,
|
||||
updatePage,
|
||||
]);
|
||||
|
||||
const saveToStage = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
onError?.('Project ID is required to save to stage.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const didSave = await saveConstructor();
|
||||
if (!didSave) return;
|
||||
|
||||
setIsSavingToStage(true);
|
||||
try {
|
||||
setIsSavingToStage(true);
|
||||
await axios.post('/publish/save-to-stage', { projectId });
|
||||
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) {
|
||||
const message = getConstructorActionErrorMessage(
|
||||
error,
|
||||
@ -224,6 +242,7 @@ export function useConstructorPageActions({
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
onError?.(message);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSavingToStage(false);
|
||||
}
|
||||
|
||||
23
frontend/src/hooks/usePagesQuery.test.ts
Normal file
23
frontend/src/hooks/usePagesQuery.test.ts
Normal 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);
|
||||
});
|
||||
37
frontend/src/lib/constructorHelpers.test.ts
Normal file
37
frontend/src/lib/constructorHelpers.test.ts
Normal 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' }],
|
||||
);
|
||||
});
|
||||
@ -45,6 +45,27 @@ export const getAssetLabel = (asset: ProjectAsset): string => {
|
||||
export const getAssetSourceValue = (asset: ProjectAsset): string =>
|
||||
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.
|
||||
* Used to filter assets for background image selection.
|
||||
@ -70,11 +91,12 @@ export const addFallbackAssetOption = (
|
||||
fallbackLabel?: string,
|
||||
): AssetOption[] => {
|
||||
const normalizedValue = String(value || '').trim();
|
||||
if (!normalizedValue) return options;
|
||||
if (options.some((option) => option.value === normalizedValue))
|
||||
return options;
|
||||
const uniqueOptions = deduplicateAssetOptions(options);
|
||||
if (!normalizedValue) return uniqueOptions;
|
||||
if (uniqueOptions.some((option) => option.value === normalizedValue))
|
||||
return uniqueOptions;
|
||||
return [
|
||||
...options,
|
||||
...uniqueOptions,
|
||||
{
|
||||
value: normalizedValue,
|
||||
label: fallbackLabel || `Custom URL · ${normalizedValue}`,
|
||||
@ -122,17 +144,19 @@ export const buildAssetOptions = (
|
||||
assetType: 'image' | 'video' | 'audio',
|
||||
additionalFilter?: (asset: ProjectAsset) => boolean,
|
||||
): AssetOption[] => {
|
||||
return assets
|
||||
.filter((asset) => {
|
||||
if (asset.asset_type !== assetType) return false;
|
||||
if (!getAssetSourceValue(asset)) return false;
|
||||
if (additionalFilter && !additionalFilter(asset)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((asset) => ({
|
||||
value: getAssetSourceValue(asset),
|
||||
label: getAssetLabel(asset),
|
||||
}));
|
||||
return deduplicateAssetOptions(
|
||||
assets
|
||||
.filter((asset) => {
|
||||
if (asset.asset_type !== assetType) return false;
|
||||
if (!getAssetSourceValue(asset)) return false;
|
||||
if (additionalFilter && !additionalFilter(asset)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((asset) => ({
|
||||
value: getAssetSourceValue(asset),
|
||||
label: getAssetLabel(asset),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -183,7 +207,7 @@ export const buildTransitionVideoOptions = (
|
||||
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
|
||||
const taggedAssets = assets
|
||||
@ -198,7 +222,7 @@ export const buildTransitionVideoOptions = (
|
||||
label: getAssetLabel(asset),
|
||||
}));
|
||||
|
||||
if (taggedAssets.length > 0) return taggedAssets;
|
||||
if (taggedAssets.length > 0) return deduplicateAssetOptions(taggedAssets);
|
||||
|
||||
// Fall back to all video assets
|
||||
return buildVideoAssetOptions(assets);
|
||||
@ -211,17 +235,19 @@ export const buildTransitionVideoOptions = (
|
||||
export const buildIconAssetOptions = (
|
||||
assets: ProjectAsset[],
|
||||
): AssetOption[] => {
|
||||
return assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
asset.type === 'icon' &&
|
||||
asset.asset_type === 'image' &&
|
||||
getAssetSourceValue(asset),
|
||||
)
|
||||
.map((asset) => ({
|
||||
value: getAssetSourceValue(asset),
|
||||
label: getAssetLabel(asset),
|
||||
}));
|
||||
return deduplicateAssetOptions(
|
||||
assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
asset.type === 'icon' &&
|
||||
asset.asset_type === 'image' &&
|
||||
getAssetSourceValue(asset),
|
||||
)
|
||||
.map((asset) => ({
|
||||
value: getAssetSourceValue(asset),
|
||||
label: getAssetLabel(asset),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -59,8 +59,9 @@ export const queryKeys = {
|
||||
// Tour Pages
|
||||
tourPages: {
|
||||
all: ['tourPages'] as const,
|
||||
lists: () => [...queryKeys.tourPages.all, 'list'] as const,
|
||||
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,
|
||||
byProject: (projectId: string) =>
|
||||
[...queryKeys.tourPages.all, 'byProject', projectId] as const,
|
||||
|
||||
@ -131,7 +131,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
isLoading: isDataLoading,
|
||||
isError: isDataError,
|
||||
error: dataError,
|
||||
refetch: refetchData,
|
||||
refetchPages: refetchData,
|
||||
} = useConstructorData({
|
||||
projectId,
|
||||
isAuthReady,
|
||||
@ -749,7 +749,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
pagesCount={pages.length}
|
||||
isElementEditMode={isElementEditMode}
|
||||
pageElementsListHref={pageElementsListHref}
|
||||
isSaving={isSaving}
|
||||
isSaving={isSaving || isSavingToStage}
|
||||
onSave={saveConstructor}
|
||||
/>
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
authenticate,
|
||||
collectConsoleFailures,
|
||||
mockFrontendApi,
|
||||
testPages,
|
||||
testProject,
|
||||
} from './fixtures';
|
||||
|
||||
@ -96,7 +97,9 @@ test('constructor saves dev page changes and promotes them to stage', async ({
|
||||
expect(saveToStageRequest.postDataJSON()).toEqual({
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@ -393,19 +393,26 @@ export async function mockFrontendApi(
|
||||
|
||||
if (path === '/publish/save-to-stage') {
|
||||
return fulfillJson(route, {
|
||||
success: true,
|
||||
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') {
|
||||
return fulfillJson(route, {
|
||||
success: true,
|
||||
publishEventId: 'publish-event-production',
|
||||
status: 'success',
|
||||
summary: {
|
||||
pages_copied: testPages.length,
|
||||
transitions_copied: 0,
|
||||
audios_copied: 0,
|
||||
transition_settings_copied: 1,
|
||||
ui_control_settings_copied: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user