Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 13 additions & 0 deletions .changeset/internal-form-in-shell-4109.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/console': minor
---

Internal `/forms/:name` renders inside the console shell, and an internal submit lands on the record it just created

A `type: 'form'` action navigates to `/forms/:name` (`ActionRunner.executeForm`). That route was declared at the TOP level of the console route tree, a sibling of the app-shell routes, so clicking a button inside an app dropped the user onto a bare form — no header, no navigation, no way back — while the URL still said they were in the console. It now renders inside the console's layout for app-independent authed pages, the same chrome `/home` and `/organizations` use. The route itself is unchanged: deep links to it keep working, because the missing chrome was the defect, not the navigation.

The second half is what happens after Submit. The post-submit default was `{ kind: 'thank-you' }` for both form modes, so a signed-in operator who had just created a record was shown the ANONYMOUS confirmation — "Your submission has been received" — with no link to the thing they had created. The default is now mode-aware: an internal submit navigates to the created record's page, while the public `/f/:slug` path keeps `thank-you`, which is the right answer for a visitor who has no console to be sent into. A form view that declares its own `submitBehavior` still wins in both modes, unchanged and untouched — the point of a default is that the corpus never has to opt out of a wrong one.

Landing on the record needs the created record's id, and that comes from the spec-declared `CreateDataResponse = { object, id, record }` returned by `POST /api/v1/data/:object`. Only that one declared key is read: `record.id` carries the same value, but reading both would be a second de-facto contract for one fact. A response that names no id — or a workspace where no app can host the record's page — falls back to confirming the submit rather than navigating somewhere broken, since the record really was created and silence would be the worse answer.

No authorable surface changed. The "land on the created record" behaviour is deliberately NOT a new `submitBehavior.kind`: the spec's union (`thank-you | redirect | continue | next-record`) is strict and stays exactly as it is, and nothing parses the new internal default out of metadata — it is only what the renderer does when an author declared nothing.
12 changes: 10 additions & 2 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { RootLandingRedirect } from './components/RootLandingRedirect';
import { ProtectedRoute } from './components/ProtectedRoute';
import { SetupRoute } from './components/SetupRoute';
import { FormPage } from './components/FormPage';
import { InternalFormRoute } from './components/InternalFormRoute';
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
import SharedRecordPage from './pages/SharedRecordPage';
import DocPage from './pages/DocPage';
Expand Down Expand Up @@ -213,10 +214,17 @@ export function App() {
<StudioDesignSurface />
</ProtectedRoute>
} />
{/* Internal authed form — same renderer, different submit path. */}
{/* Internal authed form — same renderer as `/f/:slug`, different
* submit path, and (objectui#4109) rendered INSIDE the console
* shell instead of as a bare page. The route stays exactly where
* it is: per the 2026-08-10 ruling on objectstack#7245 the missing
* chrome was the defect, not the navigation, so deep links keep
* working. `InternalFormRoute` owns the chrome + the "land on the
* created record" target; the public path above stays chrome-less
* on purpose (an anonymous visitor has no console to be in). */}
<Route path="/forms/:name" element={
<ProtectedRoute>
<FormPage mode="internal" />
<InternalFormRoute />
</ProtectedRoute>
} />
{/* Package documentation (ADR-0046): a platform-level portal
Expand Down
181 changes: 181 additions & 0 deletions apps/console/src/__tests__/internalFormShell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#4109 — `/forms/:name` (mode=internal) renders INSIDE the console
* shell; `/f/:slug` (mode=public) stays chrome-less.
*
* Maintainer ruling, 2026-08-10, quoted verbatim from objectstack#7245:
*
* > 1. `/forms/:name` in `mode="internal"` nests inside the console shell (keep
* > the route — deep-linking survives; the missing chrome is the defect, not
* > the navigation).
*
* ## What this measures, and why it renders the REAL App
*
* The deliverable is a property of `App.tsx`'s route table — which element each
* of the two form routes mounts — so this file renders that table rather than a
* transcription of it. A hand-copied route list would be free to agree with
* whatever the source does, which is the trap
* `AppContent.systemHubRoutes.test.tsx` documents at length for the same
* reason. Everything App.tsx imports but this card does not touch is stubbed
* (auth surfaces, docs, dev harnesses, the per-app SPA) so the table itself is
* the only thing under test.
*
* The console chrome is stubbed to a MARKER, following the same precedent
* (`AppContent.systemHubRoutes.test.tsx` stubs `ConsoleLayout` to a
* `data-testid` div): what is being pinned here is that the internal form is
* nested inside the shell layout at all, not how `HomeLayout` paints — that
* belongs to `@object-ui/app-shell`'s own tests.
*
* ## Reverse verification
*
* Both tests are change-detectors, in opposite directions. Restoring the
* pre-fix element (`<FormPage mode="internal" />` bare, a sibling of the
* app-shell routes) turns the first test red — the marker never renders — and
* leaves the second green. Wrapping the PUBLIC route in the same chrome turns
* the second red and leaves the first green. So neither can pass by accident
* for the other one's reason.
*/

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';

// `vi.hoisted`, not plain consts: every `vi.mock` factory below is hoisted to
// the top of the file, so a factory closing over an ordinary `const` reads it
// before initialization.
const { passthrough, stub } = vi.hoisted(() => ({
passthrough: ({ children }: { children?: ReactNode }) => <>{children}</>,
stub: (testid: string) => () => <div data-testid={testid} />,
}));

// ── The console chrome, stubbed to a marker ──────────────────────────────
// `DefaultHomeLayout` is the console's layout for app-INDEPENDENT authed pages
// (`/home`, `/organizations` — and now the internal form). Its presence IS the
// "nested inside the console shell" assertion.
vi.mock('@object-ui/app-shell', () => ({
ConsoleShell: passthrough,
ConsoleToaster: () => null,
RequireAiSurface: passthrough,
SystemRedirect: () => null,
DefaultHomeLayout: ({ children }: { children?: ReactNode }) => (
<div data-testid="console-shell-layout">{children}</div>
),
DefaultHomePage: stub('home-page'),
DefaultOrganizationsLayout: passthrough,
DefaultOrganizationsPage: stub('organizations-page'),
DefaultOrganizationLayout: stub('organization-layout'),
DefaultMembersPage: stub('members-page'),
DefaultInvitationsPage: stub('invitations-page'),
DefaultSettingsPage: stub('settings-page'),
DefaultAcceptInvitationPage: stub('accept-invitation-page'),
DefaultAiChatPage: stub('ai-chat-page'),
StudioDesignSurface: stub('studio-design-surface'),
BuilderLanding: stub('builder-landing'),
getProductName: () => 'ObjectOS',
getFaviconUrl: () => '',
// Consumed by InternalFormRoute to resolve the created-record target.
useMetadata: () => ({ apps: [{ name: 'showcase_app', _packageId: 'ai.objectstack.showcase' }] }),
useNavigationContext: () => ({ currentAppName: 'showcase_app' }),
}));

vi.mock('@object-ui/auth', () => ({
AuthProvider: passthrough,
useAuth: () => ({ user: { id: 'u1' } }),
}));

// ── Everything else App.tsx imports but this card does not touch ─────────
vi.mock('../components/ProtectedRoute', () => ({ ProtectedRoute: passthrough }));
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') }));
vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') }));
vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null }));
vi.mock('../AppContent', () => ({ AppContent: stub('app-content') }));
vi.mock('../pages/SharedRecordPage', () => ({ default: stub('shared-record-page') }));
vi.mock('../pages/DocPage', () => ({ default: stub('doc-page') }));
vi.mock('../pages/DocsIndex', () => ({ default: stub('docs-index') }));
vi.mock('../pages/DocsSlug', () => ({ default: stub('docs-slug') }));
vi.mock('../pages/DocsLayout', () => ({ default: stub('docs-layout') }));
vi.mock('../pages/auth/LoginPage', () => ({ LoginPage: stub('login-page') }));
vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: stub('register-page') }));
vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: stub('forgot-page') }));
vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: stub('reset-page') }));
vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: stub('set-password-page') }));
vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: stub('verify-email-page') }));
vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: stub('verify-prompt-page') }));
vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: stub('oauth-consent-page') }));
vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: stub('device-auth-page') }));
vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: stub('dev-master-detail') }));
vi.mock('../dev/DevLists', () => ({ DevLists: stub('dev-lists') }));
vi.mock('../dev/DevModal', () => ({ DevModal: stub('dev-modal') }));
vi.mock('../dev/DevLookup', () => ({ DevLookup: stub('dev-lookup') }));
vi.mock('../dev/DevRowActions', () => ({ DevRowActions: stub('dev-row-actions') }));

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

import { App } from '../App';

/** The FormView + object metadata both form modes resolve their spec from. */
const INTERNAL_VIEW = {
name: 'showcase_task.edit',
object: 'showcase_task',
viewKind: 'form',
label: 'Log Time',
config: { type: 'simple', sections: [{ fields: ['title'] }] },
};
const OBJECT_SCHEMA = { name: 'showcase_task', fields: { title: { type: 'text', label: 'Title' } } };
const PUBLIC_FORM = {
slug: 'contact-us',
object: 'showcase_inquiry',
label: 'Contact us',
form: { type: 'simple', sections: [{ fields: ['title'] }] },
objectSchema: { name: 'showcase_inquiry', fields: { title: { type: 'text', label: 'Title' } } },
};

beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
const body = String(url).includes('/meta/view/')
? INTERNAL_VIEW
: String(url).includes('/meta/object/')
? OBJECT_SCHEMA
: PUBLIC_FORM;
return {
ok: true,
status: 200,
statusText: 'OK',
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response;
}),
);
});

afterEach(() => {
vi.unstubAllGlobals();
window.history.pushState({}, '', '/');
});

describe('form routes and the console shell (objectui#4109)', () => {
it('nests the internal /forms/:name inside the console shell layout', async () => {
window.history.pushState({}, '', '/forms/showcase_task.edit');
render(<App />);

// The form itself resolved and rendered…
expect(await screen.findByLabelText(/Title/)).toBeInTheDocument();
// …inside the shell chrome, which is the whole point of the ruling.
const shell = screen.getByTestId('console-shell-layout');
expect(shell).toBeInTheDocument();
expect(shell).toContainElement(screen.getByLabelText(/Title/));
});

it('keeps the public /f/:slug chrome-less', async () => {
window.history.pushState({}, '', '/f/contact-us');
render(<App />);

// Same renderer, same form…
expect(await screen.findByLabelText(/Title/)).toBeInTheDocument();
// …but an anonymous visitor has no console to be inside.
expect(screen.queryByTestId('console-shell-layout')).not.toBeInTheDocument();
});
});
Loading
Loading