From b5421c70ba09982fcf7261bd41cd48c7eb634f5b Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 24 Mar 2026 09:24:57 -1000 Subject: [PATCH 01/24] Upstream reference-aware generated UI --- .../app/[collection]/ClientPage.tsx | 2 +- .../app/[collection]/edit/ClientPage.tsx | 17 +- .../app/[collection]/new/ClientPage.tsx | 17 +- .../app/[collection]/view/ClientPage.tsx | 28 ++- .../src/components/RecordCard.tsx | 24 ++- .../src/components/ReferenceFieldInput.tsx | 172 ++++++++++++++++++ .../src/components/ResolvedReferenceValue.tsx | 97 ++++++++++ .../templates/next-export-ui/src/lib/ths.ts | 20 +- test/testCliGenerateUi.js | 75 ++++++++ 9 files changed, 441 insertions(+), 11 deletions(-) create mode 100644 packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx create mode 100644 packages/templates/next-export-ui/src/components/ResolvedReferenceValue.tsx diff --git a/packages/templates/next-export-ui/app/[collection]/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/ClientPage.tsx index cb6cd00..9e28011 100644 --- a/packages/templates/next-export-ui/app/[collection]/ClientPage.tsx +++ b/packages/templates/next-export-ui/app/[collection]/ClientPage.tsx @@ -244,7 +244,7 @@ function CollectionListModePage(props: { params: { collection: string } }) {
{records.map((r, idx) => ( - + ))}
diff --git a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx index dae157f..ba6a1b1 100644 --- a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx +++ b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx @@ -13,9 +13,10 @@ import { getCollection, mutableFields, type ThsCollection, type ThsField } from import { submitWriteTx } from '../../../src/lib/tx'; import TxStatus, { type TxPhase } from '../../../src/components/TxStatus'; import ImageFieldInput from '../../../src/components/ImageFieldInput'; +import ReferenceFieldInput from '../../../src/components/ReferenceFieldInput'; function inputType(field: ThsField): 'text' | 'number' { - if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal' || field.type === 'reference') return 'number'; + if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal') return 'number'; return 'text'; } @@ -315,13 +316,25 @@ export default function EditRecordPage(props: { params: { collection: string } } disabled={txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'} onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))} /> + ) : f.type === 'reference' ? ( + setForm((prev) => ({ ...prev, [f.name]: next }))} + /> ) : ( setForm((prev) => ({ ...prev, [f.name]: e.target.value }))} - placeholder={f.type === 'reference' ? 'record id (uint256)' : f.type} + placeholder={f.type} /> )} diff --git a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx index 2a5a17e..5a3e502 100644 --- a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx +++ b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx @@ -13,9 +13,10 @@ import { createFields, getCollection, hasCreatePayment, requiredFieldNames, type import { submitWriteTx } from '../../../src/lib/tx'; import TxStatus, { type TxPhase } from '../../../src/components/TxStatus'; import ImageFieldInput from '../../../src/components/ImageFieldInput'; +import ReferenceFieldInput from '../../../src/components/ReferenceFieldInput'; function inputType(field: ThsField): 'text' | 'number' { - if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal' || field.type === 'reference') return 'number'; + if (field.type === 'uint256' || field.type === 'int256' || field.type === 'decimal') return 'number'; return 'text'; } @@ -210,13 +211,25 @@ export default function CreateRecordPage(props: { params: { collection: string } value={form[f.name] ?? ''} onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))} /> + ) : f.type === 'reference' ? ( + setForm((prev) => ({ ...prev, [f.name]: next }))} + /> ) : ( setForm((prev) => ({ ...prev, [f.name]: e.target.value }))} - placeholder={f.type === 'reference' ? 'record id (uint256)' : f.type} + placeholder={f.type} /> )} diff --git a/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx index c28c60f..890004b 100644 --- a/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx +++ b/packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx @@ -12,6 +12,7 @@ import { fetchManifest, getPrimaryDeployment, getReadRpcUrl } from '../../../src import { fieldLinkUi, getCollection, transferEnabled, type ThsCollection, type ThsField } from '../../../src/lib/ths'; import { submitWriteTx } from '../../../src/lib/tx'; import TxStatus, { type TxPhase } from '../../../src/components/TxStatus'; +import ResolvedReferenceValue from '../../../src/components/ResolvedReferenceValue'; function getValue(record: any, key: string, fallbackIndex?: number): any { if (record && typeof record === 'object' && key in record) { @@ -28,7 +29,16 @@ function fieldIndex(collection: ThsCollection, field: ThsField): number { return 9 + Math.max(0, idx); } -function renderFieldValue(field: ThsField, rendered: string) { +function renderFieldValue(args: { + collection: ThsCollection; + field: ThsField; + rendered: string; + raw: unknown; + abi: any[] | null; + publicClient: any | null; + address: `0x${string}` | undefined; +}) { + const { collection, field, rendered, raw, abi, publicClient, address } = args; if (!rendered) return ; const linkUi = fieldLinkUi(field); @@ -45,6 +55,20 @@ function renderFieldValue(field: ThsField, rendered: string) { ); } + if (field.type === 'reference') { + return ( + + ); + } + return {rendered}; } @@ -331,7 +355,7 @@ export default function ViewRecordPage(props: { params: { collection: string } } return (
{f.name}
-
{renderFieldValue(f, rendered)}
+
{renderFieldValue({ collection, field: f, rendered, raw: v, abi, publicClient, address: appAddress })}
); })} diff --git a/packages/templates/next-export-ui/src/components/RecordCard.tsx b/packages/templates/next-export-ui/src/components/RecordCard.tsx index 36c4754..3b6f4d5 100644 --- a/packages/templates/next-export-ui/src/components/RecordCard.tsx +++ b/packages/templates/next-export-ui/src/components/RecordCard.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import type { ThsCollection, ThsField } from '../lib/ths'; import { displayField, fieldLinkUi } from '../lib/ths'; import { formatFieldValue, shortAddress } from '../lib/format'; +import ResolvedReferenceValue from './ResolvedReferenceValue'; function getValue(record: any, key: string, fallbackIndex?: number): any { if (record && typeof record === 'object' && key in record) { @@ -21,8 +22,14 @@ function fieldIndex(collection: ThsCollection, field: ThsField): number { return 9 + Math.max(0, idx); } -export default function RecordCard(props: { collection: ThsCollection; record: any }) { - const { collection, record } = props; +export default function RecordCard(props: { + collection: ThsCollection; + record: any; + abi?: any[] | null; + publicClient?: any | null; + address?: `0x${string}`; +}) { + const { collection, record, abi = null, publicClient = null, address } = props; const id = getValue(record, 'id', 0); const owner = getValue(record, 'owner', 3); const createdBy = getValue(record, 'createdBy', 2); @@ -44,11 +51,12 @@ export default function RecordCard(props: { collection: ThsCollection; record: a return { name: field.name, type: field.type, + raw, value: formatFieldValue(raw, field.type, (field as any).decimals, field.name) }; }) .filter(Boolean) - .slice(0, 3) as Array<{ name: string; type: string; value: string }>; + .slice(0, 3) as Array<{ name: string; type: string; raw: unknown; value: string }>; return (
@@ -79,6 +87,16 @@ export default function RecordCard(props: { collection: ThsCollection; record: a {field.type === 'image' ? ( // eslint-disable-next-line @next/next/no-img-element {field.name} + ) : field.type === 'reference' ? ( + candidate.name === field.name) ?? { name: field.name, type: 'reference' }} + value={field.raw} + abi={abi} + publicClient={publicClient} + address={address} + fallback={field.value} + /> ) : ( field.value )} diff --git a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx new file mode 100644 index 0000000..66e1a4e --- /dev/null +++ b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx @@ -0,0 +1,172 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; + +import { listAllRecords } from '../lib/runtime'; +import { formatFieldValue } from '../lib/format'; +import { displayField, getRelatedCollection, type ThsCollection, type ThsField } from '../lib/ths'; + +type ReferenceOption = { + id: bigint; + label: string; + owned: boolean; +}; + +function getRecordValue(record: any, key: string, fallbackIndex?: number): any { + if (record && typeof record === 'object' && key in record) { + return (record as any)[key]; + } + if (Array.isArray(record) && typeof fallbackIndex === 'number') { + return record[fallbackIndex]; + } + return undefined; +} + +function fieldIndex(collection: ThsCollection, field: ThsField): number { + const idx = (collection.fields as any[]).findIndex((candidate) => candidate && candidate.name === field.name); + return 9 + Math.max(0, idx); +} + +function recordLabel(collection: ThsCollection, id: bigint, record: any): string { + const display = displayField(collection); + if (!display) return `${collection.name} #${String(id)}`; + const raw = getRecordValue(record, display.name, fieldIndex(collection, display)); + const rendered = formatFieldValue(raw, display.type, display.decimals, display.name).trim(); + if (!rendered) return `${collection.name} #${String(id)}`; + return `${rendered} (#${String(id)})`; +} + +export default function ReferenceFieldInput(props: { + manifest: any; + publicClient: any; + abi: any[]; + address: `0x${string}`; + collection: ThsCollection; + field: ThsField; + value: string; + disabled?: boolean; + onChange: (next: string) => void; +}) { + const { manifest, publicClient, abi, address, collection, field, value, disabled, onChange } = props; + const relatedCollection = useMemo(() => getRelatedCollection(collection, field.name), [collection, field.name]); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [options, setOptions] = useState([]); + + useEffect(() => { + let cancelled = false; + + async function loadOptions() { + if (!relatedCollection || !publicClient || !abi || !address) { + setOptions([]); + setError(null); + return; + } + + setLoading(true); + setError(null); + try { + const walletAccount = + typeof window !== 'undefined' ? window.localStorage.getItem('TH_ACCOUNT')?.toLowerCase() ?? '' : ''; + const page = await listAllRecords({ + publicClient, + abi, + address, + collectionName: relatedCollection.name, + manifest + }); + + const nextOptions = page.ids + .map((id, index) => { + const record = page.records[index]; + if (!record) return null; + const owner = String(getRecordValue(record, 'owner', 3) ?? '').toLowerCase(); + return { + id, + label: recordLabel(relatedCollection, id, record), + owned: Boolean(walletAccount) && owner === walletAccount + }; + }) + .filter(Boolean) as ReferenceOption[]; + + nextOptions.sort((left, right) => { + if (left.owned !== right.owned) return left.owned ? -1 : 1; + if (left.label !== right.label) return left.label.localeCompare(right.label); + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; + }); + + if (cancelled) return; + setOptions(nextOptions); + + const ownedOptions = nextOptions.filter((option) => option.owned); + if (!value && ownedOptions.length === 1) { + onChange(String(ownedOptions[0]?.id ?? '')); + } + } catch (cause: any) { + if (cancelled) return; + setOptions([]); + setError(String(cause?.message ?? cause)); + } finally { + if (!cancelled) setLoading(false); + } + } + + void loadOptions(); + + return () => { + cancelled = true; + }; + }, [abi, address, collection, field.name, manifest, onChange, publicClient, relatedCollection, value]); + + const resolvedValue = value.trim(); + const hasResolvedValue = resolvedValue !== '' && options.some((option) => String(option.id) === resolvedValue); + + if (!relatedCollection) { + return ( + onChange(event.target.value)} + placeholder="record id (uint256)" + /> + ); + } + + return ( + <> + + onChange(event.target.value)} + placeholder={`${relatedCollection.name} record id`} + /> +
+ {error + ? `Could not load ${relatedCollection.name} records automatically. You can still enter a record id manually. ${error}` + : options.length > 0 + ? `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first.` + : `Create a ${relatedCollection.name} record first, or enter a record id manually.`} +
+ + ); +} diff --git a/packages/templates/next-export-ui/src/components/ResolvedReferenceValue.tsx b/packages/templates/next-export-ui/src/components/ResolvedReferenceValue.tsx new file mode 100644 index 0000000..37885e4 --- /dev/null +++ b/packages/templates/next-export-ui/src/components/ResolvedReferenceValue.tsx @@ -0,0 +1,97 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; + +import { assertAbiFunction, fnGet } from '../lib/app'; +import { formatFieldValue } from '../lib/format'; +import { displayField, getRelatedCollection, type ThsCollection, type ThsField } from '../lib/ths'; + +function getRecordValue(record: any, key: string, fallbackIndex?: number): any { + if (record && typeof record === 'object' && key in record) { + return (record as any)[key]; + } + if (Array.isArray(record) && typeof fallbackIndex === 'number') { + return record[fallbackIndex]; + } + return undefined; +} + +function fieldIndex(collection: ThsCollection, field: ThsField): number { + const idx = (collection.fields as any[]).findIndex((candidate) => candidate && candidate.name === field.name); + return 9 + Math.max(0, idx); +} + +function recordLabel(collection: ThsCollection, id: bigint, record: any): string { + const display = displayField(collection); + if (!display) return `${collection.name} #${String(id)}`; + const raw = getRecordValue(record, display.name, fieldIndex(collection, display)); + const rendered = formatFieldValue(raw, display.type, display.decimals, display.name).trim(); + if (!rendered) return `${collection.name} #${String(id)}`; + return rendered; +} + +export default function ResolvedReferenceValue(props: { + collection: ThsCollection; + field: ThsField; + value: unknown; + abi: any[] | null; + publicClient: any | null; + address: `0x${string}` | undefined; + fallback?: string; +}) { + const { collection, field, value, abi, publicClient, address, fallback } = props; + const relatedCollection = useMemo(() => getRelatedCollection(collection, field.name), [collection, field.name]); + const [label, setLabel] = useState(null); + + const id = useMemo(() => { + if (value === null || value === undefined || value === '') return null; + try { + return typeof value === 'bigint' ? value : BigInt(String(value)); + } catch { + return null; + } + }, [value]); + + useEffect(() => { + let cancelled = false; + + async function loadLabel() { + if (!relatedCollection || !abi || !publicClient || !address || id === null) { + setLabel(null); + return; + } + + try { + assertAbiFunction(abi, fnGet(relatedCollection.name), relatedCollection.name); + const record = await publicClient.readContract({ + address, + abi, + functionName: fnGet(relatedCollection.name), + args: [id] + }); + if (cancelled) return; + setLabel(recordLabel(relatedCollection, id, record)); + } catch { + if (!cancelled) setLabel(null); + } + } + + void loadLabel(); + + return () => { + cancelled = true; + }; + }, [abi, address, id, publicClient, relatedCollection]); + + const renderedFallback = fallback && fallback.trim() ? fallback : id === null ? '—' : `#${String(id)}`; + if (!relatedCollection || id === null) { + return {renderedFallback}; + } + + return ( + + {label ? `${label} (#${String(id)})` : `${relatedCollection.name} ${renderedFallback}`} + + ); +} diff --git a/packages/templates/next-export-ui/src/lib/ths.ts b/packages/templates/next-export-ui/src/lib/ths.ts index cc01f95..3cd4bfd 100644 --- a/packages/templates/next-export-ui/src/lib/ths.ts +++ b/packages/templates/next-export-ui/src/lib/ths.ts @@ -39,6 +39,13 @@ export interface PaymentRule { amountWei: string; } +export interface ThsRelation { + field: string; + to: string; + enforce?: boolean; + reverseIndex?: boolean; +} + export interface ThsCollection { name: string; plural?: string; @@ -60,7 +67,7 @@ export interface ThsCollection { transferRules?: { access: Access; }; - relations?: Array>; + relations?: ThsRelation[]; indexes?: Record; ui?: Record; } @@ -95,6 +102,17 @@ export function getField(collection: ThsCollection, fieldName: string): ThsField return (collection.fields as any[]).find((f) => f && f.name === fieldName) ?? null; } +export function getRelationForField(collection: ThsCollection, fieldName: string): ThsRelation | null { + const relations = Array.isArray(collection.relations) ? collection.relations : []; + return relations.find((relation) => relation && relation.field === fieldName) ?? null; +} + +export function getRelatedCollection(collection: ThsCollection, fieldName: string): ThsCollection | null { + const relation = getRelationForField(collection, fieldName); + if (!relation?.to) return null; + return getCollection(relation.to); +} + export function displayField(collection: ThsCollection): ThsField | null { // Prefer first required field, else first string-like, else first. const required = Array.isArray(collection.createRules?.required) ? collection.createRules.required : []; diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js index ea6829e..fbbfbe9 100644 --- a/test/testCliGenerateUi.js +++ b/test/testCliGenerateUi.js @@ -143,6 +143,44 @@ function schemaWithUiOverrides() { }; } +function schemaWithReferenceRelation() { + return { + thsVersion: '2025-12', + schemaVersion: '0.0.1', + app: { + name: 'Reference App', + slug: 'reference-app', + features: { uploads: false, onChainIndexing: true } + }, + collections: [ + { + name: 'Profile', + fields: [{ name: 'handle', type: 'string', required: true }], + createRules: { required: ['handle'], access: 'public' }, + visibilityRules: { gets: ['handle'], access: 'public' }, + updateRules: { mutable: ['handle'], access: 'owner' }, + deleteRules: { softDelete: true, access: 'owner' }, + transferRules: { access: 'owner' }, + indexes: { unique: [{ field: 'handle', scope: 'allTime' }], index: [] } + }, + { + name: 'Post', + fields: [ + { name: 'authorProfile', type: 'reference', required: true }, + { name: 'body', type: 'string', required: true } + ], + createRules: { required: ['authorProfile', 'body'], access: 'public' }, + visibilityRules: { gets: ['authorProfile', 'body'], access: 'public' }, + updateRules: { mutable: ['body'], access: 'owner' }, + deleteRules: { softDelete: true, access: 'owner' }, + transferRules: { access: 'owner' }, + indexes: { unique: [], index: [{ field: 'authorProfile' }] }, + relations: [{ field: 'authorProfile', to: 'Profile', enforce: true, reverseIndex: true }] + } + ] + }; +} + describe('th generate (UI template)', function () { this.timeout(180000); @@ -225,6 +263,43 @@ describe('th generate (UI template)', function () { expect(generatedTokens).to.equal(readTemplateThemeTokens()); }); + it('upstreams relation metadata into reference-aware generated CRUD UI', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-ui-reference-')); + const schemaPath = path.join(dir, 'schema.json'); + const outDir = path.join(dir, 'out'); + writeJson(schemaPath, schemaWithReferenceRelation()); + + const res = runTh(['generate', schemaPath, '--out', outDir], process.cwd()); + expect(res.status, res.stderr || res.stdout).to.equal(0); + + const generatedThs = fs.readFileSync(path.join(outDir, 'ui', 'src', 'generated', 'ths.ts'), 'utf-8'); + expect(generatedThs).to.include('"relations"'); + expect(generatedThs).to.include('"authorProfile"'); + expect(generatedThs).to.include('"to": "Profile"'); + + const generatedReferenceField = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ReferenceFieldInput.tsx'), 'utf-8'); + expect(generatedReferenceField).to.include("window.localStorage.getItem('TH_ACCOUNT')"); + expect(generatedReferenceField).to.include('listAllRecords'); + expect(generatedReferenceField).to.include('Owned records appear first'); + + const generatedResolvedReference = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ResolvedReferenceValue.tsx'), 'utf-8'); + expect(generatedResolvedReference).to.include('getRelatedCollection'); + expect(generatedResolvedReference).to.include("href={`/${relatedCollection.name}/?mode=view&id=${String(id)}`}"); + + const generatedNewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'new', 'ClientPage.tsx'), 'utf-8'); + expect(generatedNewPage).to.include('ReferenceFieldInput'); + expect(generatedNewPage).to.include("f.type === 'reference'"); + + const generatedEditPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'edit', 'ClientPage.tsx'), 'utf-8'); + expect(generatedEditPage).to.include('ReferenceFieldInput'); + + const generatedViewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'view', 'ClientPage.tsx'), 'utf-8'); + expect(generatedViewPage).to.include('ResolvedReferenceValue'); + + const generatedRecordCard = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'RecordCard.tsx'), 'utf-8'); + expect(generatedRecordCard).to.include('ResolvedReferenceValue'); + }); + it('generated UI builds (next export)', function () { this.timeout(180000); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-ui-build-')); From 5558055080b528d467f26a2360512cf948c79b89 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 24 Mar 2026 09:35:18 -1000 Subject: [PATCH 02/24] Upstream upload and relationship UX patterns --- .../example/microblog-ui/src/lib/microblog.ts | 65 ++--------- .../app/[collection]/edit/ClientPage.tsx | 11 +- .../app/[collection]/new/ClientPage.tsx | 11 +- .../templates/next-export-ui/app/page.tsx | 43 ++++++++ .../src/components/ImageFieldInput.tsx | 36 +++++- .../next-export-ui/src/lib/relations.ts | 104 ++++++++++++++++++ .../next-export-ui/src/lib/upload.ts | 51 ++++++++- test/testCliGenerateUi.js | 10 ++ 8 files changed, 271 insertions(+), 60 deletions(-) create mode 100644 packages/templates/next-export-ui/src/lib/relations.ts diff --git a/apps/example/microblog-ui/src/lib/microblog.ts b/apps/example/microblog-ui/src/lib/microblog.ts index 4cec4b8..213e5ff 100644 --- a/apps/example/microblog-ui/src/lib/microblog.ts +++ b/apps/example/microblog-ui/src/lib/microblog.ts @@ -1,6 +1,6 @@ 'use client'; -import { readRecordsByIds } from './app'; +import { getRecordId, listOwnedRecords, recordOwner, resolveReferenceRecords } from './relations'; import { listAllRecords, loadAppRuntime, type AppRuntime } from './runtime'; export type ProfileRecord = { @@ -15,23 +15,6 @@ export type FeedItem = { authorProfile: any | null; }; -export function getRecordId(value: unknown): bigint | null { - if (typeof value === 'bigint') return value; - if (typeof value === 'number' && Number.isInteger(value)) return BigInt(value); - if (typeof value === 'string' && value.trim()) { - try { - return BigInt(value); - } catch { - return null; - } - } - return null; -} - -export function recordOwner(record: any): string { - return String(record?.owner ?? '').trim().toLowerCase(); -} - export function profileDisplayName(profile: any): string { const displayName = String(profile?.displayName ?? '').trim(); const handle = String(profile?.handle ?? '').trim(); @@ -71,45 +54,21 @@ export async function listProfiles(runtime: AppRuntime): Promise { - const normalizedOwner = ownerAddress.trim().toLowerCase(); - const profiles = await listProfiles(runtime); - return profiles.filter((entry) => recordOwner(entry.record) === normalizedOwner); -} - -export async function loadProfilesByIds(runtime: AppRuntime, ids: bigint[]): Promise> { - const uniqueIds = Array.from(new Set(ids.map((id) => String(id)))).map((id) => BigInt(id)); - if (!uniqueIds.length) return new Map(); - - const records = await readRecordsByIds({ - publicClient: runtime.publicClient, - abi: runtime.abi, - address: runtime.appAddress, - collectionName: 'Profile', - ids: uniqueIds - }); - - const out = new Map(); - uniqueIds.forEach((id, index) => { - out.set(String(id), records[index] ?? null); - }); - return out; + return listOwnedRecords(runtime, 'Profile', ownerAddress); } export async function resolveFeedItemsWithProfiles(runtime: AppRuntime, items: Array<{ id: bigint; record: any }>): Promise { - const profileIds = items - .map((item) => extractAuthorProfileId(item.record)) - .filter((value): value is bigint => value !== null); - - const profilesById = await loadProfilesByIds(runtime, profileIds); - - return items.map((item) => { - const authorProfileId = extractAuthorProfileId(item.record); - return { - ...item, - authorProfileId, - authorProfile: authorProfileId ? profilesById.get(String(authorProfileId)) ?? null : null - }; + const resolved = await resolveReferenceRecords(runtime, items, { + fieldName: 'authorProfile', + targetCollectionName: 'Profile' }); + + return resolved.map((item) => ({ + id: item.id, + record: item.record, + authorProfileId: item.referenceId, + authorProfile: item.referenceRecord + })); } export async function loadMicroblogRuntime(): Promise { diff --git a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx index ba6a1b1..4ed4fe4 100644 --- a/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx +++ b/packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx @@ -59,6 +59,7 @@ export default function EditRecordPage(props: { params: { collection: string } } const [record, setRecord] = useState(null); const [form, setForm] = useState>({}); + const [busyUploads, setBusyUploads] = useState>({}); const id = useMemo(() => { if (!idParam) return null; @@ -104,6 +105,7 @@ export default function EditRecordPage(props: { params: { collection: string } } const fields = collection ? mutableFields(collection) : []; const optimistic = Boolean((collection as any)?.updateRules?.optimisticConcurrency); + const uploadBusy = Object.values(busyUploads).some(Boolean); async function fetchRecord(options?: { initial?: boolean }) { if (!publicClient || !abi || !appAddress || id === null) return; @@ -153,6 +155,10 @@ export default function EditRecordPage(props: { params: { collection: string } } async function submit() { if (!manifest || !deployment || !abi || !publicClient || !appAddress || id === null) return; if (!record) return; + if (uploadBusy) { + setError('Wait for media uploads to finish before saving this record.'); + return; + } setError(null); setStatus(null); @@ -315,6 +321,7 @@ export default function EditRecordPage(props: { params: { collection: string } } value={form[f.name] ?? ''} disabled={txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'} onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))} + onBusyChange={(busy) => setBusyUploads((prev) => ({ ...prev, [f.name]: busy }))} /> ) : f.type === 'reference' ? ( void submit()} - disabled={!abi || !publicClient || !appAddress || txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'} + disabled={!abi || !publicClient || !appAddress || uploadBusy || txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'} > - Save + {uploadBusy ? 'Waiting for media upload…' : 'Save'}
diff --git a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx index 5a3e502..79b81d5 100644 --- a/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx +++ b/packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx @@ -40,6 +40,7 @@ export default function CreateRecordPage(props: { params: { collection: string } const [publicClient, setPublicClient] = useState(null); const [form, setForm] = useState>({}); + const [busyUploads, setBusyUploads] = useState>({}); useEffect(() => { async function boot() { @@ -106,6 +107,7 @@ export default function CreateRecordPage(props: { params: { collection: string } const fields = createFields(collection); const required = requiredFieldNames(collection); const payment = hasCreatePayment(collection); + const uploadBusy = Object.values(busyUploads).some(Boolean); async function submit() { if (!manifest || !deployment || !abi || !publicClient || !appAddress) return; @@ -113,6 +115,10 @@ export default function CreateRecordPage(props: { params: { collection: string } setError('App is not deployed yet (manifest has 0x0 address).'); return; } + if (uploadBusy) { + setError('Wait for media uploads to finish before submitting this record.'); + return; + } setError(null); setStatus(null); @@ -210,6 +216,7 @@ export default function CreateRecordPage(props: { params: { collection: string } manifest={manifest} value={form[f.name] ?? ''} onChange={(next) => setForm((prev) => ({ ...prev, [f.name]: next }))} + onBusyChange={(busy) => setBusyUploads((prev) => ({ ...prev, [f.name]: busy }))} /> ) : f.type === 'reference' ? ( void submit()} - disabled={!abi || !publicClient || !appAddress || txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'} + disabled={!abi || !publicClient || !appAddress || uploadBusy || txPhase === 'submitting' || txPhase === 'submitted' || txPhase === 'confirming'} > - Create + {uploadBusy ? 'Waiting for media upload…' : 'Create'} diff --git a/packages/templates/next-export-ui/app/page.tsx b/packages/templates/next-export-ui/app/page.tsx index ce0f4ec..955b391 100644 --- a/packages/templates/next-export-ui/app/page.tsx +++ b/packages/templates/next-export-ui/app/page.tsx @@ -8,6 +8,9 @@ export default function HomePage() { const editableCollections = ths.collections.filter((collection) => mutableFields(collection).length > 0).length; const transferCollections = ths.collections.filter((collection) => transferEnabled(collection)).length; const paidCollections = ths.collections.filter((collection) => Boolean(hasCreatePayment(collection))).length; + const totalRelations = ths.collections.reduce((sum, collection) => sum + (Array.isArray(collection.relations) ? collection.relations.length : 0), 0); + const indexedCollections = ths.collections.filter((collection) => Array.isArray((collection as any).indexes?.index) && (collection as any).indexes.index.length > 0).length; + const imageCollections = ths.collections.filter((collection) => collection.fields.some((field) => field.type === 'image')).length; return (
@@ -57,6 +60,9 @@ export default function HomePage() {
paid creates: {paidCollections} + relations: {totalRelations} + indexed collections: {indexedCollections} + media collections: {imageCollections} schema {ths.schemaVersion}
@@ -71,6 +77,13 @@ export default function HomePage() { The generated app reads /.well-known/tokenhost/manifest.json at runtime, so deployment metadata stays outside the bundle.

+
+
/relationships
+

Reference-aware by default

+

+ Generated forms can resolve related records, prefer owned identities when available, and render linked labels instead of exposing raw foreign keys everywhere. +

+
/wallet

Public reads, wallet-native writes

@@ -78,6 +91,13 @@ export default function HomePage() { Read-only pages use the deployment chain's public RPC when available, so browsing does not require MetaMask and does not depend on the wallet being on the right network. Create, update, delete, and transfer flows still use the wallet with clean chain and transaction feedback.

+
+
/uploads
+

Long-running media flows

+

+ Upload fields expose progress, processing, retry, and submit-blocking states so generated apps can handle remote media workflows without custom glue. +

+
/hosting

Self-hostable release

@@ -122,6 +142,9 @@ export default function HomePage() { create {collection.createRules.access} {transferEnabled(collection) ? 'transfer on' : 'transfer off'} {payment ? paid create : null} + {collection.fields.some((field) => field.type === 'reference') ? reference-aware : null} + {collection.fields.some((field) => field.type === 'image') ? media upload : null} + {Array.isArray((collection as any).indexes?.index) && (collection as any).indexes.index.length > 0 ? indexed queries : null}
@@ -133,6 +156,26 @@ export default function HomePage() { {collection.fields.length > fieldPreview.length ? +{collection.fields.length - fieldPreview.length} more : null}
+ {Array.isArray(collection.relations) && collection.relations.length > 0 ? ( +
+ {collection.relations.map((relation) => ( + + {relation.field} → {relation.to} + + ))} +
+ ) : null} + + {Array.isArray((collection as any).indexes?.index) && (collection as any).indexes.index.length > 0 ? ( +
+ {(collection as any).indexes.index.map((index: any) => ( + + {String(index?.field ?? 'field')} {String(index?.mode ?? 'equality')} + + ))} +
+ ) : null} +
Browse Create diff --git a/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx b/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx index 5c3e2e3..346538d 100644 --- a/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx +++ b/packages/templates/next-export-ui/src/components/ImageFieldInput.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { getUploadConfig, uploadFile } from '../lib/upload'; +import { getUploadConfig, uploadFile, type UploadStateUpdate } from '../lib/upload'; export default function ImageFieldInput(props: { manifest: any | null; @@ -18,7 +18,9 @@ export default function ImageFieldInput(props: { const [progress, setProgress] = useState(0); const [error, setError] = useState(null); const [status, setStatus] = useState(null); + const [phase, setPhase] = useState('requesting'); const [localPreviewUrl, setLocalPreviewUrl] = useState(null); + const [lastSelectedFile, setLastSelectedFile] = useState(null); const previewUrl = localPreviewUrl || value || ''; useEffect(() => { @@ -28,6 +30,7 @@ export default function ImageFieldInput(props: { }, [localPreviewUrl]); async function handleFile(file: File) { + setLastSelectedFile(file); setError(null); setStatus(null); setProgress(0); @@ -42,13 +45,23 @@ export default function ImageFieldInput(props: { setLocalPreviewUrl(objectUrl); setBusy(true); onBusyChange?.(true); + setPhase('requesting'); setStatus(`Uploading via ${config.runnerMode}…`); try { const uploaded = await uploadFile({ manifest, file, - onProgress: setProgress + onProgress: setProgress, + onStateChange: (next) => { + setPhase(next.phase); + setProgress(next.progress); + setStatus( + next.elapsedMs && next.phase === 'processing' + ? `${next.message} ${Math.max(1, Math.round(next.elapsedMs / 1000))}s elapsed.` + : next.message + ); + } }); onChange(uploaded.url); setStatus(uploaded.cid ? `Uploaded (${uploaded.cid.slice(0, 12)}…).` : 'Uploaded.'); @@ -88,6 +101,16 @@ export default function ImageFieldInput(props: { > Remove +
+ {config ? ( +
+ {busy + ? phase === 'processing' || phase === 'accepted' + ? `Long-running upload: Token Host is finalizing this media item via ${config.provider || config.runnerMode}.` + : `Upload in progress via ${config.provider || config.runnerMode}.` + : `Uploads run via ${config.provider || config.runnerMode}.`} +
+ ) : null} {status ?
{status}
: null} {error ?
{error}
: null} diff --git a/packages/templates/next-export-ui/src/lib/relations.ts b/packages/templates/next-export-ui/src/lib/relations.ts new file mode 100644 index 0000000..d4ad16f --- /dev/null +++ b/packages/templates/next-export-ui/src/lib/relations.ts @@ -0,0 +1,104 @@ +'use client'; + +import { readRecordsByIds } from './app'; +import { displayField, getCollection } from './ths'; +import { formatFieldValue } from './format'; +import type { AppRuntime } from './runtime'; +import { listAllRecords } from './runtime'; + +export type OwnedRecord = { + id: bigint; + record: any; +}; + +export type ResolvedReferenceItem = { + id: bigint; + record: any; + referenceId: bigint | null; + referenceRecord: any | null; +}; + +export function getRecordId(value: unknown): bigint | null { + if (typeof value === 'bigint') return value; + if (typeof value === 'number' && Number.isInteger(value)) return BigInt(value); + if (typeof value === 'string' && value.trim()) { + try { + return BigInt(value); + } catch { + return null; + } + } + return null; +} + +export function recordOwner(record: any): string { + return String(record?.owner ?? '').trim().toLowerCase(); +} + +export function relatedRecordLabel(collectionName: string, id: bigint, record: any): string { + const collection = getCollection(collectionName); + if (!collection) return `${collectionName} #${String(id)}`; + + const field = displayField(collection); + if (!field) return `${collection.name} #${String(id)}`; + + const raw = record?.[field.name]; + const rendered = formatFieldValue(raw, field.type, field.decimals, field.name).trim(); + if (!rendered) return `${collection.name} #${String(id)}`; + return `${rendered} (#${String(id)})`; +} + +export async function listOwnedRecords(runtime: AppRuntime, collectionName: string, ownerAddress: string): Promise { + const normalizedOwner = ownerAddress.trim().toLowerCase(); + const page = await listAllRecords({ + manifest: runtime.manifest, + publicClient: runtime.publicClient, + abi: runtime.abi, + address: runtime.appAddress, + collectionName + }); + + return page.ids + .map((id, index) => ({ id, record: page.records[index] })) + .filter((entry) => recordOwner(entry.record) === normalizedOwner); +} + +export async function loadRecordsByIds(runtime: AppRuntime, collectionName: string, ids: bigint[]): Promise> { + const uniqueIds = Array.from(new Set(ids.map((id) => String(id)))).map((id) => BigInt(id)); + if (!uniqueIds.length) return new Map(); + + const records = await readRecordsByIds({ + publicClient: runtime.publicClient, + abi: runtime.abi, + address: runtime.appAddress, + collectionName, + ids: uniqueIds + }); + + const out = new Map(); + uniqueIds.forEach((id, index) => { + out.set(String(id), records[index] ?? null); + }); + return out; +} + +export async function resolveReferenceRecords( + runtime: AppRuntime, + items: Array<{ id: bigint; record: any }>, + options: { fieldName: string; targetCollectionName: string } +): Promise { + const referenceIds = items + .map((item) => getRecordId(item.record?.[options.fieldName])) + .filter((value): value is bigint => value !== null); + + const recordsById = await loadRecordsByIds(runtime, options.targetCollectionName, referenceIds); + + return items.map((item) => { + const referenceId = getRecordId(item.record?.[options.fieldName]); + return { + ...item, + referenceId, + referenceRecord: referenceId ? recordsById.get(String(referenceId)) ?? null : null + }; + }); +} diff --git a/packages/templates/next-export-ui/src/lib/upload.ts b/packages/templates/next-export-ui/src/lib/upload.ts index 1b9e716..3326533 100644 --- a/packages/templates/next-export-ui/src/lib/upload.ts +++ b/packages/templates/next-export-ui/src/lib/upload.ts @@ -22,6 +22,16 @@ export type UploadConfig = { maxBytes: number | null; }; +export type UploadPhase = 'requesting' | 'accepted' | 'processing' | 'completed' | 'failed'; + +export type UploadStateUpdate = { + phase: UploadPhase; + message: string; + progress: number; + jobId?: string | null; + elapsedMs?: number; +}; + type PendingUploadResponse = { ok: true; pending: true; @@ -97,6 +107,7 @@ export async function uploadFile(args: { manifest: any; file: File; onProgress?: (percent: number) => void; + onStateChange?: (state: UploadStateUpdate) => void; }): Promise { const config = getUploadConfig(args.manifest); if (!config) throw new Error('Uploads are not enabled for this app.'); @@ -116,14 +127,27 @@ export async function uploadFile(args: { return await new Promise((resolve, reject) => { let settled = false; + const notify = (state: UploadStateUpdate) => { + args.onStateChange?.(state); + }; const finishResolve = (value: UploadResult) => { if (settled) return; settled = true; + notify({ + phase: 'completed', + message: value.cid ? `Upload completed (${value.cid.slice(0, 12)}…).` : 'Upload completed.', + progress: 100 + }); resolve(value); }; const finishReject = (error: Error) => { if (settled) return; settled = true; + notify({ + phase: 'failed', + message: error.message || 'Upload failed.', + progress: 100 + }); reject(error); }; const xhr = new XMLHttpRequest(); @@ -137,9 +161,21 @@ export async function uploadFile(args: { xhr.setRequestHeader('X-TokenHost-Upload-Mode', 'async'); } + notify({ + phase: 'requesting', + message: `Uploading ${args.file.name || 'file'} to Token Host…`, + progress: 0 + }); + xhr.upload.onprogress = (event) => { if (!event.lengthComputable || !args.onProgress) return; - args.onProgress(Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)))); + const percent = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100))); + args.onProgress(percent); + notify({ + phase: 'requesting', + message: `Uploading ${args.file.name || 'file'} to Token Host…`, + progress: percent + }); }; async function pollUploadJob(statusUrl: string): Promise { @@ -156,6 +192,13 @@ export async function uploadFile(args: { } if (body?.pending) { args.onProgress?.(100); + notify({ + phase: 'processing', + message: `Token Host accepted the upload and is processing it via ${config.runnerMode}. This can take a minute or two.`, + progress: 100, + jobId: body?.jobId ? String(body.jobId) : null, + elapsedMs: Date.now() - startedAt + }); continue; } if (!body?.ok || !body?.upload?.url) { @@ -189,6 +232,12 @@ export async function uploadFile(args: { if (xhr.status === 202 && body?.pending && body?.jobId) { const pending = body as PendingUploadResponse; + notify({ + phase: 'accepted', + message: `Upload accepted. Token Host is processing it via ${config.runnerMode}.`, + progress: 100, + jobId: pending.jobId + }); const statusUrl = normalizeUrl(pending.statusUrl || '', `${config.statusUrl}?jobId=${encodeURIComponent(pending.jobId)}`); const completed = await pollUploadJob(statusUrl); finishResolve(completed); diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js index fbbfbe9..33911c9 100644 --- a/test/testCliGenerateUi.js +++ b/test/testCliGenerateUi.js @@ -234,10 +234,14 @@ describe('th generate (UI template)', function () { const generatedImageField = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ImageFieldInput.tsx'), 'utf-8'); expect(generatedImageField).to.include('onBusyChange'); + expect(generatedImageField).to.include('Long-running upload'); + expect(generatedImageField).to.include('Retry'); const generatedUpload = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'upload.ts'), 'utf-8'); expect(generatedUpload).to.include('buildUploadNetworkError'); expect(generatedUpload).to.include('xhr.timeout = 5 * 60 * 1000;'); + expect(generatedUpload).to.include("phase: 'accepted'"); + expect(generatedUpload).to.include('Token Host accepted the upload'); const generatedClients = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'clients.ts'), 'utf-8'); expect(generatedClients).to.include('async function refreshWalletChainConfig'); @@ -286,12 +290,18 @@ describe('th generate (UI template)', function () { expect(generatedResolvedReference).to.include('getRelatedCollection'); expect(generatedResolvedReference).to.include("href={`/${relatedCollection.name}/?mode=view&id=${String(id)}`}"); + const generatedRelations = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'relations.ts'), 'utf-8'); + expect(generatedRelations).to.include('resolveReferenceRecords'); + expect(generatedRelations).to.include('listOwnedRecords'); + const generatedNewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'new', 'ClientPage.tsx'), 'utf-8'); expect(generatedNewPage).to.include('ReferenceFieldInput'); expect(generatedNewPage).to.include("f.type === 'reference'"); + expect(generatedNewPage).to.include('Waiting for media upload…'); const generatedEditPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'edit', 'ClientPage.tsx'), 'utf-8'); expect(generatedEditPage).to.include('ReferenceFieldInput'); + expect(generatedEditPage).to.include('Waiting for media upload…'); const generatedViewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'view', 'ClientPage.tsx'), 'utf-8'); expect(generatedViewPage).to.include('ResolvedReferenceValue'); From c00875b1bce84be72f23600787236f56a9177d19 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 24 Mar 2026 09:50:07 -1000 Subject: [PATCH 03/24] Upstream owned identity selection --- .../src/components/MicroblogComposeClient.tsx | 135 ++++++--------- .../src/components/ReferenceFieldInput.tsx | 153 ++++++----------- .../next-export-ui/src/lib/relations.ts | 162 ++++++++++++++++++ test/testCliGenerateUi.js | 6 +- 4 files changed, 263 insertions(+), 193 deletions(-) diff --git a/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx b/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx index 3676518..b8e3fea 100644 --- a/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx +++ b/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx @@ -5,12 +5,15 @@ import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import ImageFieldInput from './ImageFieldInput'; +import ReferenceFieldInput from './ReferenceFieldInput'; import TxStatus, { type TxPhase } from './TxStatus'; import { fnCreate } from '../lib/app'; import { chainWithRpcOverride, requestWalletAddress } from '../lib/clients'; import { getReadRpcUrl } from '../lib/manifest'; +import { useOwnedReferenceOptions } from '../lib/relations'; +import { getCollection, getField } from '../lib/ths'; import { submitWriteTx } from '../lib/tx'; -import { listOwnedProfiles, loadMicroblogRuntime, profileHandle, profileLabel, type ProfileRecord } from '../lib/microblog'; +import { loadMicroblogRuntime } from '../lib/microblog'; type ComposeState = { loading: boolean; @@ -19,8 +22,6 @@ type ComposeState = { submitError: string | null; }; -const PROFILE_STORAGE_PREFIX = 'TH_MICROBLOG_PROFILE_ID:'; - export default function MicroblogComposeClient() { const router = useRouter(); const [state, setState] = useState({ @@ -31,7 +32,6 @@ export default function MicroblogComposeClient() { }); const [runtime, setRuntime] = useState(null); const [account, setAccount] = useState(null); - const [profiles, setProfiles] = useState([]); const [selectedProfileId, setSelectedProfileId] = useState(''); const [body, setBody] = useState(''); const [image, setImage] = useState(''); @@ -69,60 +69,44 @@ export default function MicroblogComposeClient() { }; }, []); + const walletChain = useMemo( + () => (runtime ? chainWithRpcOverride(runtime.chain, getReadRpcUrl(runtime.manifest) || undefined) : null), + [runtime] + ); + const ownedReference = useOwnedReferenceOptions( + runtime + ? { + manifest: runtime.manifest, + publicClient: runtime.publicClient, + abi: runtime.abi, + address: runtime.appAddress, + collectionName: 'Post', + fieldName: 'authorProfile', + value: selectedProfileId, + onChange: setSelectedProfileId + } + : { + manifest: null, + publicClient: null, + abi: [], + address: undefined as any, + collectionName: 'Post', + fieldName: 'authorProfile', + value: selectedProfileId, + onChange: setSelectedProfileId + } + ); + const selectedProfile = ownedReference.selectedOption; + const postCollection = useMemo(() => getCollection('Post'), []); + const authorProfileField = useMemo(() => (postCollection ? getField(postCollection, 'authorProfile') : null), [postCollection]); + useEffect(() => { - let cancelled = false; if (!runtime || !account) { - setProfiles([]); setSelectedProfileId(''); return; } - - setState((prev) => ({ ...prev, loading: true, connectError: null })); - void (async () => { - try { - const ownedProfiles = await listOwnedProfiles(runtime, account); - if (cancelled) return; - setProfiles(ownedProfiles); - - let preferred = ''; - try { - const stored = localStorage.getItem(`${PROFILE_STORAGE_PREFIX}${account.toLowerCase()}`) ?? ''; - if (stored && ownedProfiles.some((entry) => String(entry.id) === stored)) preferred = stored; - } catch { - // ignore - } - if (!preferred && ownedProfiles[0]) preferred = String(ownedProfiles[0].id); - setSelectedProfileId(preferred); - } catch (error: any) { - if (cancelled) return; - setState((prev) => ({ ...prev, connectError: String(error?.message ?? error) })); - } finally { - if (!cancelled) setState((prev) => ({ ...prev, loading: false })); - } - })(); - - return () => { - cancelled = true; - }; - }, [runtime, account]); - - useEffect(() => { - if (!account || !selectedProfileId) return; - try { - localStorage.setItem(`${PROFILE_STORAGE_PREFIX}${account.toLowerCase()}`, selectedProfileId); - } catch { - // ignore - } - }, [account, selectedProfileId]); - - const selectedProfile = useMemo( - () => profiles.find((entry) => String(entry.id) === selectedProfileId) ?? null, - [profiles, selectedProfileId] - ); - const walletChain = useMemo( - () => (runtime ? chainWithRpcOverride(runtime.chain, getReadRpcUrl(runtime.manifest) || undefined) : null), - [runtime] - ); + setState((prev) => ({ ...prev, loading: ownedReference.loading, connectError: ownedReference.error })); + }, [account, ownedReference.error, ownedReference.loading, runtime]); async function connectWallet() { if (!walletChain) return; @@ -233,7 +217,7 @@ export default function MicroblogComposeClient() {
Wallet linked
-
{profiles.length}
+
{ownedReference.ownedOptions.length}
Owned profiles
@@ -257,7 +241,7 @@ export default function MicroblogComposeClient() { ) : null} - {account && !profiles.length ? ( + {account && !ownedReference.ownedOptions.length ? (
/profiles/empty

No owned profiles found

@@ -269,7 +253,7 @@ export default function MicroblogComposeClient() {
) : null} - {account && profiles.length ? ( + {account && ownedReference.ownedOptions.length ? (

Compose Post

@@ -279,37 +263,16 @@ export default function MicroblogComposeClient() {
- -
- -
- -
- {selectedProfile ? ( -
-
- profile #{String(selectedProfile.id)} - {profileHandle(selectedProfile.record) ? @{profileHandle(selectedProfile.record)} : null} -
- {profileLabel(selectedProfile.record)} - {String(selectedProfile.record?.bio ?? '').trim() ? ( -

{String(selectedProfile.record.bio)}

- ) : null} -
- ) : ( - Select a profile. - )} -
+ onChange={setSelectedProfileId} + />
diff --git a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx index 66e1a4e..b2b5c6d 100644 --- a/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx +++ b/packages/templates/next-export-ui/src/components/ReferenceFieldInput.tsx @@ -1,40 +1,10 @@ 'use client'; -import React, { useEffect, useMemo, useState } from 'react'; +import React from 'react'; +import Link from 'next/link'; -import { listAllRecords } from '../lib/runtime'; -import { formatFieldValue } from '../lib/format'; -import { displayField, getRelatedCollection, type ThsCollection, type ThsField } from '../lib/ths'; - -type ReferenceOption = { - id: bigint; - label: string; - owned: boolean; -}; - -function getRecordValue(record: any, key: string, fallbackIndex?: number): any { - if (record && typeof record === 'object' && key in record) { - return (record as any)[key]; - } - if (Array.isArray(record) && typeof fallbackIndex === 'number') { - return record[fallbackIndex]; - } - return undefined; -} - -function fieldIndex(collection: ThsCollection, field: ThsField): number { - const idx = (collection.fields as any[]).findIndex((candidate) => candidate && candidate.name === field.name); - return 9 + Math.max(0, idx); -} - -function recordLabel(collection: ThsCollection, id: bigint, record: any): string { - const display = displayField(collection); - if (!display) return `${collection.name} #${String(id)}`; - const raw = getRecordValue(record, display.name, fieldIndex(collection, display)); - const rendered = formatFieldValue(raw, display.type, display.decimals, display.name).trim(); - if (!rendered) return `${collection.name} #${String(id)}`; - return `${rendered} (#${String(id)})`; -} +import { recordSummary, useOwnedReferenceOptions } from '../lib/relations'; +import { type ThsCollection, type ThsField } from '../lib/ths'; export default function ReferenceFieldInput(props: { manifest: any; @@ -48,79 +18,20 @@ export default function ReferenceFieldInput(props: { onChange: (next: string) => void; }) { const { manifest, publicClient, abi, address, collection, field, value, disabled, onChange } = props; - const relatedCollection = useMemo(() => getRelatedCollection(collection, field.name), [collection, field.name]); - - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [options, setOptions] = useState([]); - - useEffect(() => { - let cancelled = false; - - async function loadOptions() { - if (!relatedCollection || !publicClient || !abi || !address) { - setOptions([]); - setError(null); - return; - } - - setLoading(true); - setError(null); - try { - const walletAccount = - typeof window !== 'undefined' ? window.localStorage.getItem('TH_ACCOUNT')?.toLowerCase() ?? '' : ''; - const page = await listAllRecords({ - publicClient, - abi, - address, - collectionName: relatedCollection.name, - manifest - }); - - const nextOptions = page.ids - .map((id, index) => { - const record = page.records[index]; - if (!record) return null; - const owner = String(getRecordValue(record, 'owner', 3) ?? '').toLowerCase(); - return { - id, - label: recordLabel(relatedCollection, id, record), - owned: Boolean(walletAccount) && owner === walletAccount - }; - }) - .filter(Boolean) as ReferenceOption[]; - - nextOptions.sort((left, right) => { - if (left.owned !== right.owned) return left.owned ? -1 : 1; - if (left.label !== right.label) return left.label.localeCompare(right.label); - return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; - }); - - if (cancelled) return; - setOptions(nextOptions); - - const ownedOptions = nextOptions.filter((option) => option.owned); - if (!value && ownedOptions.length === 1) { - onChange(String(ownedOptions[0]?.id ?? '')); - } - } catch (cause: any) { - if (cancelled) return; - setOptions([]); - setError(String(cause?.message ?? cause)); - } finally { - if (!cancelled) setLoading(false); - } - } - - void loadOptions(); - - return () => { - cancelled = true; - }; - }, [abi, address, collection, field.name, manifest, onChange, publicClient, relatedCollection, value]); + const { account, loading, error, options, relatedCollection, ownedOptions, selectedOption } = useOwnedReferenceOptions({ + manifest, + publicClient, + abi, + address, + collectionName: collection.name, + fieldName: field.name, + value, + onChange + }); const resolvedValue = value.trim(); const hasResolvedValue = resolvedValue !== '' && options.some((option) => String(option.id) === resolvedValue); + const selectedSummary = selectedOption ? recordSummary(selectedOption.record) : null; if (!relatedCollection) { return ( @@ -152,6 +63,30 @@ export default function ReferenceFieldInput(props: { ))} + {selectedSummary ? ( +
+
+
+ {relatedCollection.name} #{String(selectedOption?.id ?? '')} + {selectedSummary.subtitle ? {selectedSummary.subtitle} : null} +
+
+ {selectedSummary.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {selectedSummary.title} + ) : null} +
+ {selectedSummary.title} + {selectedSummary.body ? {selectedSummary.body} : null} +
+
+
+
+ ) : null} 0 - ? `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first.` + ? ownedOptions.length > 0 + ? `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry. Owned records appear first and your last choice is remembered${account ? ` for ${account.slice(0, 6)}…` : ''}.` + : `Showing ${relatedCollection.name} labels instead of a raw foreign-key entry.` : `Create a ${relatedCollection.name} record first, or enter a record id manually.`}
+ {!loading && options.length === 0 ? ( +
+ Create {relatedCollection.name} + Browse {relatedCollection.name} +
+ ) : null} ); } diff --git a/packages/templates/next-export-ui/src/lib/relations.ts b/packages/templates/next-export-ui/src/lib/relations.ts index d4ad16f..ff877ca 100644 --- a/packages/templates/next-export-ui/src/lib/relations.ts +++ b/packages/templates/next-export-ui/src/lib/relations.ts @@ -1,5 +1,7 @@ 'use client'; +import { useEffect, useMemo, useState } from 'react'; + import { readRecordsByIds } from './app'; import { displayField, getCollection } from './ths'; import { formatFieldValue } from './format'; @@ -18,6 +20,13 @@ export type ResolvedReferenceItem = { referenceRecord: any | null; }; +export type ReferenceOption = { + id: bigint; + label: string; + owned: boolean; + record: any; +}; + export function getRecordId(value: unknown): bigint | null { if (typeof value === 'bigint') return value; if (typeof value === 'number' && Number.isInteger(value)) return BigInt(value); @@ -48,6 +57,24 @@ export function relatedRecordLabel(collectionName: string, id: bigint, record: a return `${rendered} (#${String(id)})`; } +export function recordSummary(record: any): { title: string; subtitle: string | null; imageUrl: string | null; body: string | null } { + const title = + String(record?.displayName ?? '').trim() || + String(record?.title ?? '').trim() || + String(record?.name ?? '').trim() || + String(record?.handle ?? '').trim() || + 'Unnamed record'; + + const subtitle = String(record?.handle ?? '').trim() + ? `@${String(record.handle).trim()}` + : String(record?.slug ?? '').trim() || null; + + const imageUrl = String(record?.avatar ?? '').trim() || String(record?.image ?? '').trim() || null; + const body = String(record?.bio ?? '').trim() || String(record?.description ?? '').trim() || null; + + return { title, subtitle, imageUrl, body }; +} + export async function listOwnedRecords(runtime: AppRuntime, collectionName: string, ownerAddress: string): Promise { const normalizedOwner = ownerAddress.trim().toLowerCase(); const page = await listAllRecords({ @@ -102,3 +129,138 @@ export async function resolveReferenceRecords( }; }); } + +function selectionStorageKey(args: { collectionName: string; fieldName: string; account: string }) { + return `TH_REFERENCE_SELECTION:${args.collectionName}:${args.fieldName}:${args.account.toLowerCase()}`; +} + +export function useOwnedReferenceOptions(args: { + manifest: any; + publicClient: any; + abi: any[]; + address: `0x${string}`; + collectionName: string; + fieldName: string; + value: string; + onChange: (next: string) => void; +}) { + const [account, setAccount] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [options, setOptions] = useState([]); + + const collection = useMemo(() => getCollection(args.collectionName), [args.collectionName]); + const relation = useMemo( + () => (collection?.relations ?? []).find((entry) => entry.field === args.fieldName) ?? null, + [collection, args.fieldName] + ); + const relatedCollection = useMemo(() => (relation?.to ? getCollection(relation.to) : null), [relation]); + + useEffect(() => { + try { + setAccount(window.localStorage.getItem('TH_ACCOUNT')); + } catch { + setAccount(null); + } + }, []); + + useEffect(() => { + let cancelled = false; + + async function load() { + if (!relatedCollection || !args.publicClient || !args.abi || !args.address) { + setOptions([]); + setError(null); + return; + } + + setLoading(true); + setError(null); + try { + const page = await listAllRecords({ + manifest: args.manifest, + publicClient: args.publicClient, + abi: args.abi, + address: args.address, + collectionName: relatedCollection.name + }); + + const normalizedAccount = account?.trim().toLowerCase() ?? ''; + const nextOptions = page.ids + .map((id, index) => { + const record = page.records[index]; + if (!record) return null; + return { + id, + label: relatedRecordLabel(relatedCollection.name, id, record), + owned: Boolean(normalizedAccount) && recordOwner(record) === normalizedAccount, + record + }; + }) + .filter(Boolean) as ReferenceOption[]; + + nextOptions.sort((left, right) => { + if (left.owned !== right.owned) return left.owned ? -1 : 1; + if (left.label !== right.label) return left.label.localeCompare(right.label); + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; + }); + + if (cancelled) return; + setOptions(nextOptions); + + const ownedOptions = nextOptions.filter((option) => option.owned); + if (!args.value && account) { + let preferred = ''; + try { + preferred = window.localStorage.getItem( + selectionStorageKey({ collectionName: args.collectionName, fieldName: args.fieldName, account }) + ) ?? ''; + } catch { + preferred = ''; + } + if (preferred && nextOptions.some((option) => String(option.id) === preferred)) { + args.onChange(preferred); + } else if (ownedOptions.length === 1) { + args.onChange(String(ownedOptions[0]?.id ?? '')); + } + } + } catch (cause: any) { + if (cancelled) return; + setOptions([]); + setError(String(cause?.message ?? cause)); + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + + return () => { + cancelled = true; + }; + }, [account, args.abi, args.address, args.collectionName, args.fieldName, args.manifest, args.onChange, args.publicClient, args.value, relatedCollection]); + + useEffect(() => { + if (!account || !args.value) return; + try { + window.localStorage.setItem( + selectionStorageKey({ collectionName: args.collectionName, fieldName: args.fieldName, account }), + args.value + ); + } catch { + // ignore + } + }, [account, args.collectionName, args.fieldName, args.value]); + + const selectedOption = options.find((option) => String(option.id) === args.value) ?? null; + + return { + account, + loading, + error, + options, + relatedCollection, + ownedOptions: options.filter((option) => option.owned), + selectedOption + }; +} diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js index 33911c9..f1cd659 100644 --- a/test/testCliGenerateUi.js +++ b/test/testCliGenerateUi.js @@ -282,9 +282,9 @@ describe('th generate (UI template)', function () { expect(generatedThs).to.include('"to": "Profile"'); const generatedReferenceField = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ReferenceFieldInput.tsx'), 'utf-8'); - expect(generatedReferenceField).to.include("window.localStorage.getItem('TH_ACCOUNT')"); - expect(generatedReferenceField).to.include('listAllRecords'); + expect(generatedReferenceField).to.include('useOwnedReferenceOptions'); expect(generatedReferenceField).to.include('Owned records appear first'); + expect(generatedReferenceField).to.include("href={`/${relatedCollection.name}/?mode=new`}"); const generatedResolvedReference = fs.readFileSync(path.join(outDir, 'ui', 'src', 'components', 'ResolvedReferenceValue.tsx'), 'utf-8'); expect(generatedResolvedReference).to.include('getRelatedCollection'); @@ -293,6 +293,8 @@ describe('th generate (UI template)', function () { const generatedRelations = fs.readFileSync(path.join(outDir, 'ui', 'src', 'lib', 'relations.ts'), 'utf-8'); expect(generatedRelations).to.include('resolveReferenceRecords'); expect(generatedRelations).to.include('listOwnedRecords'); + expect(generatedRelations).to.include('useOwnedReferenceOptions'); + expect(generatedRelations).to.include('TH_REFERENCE_SELECTION'); const generatedNewPage = fs.readFileSync(path.join(outDir, 'ui', 'app', '[collection]', 'new', 'ClientPage.tsx'), 'utf-8'); expect(generatedNewPage).to.include('ReferenceFieldInput'); From 4fa7c2a10808421d09062f6916d02c9eb8eaff28 Mon Sep 17 00:00:00 2001 From: Mikers Date: Tue, 24 Mar 2026 09:54:13 -1000 Subject: [PATCH 04/24] Use generated post create flow in microblog --- .../src/components/MicroblogComposeClient.tsx | 326 ------------------ .../components/MicroblogPostRouteClient.tsx | 10 - 2 files changed, 336 deletions(-) delete mode 100644 apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx diff --git a/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx b/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx deleted file mode 100644 index b8e3fea..0000000 --- a/apps/example/microblog-ui/src/components/MicroblogComposeClient.tsx +++ /dev/null @@ -1,326 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useEffect, useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; - -import ImageFieldInput from './ImageFieldInput'; -import ReferenceFieldInput from './ReferenceFieldInput'; -import TxStatus, { type TxPhase } from './TxStatus'; -import { fnCreate } from '../lib/app'; -import { chainWithRpcOverride, requestWalletAddress } from '../lib/clients'; -import { getReadRpcUrl } from '../lib/manifest'; -import { useOwnedReferenceOptions } from '../lib/relations'; -import { getCollection, getField } from '../lib/ths'; -import { submitWriteTx } from '../lib/tx'; -import { loadMicroblogRuntime } from '../lib/microblog'; - -type ComposeState = { - loading: boolean; - runtimeError: string | null; - connectError: string | null; - submitError: string | null; -}; - -export default function MicroblogComposeClient() { - const router = useRouter(); - const [state, setState] = useState({ - loading: true, - runtimeError: null, - connectError: null, - submitError: null - }); - const [runtime, setRuntime] = useState(null); - const [account, setAccount] = useState(null); - const [selectedProfileId, setSelectedProfileId] = useState(''); - const [body, setBody] = useState(''); - const [image, setImage] = useState(''); - const [imageUploadBusy, setImageUploadBusy] = useState(false); - const [txStatus, setTxStatus] = useState(null); - const [txPhase, setTxPhase] = useState('idle'); - const [txHash, setTxHash] = useState(null); - - useEffect(() => { - let cancelled = false; - - (async () => { - try { - const loadedRuntime = await loadMicroblogRuntime(); - if (cancelled) return; - setRuntime(loadedRuntime); - - try { - const cached = localStorage.getItem('TH_ACCOUNT'); - if (cached && !cancelled) setAccount(cached); - } catch { - // ignore - } - } catch (error: any) { - if (cancelled) return; - setState((prev) => ({ ...prev, runtimeError: String(error?.message ?? error), loading: false })); - return; - } - - if (!cancelled) setState((prev) => ({ ...prev, loading: false })); - })(); - - return () => { - cancelled = true; - }; - }, []); - - const walletChain = useMemo( - () => (runtime ? chainWithRpcOverride(runtime.chain, getReadRpcUrl(runtime.manifest) || undefined) : null), - [runtime] - ); - const ownedReference = useOwnedReferenceOptions( - runtime - ? { - manifest: runtime.manifest, - publicClient: runtime.publicClient, - abi: runtime.abi, - address: runtime.appAddress, - collectionName: 'Post', - fieldName: 'authorProfile', - value: selectedProfileId, - onChange: setSelectedProfileId - } - : { - manifest: null, - publicClient: null, - abi: [], - address: undefined as any, - collectionName: 'Post', - fieldName: 'authorProfile', - value: selectedProfileId, - onChange: setSelectedProfileId - } - ); - const selectedProfile = ownedReference.selectedOption; - const postCollection = useMemo(() => getCollection('Post'), []); - const authorProfileField = useMemo(() => (postCollection ? getField(postCollection, 'authorProfile') : null), [postCollection]); - - useEffect(() => { - if (!runtime || !account) { - setSelectedProfileId(''); - return; - } - setState((prev) => ({ ...prev, loading: ownedReference.loading, connectError: ownedReference.error })); - }, [account, ownedReference.error, ownedReference.loading, runtime]); - - async function connectWallet() { - if (!walletChain) return; - setState((prev) => ({ ...prev, connectError: null })); - try { - const nextAccount = await requestWalletAddress(walletChain); - setAccount(nextAccount); - try { - localStorage.setItem('TH_ACCOUNT', nextAccount); - } catch { - // ignore - } - } catch (error: any) { - setState((prev) => ({ ...prev, connectError: String(error?.message ?? error) })); - } - } - - async function submit() { - if (!runtime || !walletChain || !selectedProfile || !body.trim() || imageUploadBusy) return; - - setState((prev) => ({ ...prev, submitError: null })); - setTxStatus(null); - setTxPhase('idle'); - setTxHash(null); - - try { - const result = await submitWriteTx({ - manifest: runtime.manifest, - deployment: runtime.deployment, - chain: walletChain, - publicClient: runtime.publicClient, - address: runtime.appAddress, - abi: runtime.abi, - functionName: fnCreate('Post'), - contractArgs: [ - { - authorProfile: selectedProfile.id, - body: body.trim(), - image: image.trim() - } - ], - setStatus: setTxStatus, - onPhase: setTxPhase, - onHash: setTxHash - }); - - setTxStatus(`Posted (${result.hash.slice(0, 10)}…).`); - router.push('/'); - router.refresh(); - } catch (error: any) { - setState((prev) => ({ ...prev, submitError: String(error?.message ?? error) })); - setTxStatus(null); - setTxPhase('failed'); - } - } - - if (state.loading && !runtime) { - return ( -
-

Loading composer…

-

Resolving the active deployment and wallet state.

-
- ); - } - - if (state.runtimeError) { - return ( -
-
/compose/error
-

Unable to load composer

-

{state.runtimeError}

-
- ); - } - - return ( -
-
-
-
-
- /post/compose -
- normalized author identity - profile-linked posts -
-
-

- Compose as a profile -
- not as a copied handle string. -

-

- Posts now store authorProfile as an on-chain reference to Profile, - so handle and avatar changes flow through existing posts automatically. -

-
- Back to feed - Browse profiles -
-
- -
-
/identity
-
-
-
{account ? 1 : 0}
-
Wallet linked
-
-
-
{ownedReference.ownedOptions.length}
-
Owned profiles
-
-
-
- posts reference profiles - profile changes propagate -
-
-
-
- - {!account ? ( -
-
/wallet
-

Connect a wallet to compose

-

Posting now requires selecting one of your on-chain profiles. Connect the wallet that owns the profile first.

-
- -
- {state.connectError ?

{state.connectError}

: null} -
- ) : null} - - {account && !ownedReference.ownedOptions.length ? ( -
-
/profiles/empty
-

No owned profiles found

-

Create a profile first. Once it exists on-chain under this wallet, you can compose posts as that profile.

-
- Create profile -
- {state.connectError ?

{state.connectError}

: null} -
- ) : null} - - {account && ownedReference.ownedOptions.length ? ( -
-
-

Compose Post

-

Choose the on-chain profile identity for this post, then write the post body and optional image.

-
- -
-
- - -
- -
- -