Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions resources/js/visual-editor/editor/components/EditorBoot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EditorShell } from './EditorShell';
import {
fetchPost,
useAutosave,
type AutosaveState,
type PostRestClientOptions,
} from '../rest';
import { createEditorStore, type Block, type EditorStore } from '../store';

export interface EditorBootProps {
postId: string;
postType: string;
apiBase: string;
}

type LoadState =
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'ready'; store: EditorStore };

export function EditorBoot({ postId, apiBase }: EditorBootProps) {
const clientOptions = useMemo<PostRestClientOptions>(
() => ({ apiBase }),
[apiBase]
);

const [loadState, setLoadState] = useState<LoadState>({ status: 'loading' });
const [loadAttempt, setLoadAttempt] = useState(0);
const abortRef = useRef<AbortController | null>(null);

useEffect(() => {
const controller = new AbortController();
abortRef.current = controller;

setLoadState({ status: 'loading' });

fetchPost(postId, clientOptions, { signal: controller.signal })
.then((payload) => {
if (controller.signal.aborted) {
return;
}

const store = createEditorStore(payload.blocks as Block[]);
setLoadState({ status: 'ready', store });
})
.catch((error: unknown) => {
if (controller.signal.aborted) {
return;
}

const wrapped =
error instanceof Error ? error : new Error('Failed to load post.');
setLoadState({ status: 'error', error: wrapped });
});

return () => {
controller.abort();
abortRef.current = null;
};
}, [postId, clientOptions, loadAttempt]);

const retry = useCallback(() => {
setLoadAttempt((attempt) => attempt + 1);
}, []);

if (loadState.status === 'loading') {
return (
<div
className="ve-editor-boot ve-editor-boot--loading"
data-ve-editor-boot="loading"
role="status"
aria-live="polite"
>
Loading editor…
</div>
);
}

if (loadState.status === 'error') {
return (
<div
className="ve-editor-boot ve-editor-boot--error"
data-ve-editor-boot="error"
role="alert"
>
<p className="ve-editor-boot__message">
Failed to load the post: {loadState.error.message}
</p>
<button
type="button"
className="ve-editor-boot__retry"
onClick={retry}
>
Retry
</button>
</div>
);
}

return (
<EditorBootReady
store={loadState.store}
postId={postId}
clientOptions={clientOptions}
/>
);
}

interface EditorBootReadyProps {
store: EditorStore;
postId: string;
clientOptions: PostRestClientOptions;
}

function EditorBootReady({ store, postId, clientOptions }: EditorBootReadyProps) {
const autosave = useAutosave({ store, postId, clientOptions });

return (
<>
<AutosaveAnnouncer state={autosave} />
<EditorShell store={store} saveStatus={autosave} />
</>
);
}

function AutosaveAnnouncer({ state }: { state: AutosaveState }) {
if (state.status !== 'error' || !state.lastError) {
return null;
}

return (
<div
className="ve-editor-boot__toast"
data-ve-editor-boot="save-error"
role="alert"
>
Failed to save: {state.lastError.message}
</div>
);
}
39 changes: 33 additions & 6 deletions resources/js/visual-editor/editor/components/EditorShell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useStore } from 'zustand';
import type { EditorStore } from '../store';
import type { AutosaveState } from '../rest';
import { EditorStoreProvider, useEditorStore } from '../primitives';
import { Canvas } from './Canvas';
import { BlockList } from './BlockList';
Expand All @@ -9,20 +10,21 @@ import { InserterPanel } from './InserterPanel';

export interface EditorShellProps {
store: EditorStore;
saveStatus?: AutosaveState;
}

export function EditorShell({ store }: EditorShellProps) {
export function EditorShell({ store, saveStatus }: EditorShellProps) {
return (
<EditorStoreProvider store={store}>
<EditorShellChrome />
<EditorShellChrome saveStatus={saveStatus} />
</EditorStoreProvider>
);
}

function EditorShellChrome() {
function EditorShellChrome({ saveStatus }: { saveStatus?: AutosaveState }) {
return (
<div className="ve-editor-shell" data-ve-editor-shell="">
<Statusbar />
<Statusbar saveStatus={saveStatus} />
<KeyboardBindings />
<RichTextToolbar />
<div className="ve-editor-shell__body">
Expand All @@ -35,11 +37,14 @@ function EditorShellChrome() {
);
}

function Statusbar() {
function Statusbar({ saveStatus }: { saveStatus?: AutosaveState }) {
const store = useEditorStore();
const isDirty = useStore(store, (state) => state.isDirty);
const selectedClientId = useStore(store, (state) => state.selection.clientId);

const statusLabel = resolveStatusLabel(saveStatus, isDirty);
const statusToken = saveStatus?.status ?? (isDirty ? 'dirty' : 'saved');

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return (
<div className="ve-editor-shell__statusbar" role="status" aria-live="polite">
<span
Expand All @@ -50,8 +55,9 @@ function Statusbar() {
.filter(Boolean)
.join(' ')}
data-ve-dirty={isDirty || undefined}
data-ve-save-status={statusToken}
>
{isDirty ? 'Unsaved changes' : 'Saved'}
{statusLabel}
</span>
<span
className="ve-editor-shell__status"
Expand Down Expand Up @@ -95,6 +101,27 @@ function KeyboardBindings() {
return null;
}

function resolveStatusLabel(
saveStatus: AutosaveState | undefined,
isDirty: boolean
): string {
if (saveStatus) {
switch (saveStatus.status) {
case 'saving':
return 'Saving…';
case 'saved':
return 'Saved';
case 'error':
return 'Save failed';
case 'idle':
default:
break;
}
}

return isDirty ? 'Unsaved changes' : 'Saved';
}

function isUndoRedoKey(event: KeyboardEvent): boolean {
if (!(event.metaKey || event.ctrlKey)) {
return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EditorBoot } from '../EditorBoot';
import {
clearRegistry,
registerBlock,
type BlockEditProps,
} from '../../registry';

function Paragraph({ attributes, clientId }: BlockEditProps) {
return <p data-client-id={clientId}>{String(attributes.content ?? '')}</p>;
}

const originalFetch = globalThis.fetch;

beforeEach(() => {
clearRegistry();
registerBlock({ name: 'core/paragraph', edit: Paragraph });
});

afterEach(() => {
clearRegistry();
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}

describe('EditorBoot', () => {
it('fetches the post on mount and renders the editor', async () => {
const payload = {
id: 1,
title: 'Test',
blocks: [
{
clientId: 'loaded-1',
name: 'core/paragraph',
attributes: { content: 'Loaded from REST' },
innerBlocks: [],
},
],
updated_at: '2026-04-14T12:00:00+00:00',
};

globalThis.fetch = vi
.fn()
.mockResolvedValue(jsonResponse(payload)) as unknown as typeof fetch;

render(
<EditorBoot postId="1" postType="post" apiBase="/visual-editor/api" />
);

expect(screen.getByText('Loading editor…')).toBeInTheDocument();

await waitFor(() => {
expect(screen.getByText('Loaded from REST')).toBeInTheDocument();
});

expect(document.querySelector('[data-ve-editor-shell]')).not.toBeNull();
expect(globalThis.fetch).toHaveBeenCalledWith(
'/visual-editor/api/posts/1',
expect.objectContaining({ method: 'GET' })
);
});

it('renders the error state with a retry button on load failure', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'Nope' }), { status: 500 })
)
.mockResolvedValueOnce(
jsonResponse({
id: 1,
title: 'Test',
blocks: [],
updated_at: '2026-04-14T12:00:00+00:00',
})
);

globalThis.fetch = fetchMock as unknown as typeof fetch;

render(
<EditorBoot postId="1" postType="post" apiBase="/visual-editor/api" />
);

await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});

expect(screen.getByText(/Failed to load the post/)).toBeInTheDocument();

const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /retry/i }));

await waitFor(() => {
expect(document.querySelector('[data-ve-editor-shell]')).not.toBeNull();
});

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
Loading