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
9 changes: 9 additions & 0 deletions .changeset/11769-public-form-url-basename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/console': patch
---

The developer Public Forms page offers each public form's real anonymous URL (objectui#11769).

The page built the link, the copied URL, the iframe snippet and the React snippet as `ORIGIN/console/f/SLUG`, and printed `/console/f/` beside both slug fields. No host serves the console at `/console/`: the framework CLI and cloud mount it at `/_console/`, and a framework-served console answers `/console/f/SLUG` with a 404. The page now asks the console router where its anonymous route `/f/:slug` is served, so it offers `ORIGIN/_console/f/SLUG` under a `/_console` mount and `ORIGIN/f/SLUG` on a root-mounted console. The link, the copied URL, both snippets and both slug-field prefixes show the same address.

Nothing is added to the package entry: no export, prop, type member or language-pack key.
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
// The page asks the router where the anonymous route is served (objectui#11769),
// so it renders inside one, as it does in the app.
import { MemoryRouter } from 'react-router-dom';

/**
* Two published public forms — one declaring an `object`, one not. Both need
Expand Down Expand Up @@ -101,7 +104,7 @@ const emptyIn = (el: HTMLElement): HTMLElement | null =>
el.querySelector('[data-slot="empty-value"]');

async function mount() {
const { container } = render(<PublicFormsPage />);
const { container } = render(<PublicFormsPage />, { wrapper: MemoryRouter });
// `queryByText` THROWS on multiple matches, and the Name cell prints the
// label and the name — so a single-match query never resolves here.
await waitFor(() =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The Public Forms page offers the anonymous URL the console is served at
* (objectui#11769).
*
* ## The defect
*
* The page built every anonymous URL as `${origin}/console/f/SLUG`: the copied
* URL, the iframe snippet, the React snippet, and the `/console/f/` prefix
* printed beside both slug fields. No host serves the console at `/console/`.
* The framework CLI and cloud mount it at `/_console/` and inject
* `<base href="/_console/">`, which `App.tsx` turns into the router's basename.
* The anonymous route is `/f/:slug` under that basename. A framework-served
* console (`@objectstack/cli` 17.7.0 `serve`) answers `GET /console/f/SLUG`
* with a 404 `ENDPOINT_NOT_FOUND`, and `GET /_console/f/SLUG` with the
* console's HTML. That was measured once, for this card. Nothing here
* re-derives it.
*
* ## What these pins hold
*
* The page asks the router where `/f` lives. So the URL follows the basename it
* renders under, and every surface that shows it shows the same value:
*
* - under `/_console`, the page offers `ORIGIN/_console/f/SLUG`;
* - on a root-mounted console it offers `ORIGIN/f/SLUG`;
* - nested under the metadata-driven entry
* (`/apps/APP/component/developer/public-forms`), the URL does not take on
* that route's depth;
* - the link, the copied URL, the URL inside both snippets and the two
* slug-field prefixes agree.
*
* The router is `BrowserRouter`, as in `App.tsx`, so `useHref` resolves through
* the same navigator the shipped app uses.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, within, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

/** One published public form, and one FormView not yet published (so the
* Publish dialog, which needs a candidate, can be opened). */
const { ADAPTER } = vi.hoisted(() => {
const form = (name: string, sharing?: Record<string, unknown>) => ({
spec: {
name,
label: name,
object: 'lead',
type: 'simple',
sections: [{ label: 'Lead', fields: ['email'] }],
...(sharing ? { sharing } : {}),
},
});
// A STABLE singleton: a fresh object per render loops the page's load effect.
const ADAPTER = {
getClient: () => ({
meta: {
getItems: async (type: string) =>
type === 'view'
? [
form('lead_contact_us', {
enabled: true,
allowAnonymous: true,
publicLink: '/forms/contact-us',
}),
form('lead_internal'),
]
: [],
saveItem: async () => ({ ok: true }),
},
}),
};
return { ADAPTER };
});

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => ADAPTER,
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

// Imported AFTER the mocks so the page picks them up.
import { PublicFormsPage } from './PublicFormsPage';

const SLUG = 'contact-us';

/**
* Where the console is mounted. `basename` is what `App.tsx` resolves from the
* injected `<base href>`; `at` is the route the page is reached through.
*/
const MOUNTS = [
{
name: 'a /_console mount (framework CLI and cloud)',
basename: '/_console',
at: '/developer/public-forms',
nested: false,
path: '/_console/f/contact-us',
prefix: '/_console/f/',
},
{
name: 'a root-mounted console',
basename: '/',
at: '/developer/public-forms',
nested: false,
path: '/f/contact-us',
prefix: '/f/',
},
{
name: 'a /_console mount, reached through the metadata-driven component route',
basename: '/_console',
at: '/apps/crm/component/developer/public-forms',
nested: true,
path: '/_console/f/contact-us',
prefix: '/_console/f/',
},
] as const;

type Mount = (typeof MOUNTS)[number];

let writeText: ReturnType<typeof vi.fn>;

beforeEach(() => {
writeText = vi.fn(async () => undefined);
Object.defineProperty(window.navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
});

afterEach(() => {
cleanup();
window.history.replaceState({}, '', '/');
});

async function renderUnder(mount: Mount) {
const entry = `${mount.basename === '/' ? '' : mount.basename}${mount.at}`;
window.history.replaceState({}, '', entry);
render(
<BrowserRouter basename={mount.basename}>
{mount.nested ? (
<Routes>
<Route path="/apps/:app/component/*" element={<PublicFormsPage />} />
</Routes>
) : (
<PublicFormsPage />
)}
</BrowserRouter>,
);
// The row exists once the listing has loaded.
await screen.findByRole('button', { name: /Edit sharing & post-submit behavior/i });
}

/** Click one copy action and return exactly what it put on the clipboard. */
async function copiedBy(title: string): Promise<string> {
const before = writeText.mock.calls.length;
fireEvent.click(screen.getByTitle(title));
await waitFor(() => expect(writeText.mock.calls.length).toBe(before + 1));
return writeText.mock.calls[before][0] as string;
}

/** The prefix printed beside the open dialog's slug field. */
function slugFieldPrefix(): string {
const field = screen.getByLabelText('URL slug');
return (field.parentElement?.textContent ?? '').trim();
}

describe.each(MOUNTS)('Public Forms URL under $name (objectui#11769)', (mount) => {
it('offers ORIGIN + basename + /f/SLUG as the link and the copied URL', async () => {
await renderUnder(mount);
const expected = `${window.location.origin}${mount.path}`;

const link = screen.getByText(expected).closest('a');
expect(link).not.toBeNull();
expect(link).toHaveAttribute('href', expected);

const url = await copiedBy('Copy URL');
expect(url).toBe(expected);
expect(new URL(url).pathname).toBe(mount.path);
});

it('puts exactly the copied URL inside both snippets', async () => {
await renderUnder(mount);
const url = await copiedBy('Copy URL');

const iframe = await copiedBy('Copy <iframe> embed');
expect([...iframe.matchAll(/src="([^"]*)"/g)].map((m) => m[1])).toEqual([url]);

const react = await copiedBy('Copy React snippet');
expect([...react.matchAll(/src=\{`([^`]*)`\}/g)].map((m) => m[1])).toEqual([url]);
});

it('prints the same basename-prefixed route beside both slug fields', async () => {
await renderUnder(mount);

fireEvent.click(screen.getByRole('button', { name: /Publish form/i }));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(slugFieldPrefix()).toBe(mount.prefix);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());

fireEvent.click(screen.getByRole('button', { name: /Edit sharing & post-submit behavior/i }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByLabelText('URL slug')).toHaveValue(SLUG);
expect(slugFieldPrefix()).toBe(mount.prefix);
});

it('shows no /console/f/ address anywhere on the page', async () => {
await renderUnder(mount);
// `/_console/f/` does not contain this substring: the character before
// `console` there is `_`, not `/`.
expect(document.body.textContent ?? '').not.toContain('/console/f/');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// The page asks the router where the anonymous route is served (objectui#11769),
// so it renders inside one, as it does in the app.
import { MemoryRouter } from 'react-router-dom';
import { FormViewSchema } from '@objectstack/spec/ui';

/**
Expand Down Expand Up @@ -118,7 +121,7 @@ function setUrl(field: HTMLElement, value: string) {

/** Open the row's editor and switch the post-submit behavior to `redirect`. */
async function openRedirectEditor(user: ReturnType<typeof userEvent.setup>) {
render(<PublicFormsPage />);
render(<PublicFormsPage />, { wrapper: MemoryRouter });
await user.click(
await screen.findByRole('button', { name: /Edit sharing & post-submit behavior/i }),
);
Expand Down Expand Up @@ -231,7 +234,7 @@ describe('in-contract values still save (#4990)', () => {
*/
it('leaves thank-you title/message unvalidated — the spec declares them free text', async () => {
const user = userEvent.setup();
render(<PublicFormsPage />);
render(<PublicFormsPage />, { wrapper: MemoryRouter });
await user.click(
await screen.findByRole('button', { name: /Edit sharing & post-submit behavior/i }),
);
Expand Down
25 changes: 22 additions & 3 deletions apps/console/src/pages/developer/PublicFormsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,22 @@
* `thank-you`'s `title` / `message` stay unvalidated deliberately: the spec
* declares both as free-form strings, so there is no contract for a door to
* state.
*
* ## The anonymous URL is the router's answer, not a hard-coded mount
*
* The anonymous route is `/f/:slug` UNDER the console router's basename (the
* `<Route path="/f/:slug">` in `App.tsx`, inside `BrowserRouter
* basename={BASENAME}`), and that basename comes from the host's injected
* `<base href>`: `/_console` on every framework- and cloud-served mount, `/`
* on a standalone run. This page used to print `${origin}/console/f/SLUG`, a
* mount no host serves (objectui#11769). It now asks the router where `/f`
* lives (`useHref`, which prefixes the basename), so the copied URL, both
* snippets and the two slug-field prefixes are one value, and they follow the
* mount the page is actually running under.
*/

import { useEffect, useState } from 'react';
import { useHref } from 'react-router-dom';
import { useAdapter } from '@object-ui/app-shell';
import {
Card,
Expand Down Expand Up @@ -88,6 +101,9 @@ function sanitizeSlug(s: string): string {
return s.trim().toLowerCase().replace(/[^a-z0-9-_]+/g, '-').replace(/^-+|-+$/g, '');
}

/** The anonymous form route's prefix, as `App.tsx` declares it (`/f/:slug`). */
const PUBLIC_FORM_ROUTE = '/f';

export function PublicFormsPage() {
const adapter = useAdapter();
const client: any = adapter?.getClient?.();
Expand Down Expand Up @@ -190,8 +206,11 @@ export function PublicFormsPage() {
useEffect(() => { load(); }, []);

const origin = typeof window !== 'undefined' ? window.location.origin : '';
// Basename-prefixed by the router itself: `/_console/f` under a `/_console`
// mount, `/f` on a root-mounted console. Every URL below is built from it.
const publicFormPath = useHref(PUBLIC_FORM_ROUTE);

const formatPublicUrl = (slug: string) => `${origin}/console/f/${slug}`;
const formatPublicUrl = (slug: string) => `${origin}${publicFormPath}/${slug}`;
const formatIframe = (slug: string) =>
`<iframe src="${formatPublicUrl(slug)}" width="100%" height="640" frameborder="0" style="border:0;"></iframe>`;
const formatReact = (slug: string) =>
Expand Down Expand Up @@ -476,7 +495,7 @@ export function PublicFormsPage() {
<div className="space-y-1.5">
<Label htmlFor="publish-slug">URL slug</Label>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">/console/f/</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">{`${publicFormPath}/`}</span>
<Input
id="publish-slug"
placeholder="contact-us"
Expand Down Expand Up @@ -514,7 +533,7 @@ export function PublicFormsPage() {
<div className="space-y-1.5">
<Label htmlFor="edit-slug">URL slug</Label>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">/console/f/</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">{`${publicFormPath}/`}</span>
<Input
id="edit-slug"
value={editSlug}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
// The page asks the router where the anonymous route is served (objectui#11769),
// so it renders inside one, as it does in the app.
import { MemoryRouter } from 'react-router-dom';

/**
* One mutable answer, read by BOTH pages' `meta.getItems`. A stable singleton
Expand Down Expand Up @@ -140,23 +143,23 @@ describe('FlowRunsPage — meta.getItems envelope (objectui#6917)', () => {
describe('PublicFormsPage — meta.getItems envelope (objectui#6917)', () => {
it("still reads the envelope's `items` member", async () => {
state.answer = { items: [FORM] };
render(<PublicFormsPage />);
render(<PublicFormsPage />, { wrapper: MemoryRouter });
await waitFor(() => {
expect(screen.getByText('Log Time')).toBeInTheDocument();
});
});

it('still reads a bare array', async () => {
state.answer = [FORM];
render(<PublicFormsPage />);
render(<PublicFormsPage />, { wrapper: MemoryRouter });
await waitFor(() => {
expect(screen.getByText('Log Time')).toBeInTheDocument();
});
});

it('does NOT read `value` — not a member of this envelope', async () => {
state.answer = { value: [FORM] };
render(<PublicFormsPage />);
render(<PublicFormsPage />, { wrapper: MemoryRouter });
await waitFor(() => {
expect(screen.getByText('No public forms yet')).toBeInTheDocument();
});
Expand Down
Loading