feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { PageProvider, usePages, applyLandingInvariant } from './PageContext';
|
||||
import { PageData } from '../types';
|
||||
|
||||
/**
|
||||
* PKG-I: page duplicate / reorder / set-landing.
|
||||
*
|
||||
* The landing-page invariant is: `pages[0]` is the landing page, slug
|
||||
* LOCKED to `'index'`; every other page gets a real, unique slug. This
|
||||
* suite covers:
|
||||
* - `applyLandingInvariant` as a pure function (unit tests, no provider).
|
||||
* - `movePage`/`setLandingPage` re-establishing the invariant after
|
||||
* reordering, via `PageProvider`.
|
||||
* - `duplicatePage` inserting a copy right after the source with a copied
|
||||
* craftState + seo and a unique slug, and switching the canvas to it.
|
||||
*/
|
||||
|
||||
function makePage(overrides: Partial<PageData> & { id: string }): PageData {
|
||||
return { name: overrides.id, slug: overrides.id, craftState: null, ...overrides };
|
||||
}
|
||||
|
||||
describe('applyLandingInvariant (pure)', () => {
|
||||
test('page at index 0 gets slug "index" even if it held a different slug', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'b', name: 'Home', slug: 'index' }),
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
expect(result[0].slug).toBe('index');
|
||||
expect(result[0].id).toBe('a');
|
||||
});
|
||||
|
||||
test('demotes the old landing page (now at index > 0) to a unique real slug', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'b', name: 'Home', slug: 'index' }),
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
const demoted = result.find((p) => p.id === 'b')!;
|
||||
expect(demoted.slug).not.toBe('index');
|
||||
expect(demoted.slug).toBe('home');
|
||||
});
|
||||
|
||||
test('never produces two pages with slug "index"', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'b', name: 'Home', slug: 'index' }),
|
||||
makePage({ id: 'c', name: 'Contact', slug: 'contact' }),
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
const indexSlugs = result.filter((p) => p.slug === 'index');
|
||||
expect(indexSlugs).toHaveLength(1);
|
||||
expect(indexSlugs[0].id).toBe('a');
|
||||
});
|
||||
|
||||
test('demoted page slug is deduped against a colliding existing slug elsewhere in the array', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'b', name: 'Home', slug: 'index' }), // demoted page, named "Home" -> slugifies to "home"
|
||||
makePage({ id: 'c', name: 'HomePage', slug: 'home' }), // unrelated page already using slug "home"
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
expect(result[0].slug).toBe('index');
|
||||
const demoted = result.find((p) => p.id === 'b')!;
|
||||
expect(demoted.slug).toBe('home-2');
|
||||
const untouched = result.find((p) => p.id === 'c')!;
|
||||
expect(untouched.slug).toBe('home');
|
||||
|
||||
const slugs = result.map((p) => p.slug);
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
});
|
||||
|
||||
test('non-landing pages that already have a real slug are left untouched', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'Home', slug: 'index' }),
|
||||
makePage({ id: 'b', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'c', name: 'Contact', slug: 'contact' }),
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
expect(result[1]).toEqual(pages[1]);
|
||||
expect(result[2]).toEqual(pages[2]);
|
||||
});
|
||||
|
||||
test('empty array is a no-op', () => {
|
||||
expect(applyLandingInvariant([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- PageProvider-mounted coverage ---------- */
|
||||
|
||||
let serializeReturn = '{}';
|
||||
const deserializeMock = vi.fn();
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({
|
||||
query: { serialize: () => serializeReturn },
|
||||
actions: { deserialize: deserializeMock },
|
||||
}),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
async function flushTimers() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
serializeReturn = '{}';
|
||||
deserializeMock.mockClear();
|
||||
});
|
||||
|
||||
describe('PageContext.movePage', () => {
|
||||
test('moves a page up, swapping with its neighbor', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
act(() => ctx!.addPage('Contact', 'contact'));
|
||||
// pages: [Home(index0), About, Contact]
|
||||
const contactId = ctx!.pages[2].id;
|
||||
|
||||
act(() => ctx!.movePage(contactId, 'up'));
|
||||
|
||||
expect(ctx!.pages.map((p) => p.name)).toEqual(['Home', 'Contact', 'About']);
|
||||
// Landing invariant still holds -- Home untouched at index 0.
|
||||
expect(ctx!.pages[0].slug).toBe('index');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('is a no-op at the top boundary', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const before = ctx!.pages.map((p) => p.id);
|
||||
|
||||
act(() => ctx!.movePage(homeId, 'up'));
|
||||
|
||||
expect(ctx!.pages.map((p) => p.id)).toEqual(before);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('is a no-op at the bottom boundary', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
const before = ctx!.pages.map((p) => p.id);
|
||||
|
||||
act(() => ctx!.movePage(aboutId, 'down'));
|
||||
|
||||
expect(ctx!.pages.map((p) => p.id)).toEqual(before);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('moving a non-landing page into index 0 promotes it and demotes the old landing page to a real slug', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
// pages: [Home(index0, slug index), About]
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
const homeId = ctx!.pages[0].id;
|
||||
|
||||
act(() => ctx!.movePage(aboutId, 'up'));
|
||||
// pages: [About, Home]
|
||||
|
||||
expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]);
|
||||
expect(ctx!.pages[0].slug).toBe('index'); // About is now the landing page
|
||||
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
|
||||
expect(demotedHome.slug).not.toBe('index');
|
||||
expect(demotedHome.slug).toBe('home');
|
||||
|
||||
// Exactly one 'index' slug, always at index 0.
|
||||
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
|
||||
expect(indexPages).toHaveLength(1);
|
||||
expect(ctx!.pages.indexOf(indexPages[0])).toBe(0);
|
||||
|
||||
// movePage does not touch the canvas.
|
||||
expect(deserializeMock).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageContext.setLandingPage', () => {
|
||||
test('promotes an arbitrary page to index 0 and demotes the old landing page', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
act(() => ctx!.addPage('Contact', 'contact'));
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const contactId = ctx!.pages[2].id;
|
||||
|
||||
act(() => ctx!.setLandingPage(contactId));
|
||||
|
||||
expect(ctx!.pages[0].id).toBe(contactId);
|
||||
expect(ctx!.pages[0].slug).toBe('index');
|
||||
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
|
||||
expect(demotedHome.slug).toBe('home');
|
||||
|
||||
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
|
||||
expect(indexPages).toHaveLength(1);
|
||||
|
||||
// setLandingPage does not touch the canvas.
|
||||
expect(deserializeMock).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('is a no-op when the page is already the landing page', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const before = ctx!.pages.map((p) => ({ ...p }));
|
||||
|
||||
act(() => ctx!.setLandingPage(homeId));
|
||||
|
||||
expect(ctx!.pages).toEqual(before);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageContext.duplicatePage', () => {
|
||||
test('inserts a copy immediately after the source with a copied craftState, seo, and a unique slug', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
await flushTimers();
|
||||
act(() => ctx!.addPage('Contact', 'contact'));
|
||||
await flushTimers();
|
||||
// pages: [Home, About, Contact]; Contact is currently active.
|
||||
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
act(() => ctx!.updatePageSeo(aboutId, { metaTitle: 'About Us' }));
|
||||
|
||||
act(() => ctx!.duplicatePage(aboutId));
|
||||
await flushTimers();
|
||||
|
||||
const names = ctx!.pages.map((p) => p.name);
|
||||
expect(names).toEqual(['Home', 'About', 'About copy', 'Contact']);
|
||||
|
||||
const copy = ctx!.pages[2];
|
||||
expect(copy.name).toBe('About copy');
|
||||
expect(copy.slug).toBe('about-copy');
|
||||
expect(copy.seo).toEqual({ metaTitle: 'About Us' });
|
||||
|
||||
// Every slug in the array is unique.
|
||||
const slugs = ctx!.pages.map((p) => p.slug);
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
|
||||
// Landing invariant untouched -- copy is never index 0.
|
||||
expect(ctx!.pages[0].slug).toBe('index');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('duplicating the ACTIVE page saves the live canvas into the copy (and the original)', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
// Home is active by default. Simulate the user having made live edits.
|
||||
serializeReturn = '{"ROOT":{"live":"edit"}}';
|
||||
|
||||
act(() => ctx!.duplicatePage(ctx!.pages[0].id));
|
||||
await flushTimers();
|
||||
|
||||
const copy = ctx!.pages[1];
|
||||
expect(copy.name).toBe('Home copy');
|
||||
expect(copy.craftState).toBe('{"ROOT":{"live":"edit"}}');
|
||||
|
||||
// Original page's stored state was also refreshed to the live canvas.
|
||||
expect(ctx!.pages[0].craftState).toBe('{"ROOT":{"live":"edit"}}');
|
||||
|
||||
// The canvas switched to the new copy.
|
||||
expect(ctx!.activePageId).toBe(copy.id);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('duplicating a non-active page copies its already-stored craftState (no live-canvas read)', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
await flushTimers();
|
||||
// Home is now inactive, stored with whatever it serialized to on switch.
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const homeCraftState = ctx!.pages[0].craftState;
|
||||
|
||||
// Switch the live serialize() return to something else, to prove
|
||||
// duplicating an inactive page does NOT read the live canvas.
|
||||
serializeReturn = '{"ROOT":{"unrelated":"currently-active-page-content"}}';
|
||||
|
||||
act(() => ctx!.duplicatePage(homeId));
|
||||
await flushTimers();
|
||||
|
||||
const copy = ctx!.pages.find((p) => p.name === 'Home copy')!;
|
||||
expect(copy.craftState).toBe(homeCraftState);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('switches the canvas to the new copy (deserialize called with the copy craftState)', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
deserializeMock.mockClear();
|
||||
act(() => ctx!.duplicatePage(ctx!.pages[0].id));
|
||||
await flushTimers();
|
||||
|
||||
expect(deserializeMock).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,28 @@ interface PageContextValue {
|
||||
addPage: (name: string, slug: string) => void;
|
||||
deletePage: (pageId: string) => void;
|
||||
renamePage: (pageId: string, name: string, slug: string) => void;
|
||||
/**
|
||||
* Duplicates `pageId`, inserting the copy immediately after the source in
|
||||
* `pages` and switching the canvas to the new copy. If `pageId` is the
|
||||
* active page, its current on-canvas state is saved first so the copy
|
||||
* (and the original) both reflect what's actually on screen. The copy
|
||||
* gets its own unique slug (never `'index'` -- it's never at index 0) and
|
||||
* a name of `"<source name> copy"`; its `seo` is copied from the source.
|
||||
*/
|
||||
duplicatePage: (pageId: string) => void;
|
||||
/**
|
||||
* Reorders `pageId` one slot `'up'` or `'down'` within `pages` (swap with
|
||||
* the adjacent page; no-op at either end). Does NOT touch the live
|
||||
* canvas -- only list order changes. Re-applies the landing-page
|
||||
* invariant afterward (see `applyLandingInvariant`) since a reorder can
|
||||
* move a different page into/out of index 0.
|
||||
*/
|
||||
movePage: (pageId: string, direction: 'up' | 'down') => void;
|
||||
/**
|
||||
* Moves `pageId` to index 0 (making it the new landing page) and
|
||||
* re-applies the landing-page invariant. Does NOT touch the live canvas.
|
||||
*/
|
||||
setLandingPage: (pageId: string) => void;
|
||||
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
|
||||
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
|
||||
setHeaderCraftState: (craftState: string) => void;
|
||||
@@ -188,6 +210,9 @@ const PageContext = createContext<PageContextValue>({
|
||||
addPage: () => {},
|
||||
deletePage: () => {},
|
||||
renamePage: () => {},
|
||||
duplicatePage: () => {},
|
||||
movePage: () => {},
|
||||
setLandingPage: () => {},
|
||||
updatePageSeo: () => {},
|
||||
setHeaderCraftState: () => {},
|
||||
setFooterCraftState: () => {},
|
||||
@@ -229,6 +254,52 @@ export function uniqueSlug(base: string, existingSlugs: string[]): string {
|
||||
return `${base}-${i}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-establishes the landing-page invariant on a REORDERED pages array: the
|
||||
* page now at index 0 is the landing page and its slug is locked to
|
||||
* `'index'` (regardless of whatever slug it held before it was moved there);
|
||||
* every other page keeps its slug UNLESS it's the page that previously held
|
||||
* `'index'` and has now been demoted to index > 0 -- that page needs a real,
|
||||
* unique slug of its own (derived from its name) since a page can no longer
|
||||
* publish to `index.html` from anywhere but index 0.
|
||||
*
|
||||
* Pure function of the array -- used by both `movePage` (swap two adjacent
|
||||
* pages) and `setLandingPage` (move an arbitrary page to index 0) as the
|
||||
* shared "fix the invariant up after reordering" step, and directly
|
||||
* unit-testable without mounting `PageProvider`.
|
||||
*
|
||||
* Assumes at most one page enters with slug `'index'` (true for any array
|
||||
* that already satisfied the invariant before the reorder that produced this
|
||||
* input) -- exactly the case both callers hand it.
|
||||
*/
|
||||
export function applyLandingInvariant(pages: PageData[]): PageData[] {
|
||||
if (pages.length === 0) return pages;
|
||||
|
||||
// Slugs that are FIXED and must not be collided into: 'index' (reserved
|
||||
// for whoever ends up at index 0) plus every non-landing page's existing
|
||||
// slug except the demoted page's (it currently holds 'index' and is about
|
||||
// to be given a new one). Computed upfront, over the WHOLE array, so the
|
||||
// demoted page's new slug is checked against every other page regardless
|
||||
// of array order -- checking only "slugs seen so far" while walking the
|
||||
// array would miss a collision against a page that appears LATER in the
|
||||
// list than the demoted one.
|
||||
const fixedSlugs: string[] = ['index'];
|
||||
for (let i = 1; i < pages.length; i++) {
|
||||
if (pages[i].slug !== 'index') fixedSlugs.push(pages[i].slug);
|
||||
}
|
||||
|
||||
return pages.map((page, i) => {
|
||||
if (i === 0) {
|
||||
return page.slug === 'index' ? page : { ...page, slug: 'index' };
|
||||
}
|
||||
if (page.slug === 'index') {
|
||||
// Demoted landing page -- give it a real, unique slug of its own.
|
||||
return { ...page, slug: uniqueSlug(slugify(page.name), fixedSlugs) };
|
||||
}
|
||||
return page;
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE: PageData = {
|
||||
id: 'home',
|
||||
name: 'Home',
|
||||
@@ -409,6 +480,97 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
[loadState],
|
||||
);
|
||||
|
||||
/**
|
||||
* Duplicates `pageId`: inserts a copy immediately after the source in
|
||||
* `pages` and switches the live canvas to it. See the doc comment on
|
||||
* `PageContextValue.duplicatePage`.
|
||||
*/
|
||||
const duplicatePage = useCallback(
|
||||
(pageId: string) => {
|
||||
const isActive = pageId === activePageIdRef.current;
|
||||
|
||||
// If the source is the active page, persist its current on-canvas
|
||||
// state back into `pages` first (same as switchPage/addPage do)
|
||||
// so the ORIGINAL page isn't left with a stale craftState after
|
||||
// this. `query.serialize()` below reads the live canvas directly
|
||||
// rather than waiting on this (React state update timing aside,
|
||||
// it's simplest to just ask Craft.js for the truth).
|
||||
if (isActive) {
|
||||
saveCurrentState();
|
||||
}
|
||||
|
||||
const source = pagesRef.current.find((p) => p.id === pageId);
|
||||
if (!source) return;
|
||||
|
||||
const sourceCraftState = isActive ? query.serialize() : source.craftState;
|
||||
const otherSlugs = pagesRef.current.map((p) => p.slug);
|
||||
const copyId = nextPageId();
|
||||
const copyName = `${source.name} copy`;
|
||||
// The copy is always inserted AFTER the source (index >= 1), so it
|
||||
// never needs the reserved 'index' slug -- a normal unique slug always
|
||||
// applies here regardless of whether the source itself is the landing
|
||||
// page.
|
||||
const copySlug = uniqueSlug(slugify(copyName), otherSlugs);
|
||||
const copy: PageData = {
|
||||
id: copyId,
|
||||
name: copyName,
|
||||
slug: copySlug,
|
||||
craftState: sourceCraftState,
|
||||
seo: source.seo ? { ...source.seo } : undefined,
|
||||
};
|
||||
|
||||
setPages((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === pageId);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(idx + 1, 0, copy);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Switch the canvas to the new copy so the user lands on it, same as
|
||||
// addPage switching to the freshly created page.
|
||||
loadState(copy.craftState, EMPTY_CANVAS);
|
||||
setActivePageId(copyId);
|
||||
activePageIdRef.current = copyId;
|
||||
},
|
||||
[query, saveCurrentState, loadState],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reorders `pageId` one slot up or down (swap with the adjacent page).
|
||||
* Pure list-order change -- does not touch the live canvas. See the doc
|
||||
* comment on `PageContextValue.movePage`.
|
||||
*/
|
||||
const movePage = useCallback((pageId: string, direction: 'up' | 'down') => {
|
||||
setPages((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === pageId);
|
||||
if (idx === -1) return prev;
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= prev.length) return prev; // no-op at the ends
|
||||
|
||||
const next = [...prev];
|
||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||
return applyLandingInvariant(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Moves `pageId` to index 0, making it the new landing page. Pure list-
|
||||
* order change -- does not touch the live canvas. See the doc comment on
|
||||
* `PageContextValue.setLandingPage`.
|
||||
*/
|
||||
const setLandingPage = useCallback((pageId: string) => {
|
||||
setPages((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === pageId);
|
||||
if (idx <= 0) return prev; // already the landing page, or not found
|
||||
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(idx, 1);
|
||||
next.unshift(moved);
|
||||
return applyLandingInvariant(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
|
||||
setPages((prev) =>
|
||||
prev.map((p, i) => {
|
||||
@@ -556,6 +718,9 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
addPage,
|
||||
deletePage,
|
||||
renamePage,
|
||||
duplicatePage,
|
||||
movePage,
|
||||
setLandingPage,
|
||||
updatePageSeo,
|
||||
setHeaderCraftState,
|
||||
setFooterCraftState,
|
||||
|
||||
Reference in New Issue
Block a user