updated page dropdown in the constructor

This commit is contained in:
Dmitri 2026-05-08 16:44:25 +02:00
parent 06a29dbf6a
commit 027af5082b
6 changed files with 418 additions and 95 deletions

View File

@ -0,0 +1,123 @@
/**
* CreatePageModal Component
*
* Modal dialog for creating new pages with custom name.
* Slug is auto-generated from the page name behind the scenes.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import CardBoxModal from '../CardBoxModal';
import { sanitizeSlug, buildUniqueSlug } from '../../lib/slugHelpers';
interface CreatePageModalProps {
/** Whether the modal is visible */
isActive: boolean;
/** Whether page creation is in progress */
isCreating: boolean;
/** Set of existing slugs for uniqueness validation */
existingSlugs: Set<string>;
/** Suggested page number for default name */
suggestedPageNumber: number;
/** Called when user confirms with valid name and slug */
onConfirm: (pageName: string, slug: string) => void;
/** Called when user cancels */
onCancel: () => void;
}
/**
* Modal for creating new pages - only asks for name, slug is auto-generated
*/
const CreatePageModal: React.FC<CreatePageModalProps> = ({
isActive,
isCreating,
existingSlugs,
suggestedPageNumber,
onConfirm,
onCancel,
}) => {
const [pageName, setPageName] = useState('');
const [nameError, setNameError] = useState('');
// Reset form when modal opens
useEffect(() => {
if (isActive) {
const defaultName = `Page ${suggestedPageNumber}`;
setPageName(defaultName);
setNameError('');
}
}, [isActive, suggestedPageNumber]);
// Validate name
const nameValidationError = useMemo(() => {
const trimmed = pageName.trim();
if (!trimmed) return 'Page name is required';
if (trimmed.length > 255) return 'Page name must be 255 characters or less';
return '';
}, [pageName]);
// Auto-generate unique slug from name
const generatedSlug = useMemo(() => {
const baseSlug = sanitizeSlug(pageName) || 'page';
return buildUniqueSlug(baseSlug, existingSlugs);
}, [pageName, existingSlugs]);
const handleNameChange = useCallback((value: string) => {
setPageName(value);
setNameError('');
}, []);
const handleConfirm = useCallback(() => {
if (nameValidationError) {
setNameError(nameValidationError);
return;
}
onConfirm(pageName.trim(), generatedSlug);
}, [pageName, generatedSlug, nameValidationError, onConfirm]);
const handleCancel = useCallback(() => {
if (!isCreating) {
onCancel();
}
}, [isCreating, onCancel]);
const isConfirmDisabled = isCreating || Boolean(nameValidationError);
return (
<CardBoxModal
title="Create page"
buttonColor="info"
buttonLabel={isCreating ? 'Creating...' : 'Create'}
isConfirmDisabled={isConfirmDisabled}
isActive={isActive}
onConfirm={handleConfirm}
onCancel={isCreating ? undefined : handleCancel}
>
<div>
<label
htmlFor="create-page-name"
className="block text-sm font-semibold mb-1"
>
Page name
</label>
<input
id="create-page-name"
type="text"
value={pageName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter page name"
className="w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800"
autoFocus
maxLength={255}
/>
{(nameError || nameValidationError) && (
<p className="text-xs text-red-600 mt-1">
{nameError || nameValidationError}
</p>
)}
</div>
</CardBoxModal>
);
};
export default CreatePageModal;

View File

@ -6,6 +6,7 @@ import {
mdiViewDashboard,
mdiChevronDown,
mdiChevronUp,
mdiPencil,
} from '@mdi/js';
import Icon from '@mdi/react';
import axios from 'axios';
@ -22,7 +23,7 @@ import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions';
import { useAppSelector, useAppDispatch } from '../stores/hooks';
import { logger } from '../lib/logger';
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers';
import { sanitizeSlug, buildUniqueSlug } from '../lib/slugHelpers';
import {
toRoutePath,
compareRoutes,
@ -114,10 +115,15 @@ const TourFlowManager = () => {
const [isCreatingPage, setIsCreatingPage] = useState(false);
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
const [newPageSlug, setNewPageSlug] = useState('');
const [newPageSlugError, setNewPageSlugError] = useState('');
const [newPageName, setNewPageName] = useState('');
const [deletingId, setDeletingId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
// Edit page modal state
const [isEditPageModalActive, setIsEditPageModalActive] = useState(false);
const [editingPageId, setEditingPageId] = useState('');
const [editPageName, setEditPageName] = useState('');
const [editPageNameError, setEditPageNameError] = useState('');
const [isSavingPageName, setIsSavingPageName] = useState(false);
// Use selector for current project's dev transition settings
const projectTransitionSettingsEntity = useAppSelector((state) =>
@ -337,15 +343,25 @@ const TourFlowManager = () => {
[pages, targetEnvironment],
);
const slugValidationError = useMemo(() => {
const slug = newPageSlug.trim();
if (!slug) return 'Slug is required.';
if (!slugPattern.test(slug))
return 'Use lowercase letters, numbers, and hyphens only.';
if (pageSlugsInEnvironment.has(slug))
return 'This slug already exists in the selected environment.';
const nameValidationError = useMemo(() => {
const name = newPageName.trim();
if (!name) return 'Page name is required.';
if (name.length > 255) return 'Page name must be 255 characters or less.';
return '';
}, [newPageSlug, pageSlugsInEnvironment]);
}, [newPageName]);
// Auto-generate unique slug from name
const generatedSlug = useMemo(() => {
const baseSlug = sanitizeSlug(newPageName) || 'page';
return buildUniqueSlug(baseSlug, pageSlugsInEnvironment);
}, [newPageName, pageSlugsInEnvironment]);
const editNameValidationError = useMemo(() => {
const name = editPageName.trim();
if (!name) return 'Page name is required.';
if (name.length > 255) return 'Page name must be 255 characters or less.';
return '';
}, [editPageName]);
const nextPageNumber = useMemo(() => pages.length + 1, [pages.length]);
@ -407,24 +423,113 @@ const TourFlowManager = () => {
return;
}
const suggestedSlug = buildUniqueSlug(
`page-${nextPageNumber}`,
pageSlugsInEnvironment,
);
setNewPageSlug(suggestedSlug);
setNewPageSlugError('');
const suggestedName = `Page ${nextPageNumber}`;
setNewPageName(suggestedName);
setIsCreatePageModalActive(true);
};
const handleSlugChange = (value: string) => {
setNewPageSlug(value);
setNewPageSlugError('');
const handleNameChange = (value: string) => {
setNewPageName(value);
};
const closeCreatePageModal = () => {
setIsCreatePageModalActive(false);
setNewPageSlug('');
setNewPageSlugError('');
setNewPageName('');
};
// Edit page modal handlers
const openEditPageModal = (event: React.MouseEvent, page: TourPage) => {
event.stopPropagation();
setEditingPageId(page.id);
setEditPageName(page.name || '');
setEditPageNameError('');
setIsEditPageModalActive(true);
};
const handleEditNameChange = (value: string) => {
setEditPageName(value);
setEditPageNameError('');
};
const closeEditPageModal = () => {
setIsEditPageModalActive(false);
setEditingPageId('');
setEditPageName('');
setEditPageNameError('');
};
const handleSavePageName = async () => {
if (editNameValidationError) {
setEditPageNameError(editNameValidationError);
return;
}
try {
setIsSavingPageName(true);
setEditPageNameError('');
// Fetch full page data from API to preserve all fields
// The backend's getFieldMapping converts missing fields to null
const pageResponse = await axios.get(`/tour_pages/${editingPageId}`);
const fullPageData = pageResponse.data;
if (!fullPageData) {
setEditPageNameError('Page not found');
return;
}
// Send all existing fields with only name changed
await axios.put(`/tour_pages/${editingPageId}`, {
id: editingPageId,
data: {
environment: fullPageData.environment,
source_key: fullPageData.source_key,
name: editPageName.trim(),
slug: fullPageData.slug,
sort_order: fullPageData.sort_order,
background_image_url: fullPageData.background_image_url,
background_video_url: fullPageData.background_video_url,
background_audio_url: fullPageData.background_audio_url,
background_loop: fullPageData.background_loop,
background_video_autoplay: fullPageData.background_video_autoplay,
background_video_loop: fullPageData.background_video_loop,
background_video_muted: fullPageData.background_video_muted,
background_video_start_time: fullPageData.background_video_start_time,
background_video_end_time: fullPageData.background_video_end_time,
design_width: fullPageData.design_width,
design_height: fullPageData.design_height,
requires_auth: fullPageData.requires_auth,
ui_schema_json: fullPageData.ui_schema_json,
},
});
// Update local state
setPages((prev) =>
prev.map((page) =>
page.id === editingPageId
? { ...page, name: editPageName.trim() }
: page,
),
);
closeEditPageModal();
toast.success('Page name updated');
} catch (error: unknown) {
const axiosError = error as {
response?: { data?: { message?: string } };
};
const message =
axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) ||
'Failed to update page name.';
setEditPageNameError(message);
logger.error(
'Failed to update page name:',
error instanceof Error ? error : { error },
);
} finally {
setIsSavingPageName(false);
}
};
const handleCreatePage = async () => {
@ -433,23 +538,22 @@ const TourFlowManager = () => {
return;
}
const slug = newPageSlug.trim();
const validationError = slugValidationError || newPageSlugError;
if (validationError) {
setNewPageSlugError(validationError);
const name = newPageName.trim();
// Validate name
if (nameValidationError) {
return;
}
try {
setIsCreatingPage(true);
setErrorMessage('');
setNewPageSlugError('');
const payload = {
project: activeProjectId,
environment: targetEnvironment,
source_key: '',
name: `Page ${nextPageNumber}`,
slug,
name,
slug: generatedSlug,
sort_order:
Math.max(...pages.map((item) => Number(item.sort_order || 0)), 0) + 1,
background_image_url: '',
@ -461,8 +565,7 @@ const TourFlowManager = () => {
};
await axios.post('/tour_pages', { data: payload });
setIsCreatePageModalActive(false);
setNewPageSlug('');
closeCreatePageModal();
await loadData();
} catch (error: unknown) {
const axiosError = error as {
@ -473,7 +576,6 @@ const TourFlowManager = () => {
(error instanceof Error ? error.message : null) ||
'Failed to create page.';
setErrorMessage(message);
setNewPageSlugError(message);
logger.error(
'Failed to create page:',
error instanceof Error ? error : { error },
@ -776,33 +878,66 @@ const TourFlowManager = () => {
title='Create page'
buttonColor='info'
buttonLabel={isCreatingPage ? 'Creating...' : 'Create'}
isConfirmDisabled={Boolean(slugValidationError) || isCreatingPage}
isConfirmDisabled={Boolean(nameValidationError) || isCreatingPage}
isActive={isCreatePageModalActive}
onConfirm={handleCreatePage}
onCancel={isCreatingPage ? undefined : closeCreatePageModal}
>
<div>
<label
htmlFor='new-page-slug'
htmlFor='new-page-name'
className='block text-sm font-semibold mb-1'
>
Page slug
Page name
</label>
<input
id='new-page-slug'
id='new-page-name'
type='text'
value={newPageSlug}
onChange={(event) => handleSlugChange(event.target.value)}
placeholder='my-page-slug'
value={newPageName}
onChange={(event) => handleNameChange(event.target.value)}
placeholder='Enter page name'
className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800'
autoFocus
maxLength={255}
/>
<p className='text-xs text-gray-500 mt-2'>
Use lowercase letters, numbers, and hyphens.
</p>
{(newPageSlugError || slugValidationError) && (
<p className='text-xs text-red-600 mt-2'>
{newPageSlugError || slugValidationError}
{nameValidationError && (
<p className='text-xs text-red-600 mt-1'>
{nameValidationError}
</p>
)}
</div>
</CardBoxModal>
{/* Edit Page Name Modal */}
<CardBoxModal
title='Edit page name'
buttonColor='info'
buttonLabel={isSavingPageName ? 'Saving...' : 'Save'}
isConfirmDisabled={Boolean(editNameValidationError) || isSavingPageName}
isActive={isEditPageModalActive}
onConfirm={handleSavePageName}
onCancel={isSavingPageName ? undefined : closeEditPageModal}
>
<div>
<label
htmlFor='edit-page-name'
className='block text-sm font-semibold mb-1'
>
Page name
</label>
<input
id='edit-page-name'
type='text'
value={editPageName}
onChange={(event) => handleEditNameChange(event.target.value)}
placeholder='Enter page name'
className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800'
autoFocus
maxLength={255}
/>
{(editPageNameError || editNameValidationError) && (
<p className='text-xs text-red-600 mt-1'>
{editPageNameError || editNameValidationError}
</p>
)}
</div>
@ -829,6 +964,9 @@ const TourFlowManager = () => {
const canDelete =
entry.type === 'page' ? canDeletePage : canDeleteTransition;
const isDeleting = deletingId === entry.id;
const pageData = entry.type === 'page'
? pages.find((p) => p.id === entry.id)
: null;
return (
<li key={`${entry.type}-${entry.id}`}>
@ -854,7 +992,7 @@ const TourFlowManager = () => {
}
}}
>
<div className='pr-8'>
<div className='pr-20'>
<p className='text-xs uppercase text-gray-500 mb-1'>
{entry.description}
</p>
@ -864,17 +1002,29 @@ const TourFlowManager = () => {
</p>
</div>
<BaseButton
className='!absolute top-3 right-3'
icon={mdiClose}
color='danger'
outline
small
onClick={(event) =>
handleDelete(event, entry.id, entry.type)
}
disabled={!canDelete || isDeleting}
/>
<div className='absolute top-3 right-3 flex gap-1'>
{entry.type === 'page' && pageData && (
<BaseButton
icon={mdiPencil}
color='info'
outline
small
onClick={(event) =>
openEditPageModal(event, pageData)
}
/>
)}
<BaseButton
icon={mdiClose}
color='danger'
outline
small
onClick={(event) =>
handleDelete(event, entry.id, entry.type)
}
disabled={!canDelete || isDeleting}
/>
</div>
</div>
</li>
);

View File

@ -105,9 +105,16 @@ export function useElementWrapperStyle({
// Build inline style from element properties
const inlineStyle = buildElementStyle(element);
// If element has configured padding, use it exclusively (skip default paddingStyle)
// This avoids React warning about conflicting shorthand/non-shorthand properties
const finalStyle =
'padding' in inlineStyle
? inlineStyle
: { ...paddingStyle, ...inlineStyle };
return {
className: classNames,
style: { ...paddingStyle, ...inlineStyle },
style: finalStyle,
};
}, [element, isSelected, isEditMode]);
}

View File

@ -80,8 +80,8 @@ interface UseConstructorPageActionsResult {
saveConstructor: () => Promise<void>;
/** Save dev content to stage environment */
saveToStage: () => Promise<void>;
/** Create a new page */
createPage: () => Promise<void>;
/** Create a new page with the given name and slug */
createPage: (pageName: string, slug: string) => Promise<void>;
/** Create a transition (legacy - transitions are now stored on elements) */
createTransition: (params: {
name?: string;
@ -256,24 +256,33 @@ export function useConstructorPageActions({
}
}, [projectId, saveConstructor, onError, onSuccess]);
const createPage = useCallback(async () => {
const createPage = useCallback(async (pageName: string, slug: string) => {
if (!projectId) {
onError?.('Project is required.');
return;
}
if (!pageName.trim()) {
onError?.('Page name is required.');
return;
}
if (!slug.trim()) {
onError?.('Page slug is required.');
return;
}
const maxSortOrder = Math.max(
0,
...pages.map((item) => Number(item.sort_order || 0)),
);
const nextPageNumber = pages.length + 1;
const payload = {
project: projectId,
environment: activePage?.environment || 'dev',
source_key: '',
name: `Page ${nextPageNumber}`,
slug: `page-${nextPageNumber}-${Date.now().toString().slice(-4)}`,
name: pageName.trim(),
slug: slug.trim(),
sort_order: maxSortOrder + 1,
background_image_url: '',
background_video_url: '',

View File

@ -47,17 +47,14 @@ export function sanitizeSlug(value: string): string {
*/
export function buildUniqueSlug(
baseValue: string,
usedSlugs: Set<string>,
_usedSlugs: Set<string>,
): string {
const baseSlug = sanitizeSlug(baseValue) || 'page';
if (!usedSlugs.has(baseSlug)) return baseSlug;
let suffix = 2;
while (usedSlugs.has(`${baseSlug}-${suffix}`)) {
suffix += 1;
}
return `${baseSlug}-${suffix}`;
// Always append timestamp to guarantee uniqueness
// (_usedSlugs kept for API compatibility but not used - cached data may be stale)
const timestamp = Date.now().toString(36);
return `${baseSlug}-${timestamp}`;
}
/**

View File

@ -19,6 +19,7 @@ import TransitionPreviewOverlay from '../components/Constructor/TransitionPrevie
import CanvasElementComponent from '../components/Constructor/CanvasElement';
import GalleryCarouselOverlay from '../components/UiElements/GalleryCarouselOverlay';
import ElementEditorPanel from '../components/Constructor/ElementEditorPanel';
import CreatePageModal from '../components/Constructor/CreatePageModal';
import { BackdropPortalProvider } from '../components/BackdropPortal';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
@ -286,6 +287,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
currentElementTransitionSettings,
setCurrentElementTransitionSettings,
] = useState<ElementTransitionSettings | null>(null);
// Create page modal state
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
const isConstructorEditMode = constructorInteractionMode === 'edit';
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
@ -368,6 +371,20 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[activePageId, pages],
);
// Existing page slugs for uniqueness validation (current environment only)
const existingSlugs = useMemo(() => {
const targetEnv = activePage?.environment || 'dev';
return new Set(
pages
.filter((p) => (p.environment || 'dev') === targetEnv)
.map((p) => p.slug || '')
.filter(Boolean),
);
}, [pages, activePage?.environment]);
// Suggested page number for new page name
const suggestedPageNumber = useMemo(() => pages.length + 1, [pages.length]);
// Last project-level save: most recent updatedAt across all pages
const lastProjectSaveAt = useMemo(() => {
if (!pages.length) return null;
@ -765,6 +782,25 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
await refreshPublishStatus();
}, [saveToStage, refreshPublishStatus]);
// Handlers for create page modal
const handleShowCreatePageModal = useCallback(async () => {
// Refresh pages data to ensure existingSlugs is up to date
await refetchData();
setIsCreatePageModalActive(true);
}, [refetchData]);
const handleCreatePageWithName = useCallback(
async (pageName: string, slug: string) => {
await createPage(pageName, slug);
setIsCreatePageModalActive(false);
},
[createPage],
);
const handleCloseCreatePageModal = useCallback(() => {
setIsCreatePageModalActive(false);
}, []);
useEffect(() => {
if (!router.isReady || typeof window === 'undefined') return;
@ -1077,31 +1113,22 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
updateBackgroundFromPage,
]);
// Separate effect for initial background loading (matches RuntimePresentation pattern)
// This effect ONLY handles initial page load when backgrounds are empty.
// switchToPage handles all subsequent navigation by calling navNavigateToPage directly.
// Keeping this separate prevents race conditions where state updates trigger
// this effect before activePageId has been updated via applyPageSelection.
// Separate effect for background loading when activePage changes.
// This handles both initial page load AND page switches (e.g., after creating a new page).
// The lastInitializedPageIdRef prevents redundant calls for the same page.
useEffect(() => {
if (!activePage || lastInitializedPageIdRef.current === activePage.id)
return;
// Only initialize when backgrounds are EMPTY (initial load)
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
lastInitializedPageIdRef.current = activePage.id;
navNavigateToPage({
id: activePage.id,
background_image_url: activePage.background_image_url,
background_video_url: activePage.background_video_url,
background_audio_url: activePage.background_audio_url,
});
}
}, [
activePage,
navCurrentBgImageUrl,
navCurrentBgVideoUrl,
navNavigateToPage,
]);
// Update navigation state with new page's backgrounds
lastInitializedPageIdRef.current = activePage.id;
navNavigateToPage({
id: activePage.id,
background_image_url: activePage.background_image_url,
background_video_url: activePage.background_video_url,
background_audio_url: activePage.background_audio_url,
});
}, [activePage, navNavigateToPage]);
useEffect(() => {
if (allowedNavigationTypes.length !== 1) return;
@ -1735,7 +1762,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onSelectMenuItem={selectMenuItemForEdit}
allowedNavigationTypes={allowedNavigationTypes}
onAddElement={addElement}
onCreatePage={createPage}
onCreatePage={handleShowCreatePageModal}
isCreatingPage={isCreatingPage}
onSave={saveConstructor}
onSaveToStage={handleSaveToStage}
@ -1844,7 +1871,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
isCreatingPage ? 'Creating...' : 'Create First Page'
}
icon={mdiPlus}
onClick={createPage}
onClick={handleShowCreatePageModal}
disabled={isCreatingPage}
/>
</div>
@ -1962,6 +1989,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/>
)}
{/* Create Page Modal */}
<CreatePageModal
isActive={isCreatePageModalActive}
isCreating={isCreatingPage}
existingSlugs={existingSlugs}
suggestedPageNumber={suggestedPageNumber}
onConfirm={handleCreatePageWithName}
onCancel={handleCloseCreatePageModal}
/>
<style jsx>{`
.menu-action-btn {
width: 100%;