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",
|
"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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:**
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
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 () => {
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:**
|
||||||
|
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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]}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
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 =>
|
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),
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user