Skip to content

Commit 8722166

Browse files
os-zhuangCopilot
andcommitted
feat(public-form): route lookup picker through scoped per-form endpoint
Public forms now resolve lookup options through `GET /api/v1/forms/:slug/lookup/:field` instead of the global data endpoint. The page builds a referenceTo→fieldName map from the form spec; lookups for objects outside that map short-circuit to an empty result, matching Airtable's per-form-scoped picker design. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 99c9955 commit 8722166

1 file changed

Lines changed: 57 additions & 10 deletions

File tree

apps/console/src/pages/PublicFormPage.tsx

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const SERVER_URL = import.meta.env.VITE_SERVER_URL || '';
3434
* Resolve a public form spec by slug. Tries the canonical endpoint first
3535
* and falls back to a client-side scan of the `view` metadata index.
3636
*/
37-
async function resolvePublicForm(slug: string): Promise<{ config: EmbeddableFormConfig; schema: any | null } | null> {
37+
async function resolvePublicForm(slug: string): Promise<{ config: EmbeddableFormConfig; schema: any | null; lookupFieldMap: Record<string, string> } | null> {
3838
const publicLink = `/forms/${slug}`;
3939

4040
// 1. Canonical endpoint — when the backend implements it, this becomes
@@ -47,7 +47,7 @@ async function resolvePublicForm(slug: string): Promise<{ config: EmbeddableForm
4747
const spec = await res.json();
4848
const config = mapViewSpecToEmbeddableConfig(spec, slug);
4949
if (!config) return null;
50-
return { config, schema: spec?.objectSchema ?? null };
50+
return { config, schema: spec?.objectSchema ?? null, lookupFieldMap: buildLookupFieldMap(spec) };
5151
}
5252
} catch {
5353
// network error — fall through to the discovery fallback
@@ -81,7 +81,7 @@ async function resolvePublicForm(slug: string): Promise<{ config: EmbeddableForm
8181
if (!match) return null;
8282
const config = mapViewSpecToEmbeddableConfig(match, slug);
8383
if (!config) return null;
84-
return { config, schema: null };
84+
return { config, schema: null, lookupFieldMap: buildLookupFieldMap(match) };
8585
} catch {
8686
return null;
8787
}
@@ -129,12 +129,42 @@ function mapViewSpecToEmbeddableConfig(
129129
};
130130
}
131131

132+
/**
133+
* Map each lookup field surviving the framework's strip step to its
134+
* `referenceTo` target. The framework's `/forms/:slug` handler drops any
135+
* lookup field that doesn't carry an explicit `publicPicker` config, so
136+
* any entry remaining in the map is guaranteed safe to route to the
137+
* scoped picker endpoint.
138+
*/
139+
function buildLookupFieldMap(spec: any): Record<string, string> {
140+
const formView = spec?.__matchedFormView ?? spec?.form ?? spec;
141+
const objectFields = spec?.objectSchema?.fields ?? {};
142+
const map: Record<string, string> = {};
143+
for (const section of formView?.sections ?? []) {
144+
for (const f of section?.fields ?? []) {
145+
const name = typeof f === 'string' ? f : f?.field;
146+
if (!name) continue;
147+
const def = objectFields[name];
148+
if (!def) continue;
149+
if (def.type !== 'lookup' && def.type !== 'master_detail') continue;
150+
const target = def.referenceTo ?? def.target ?? def.options?.objectName;
151+
if (target) map[target] = name;
152+
}
153+
}
154+
return map;
155+
}
156+
132157
/**
133158
* Anonymous data source — posts to the public submit endpoint (preferred)
134159
* and falls back to the legacy data endpoint. No auth header is attached.
135-
* Only the `create` op is implemented; the embeddable form never reads.
160+
* `find()` is routed through the per-field scoped picker endpoint when the
161+
* caller is asking about an object referenced by a public-picker lookup.
136162
*/
137-
function createPublicDataSource(slug: string, schema: any | null): DataSource {
163+
function createPublicDataSource(
164+
slug: string,
165+
schema: any | null,
166+
lookupFieldMap: Record<string, string> = {},
167+
): DataSource {
138168
const post = async (objectName: string, data: Record<string, unknown>) => {
139169
// 1. Preferred public endpoint
140170
const submitRes = await fetch(
@@ -168,15 +198,29 @@ function createPublicDataSource(slug: string, schema: any | null): DataSource {
168198
throw new Error(`Submission failed (${submitRes.status}). Please try again.`);
169199
};
170200

171-
// The EmbeddableForm only calls `.create(...)`. Stubs for the rest of
172-
// the DataSource interface keep TypeScript happy without giving guests
173-
// any read/edit/delete capability.
201+
// Scoped lookup picker — called by LookupField for an opt-in
202+
// `publicPicker`-enabled field. Routes to the per-field endpoint that
203+
// enforces the form's allowlist + filter + display-field projection.
204+
// For any other object the search returns empty so a stray lookup
205+
// widget in the form can never enumerate unrelated tables.
206+
const find = async (objectName: string, params: any) => {
207+
const fieldName = lookupFieldMap[objectName];
208+
if (!fieldName) return { data: [], total: 0 };
209+
const q = String(params?.search ?? params?.q ?? '').trim();
210+
const url = `${SERVER_URL}/api/v1/forms/${encodeURIComponent(slug)}/lookup/${encodeURIComponent(fieldName)}${q ? `?q=${encodeURIComponent(q)}` : ''}`;
211+
const res = await fetch(url, { headers: { Accept: 'application/json' } });
212+
if (!res.ok) return { data: [], total: 0 };
213+
const body: any = await res.json().catch(() => null);
214+
const rows: any[] = Array.isArray(body?.data) ? body.data : [];
215+
return { data: rows, total: typeof body?.total === 'number' ? body.total : rows.length };
216+
};
217+
174218
return {
175219
create: post,
176220
update: () => Promise.reject(new Error('Not permitted on public form')),
177221
delete: () => Promise.reject(new Error('Not permitted on public form')),
178222
findOne: () => Promise.resolve(null),
179-
find: () => Promise.resolve({ data: [], total: 0 }),
223+
find,
180224
// EmbeddableForm calls getObjectSchema() to look up field types and
181225
// labels. The schema is embedded in the public-form resolver response
182226
// so no auth-protected meta call is required. Return a safe stub when
@@ -209,6 +253,7 @@ export function PublicFormPage() {
209253
const { t } = useObjectTranslation();
210254
const [config, setConfig] = useState<EmbeddableFormConfig | null>(null);
211255
const [schema, setSchema] = useState<any | null>(null);
256+
const [lookupFieldMap, setLookupFieldMap] = useState<Record<string, string>>({});
212257
const [error, setError] = useState<string | null>(null);
213258
const [loading, setLoading] = useState(true);
214259
const [isDemo, setIsDemo] = useState(false);
@@ -242,6 +287,7 @@ export function PublicFormPage() {
242287
setError(null);
243288
setConfig(null);
244289
setSchema(null);
290+
setLookupFieldMap({});
245291

246292
// Dev-only fallback: when no backend is reachable, the
247293
// `?demo=1` query param renders a hardcoded CRM web-to-lead form so
@@ -267,6 +313,7 @@ export function PublicFormPage() {
267313
} else {
268314
setConfig(result.config);
269315
setSchema(result.schema);
316+
setLookupFieldMap(result.lookupFieldMap ?? {});
270317
}
271318
})
272319
.catch((err) => {
@@ -320,7 +367,7 @@ export function PublicFormPage() {
320367
return (
321368
<EmbeddableForm
322369
config={{ ...config, texts: { ...texts, ...config.texts } }}
323-
dataSource={isDemo ? createDemoDataSource() : createPublicDataSource(slug, schema)}
370+
dataSource={isDemo ? createDemoDataSource() : createPublicDataSource(slug, schema, lookupFieldMap)}
324371
/>
325372
);
326373
}

0 commit comments

Comments
 (0)