feat: Phase 2 — ObjectUI Rendering Engine#6
Conversation
- ViewRenderer: top-level dispatcher with extensible registry - ListViewRenderer: metadata-driven list with sorting, pull-to-refresh, field formatting - FormViewRenderer: section-based forms with FieldRenderer, validation, mutation - DetailViewRenderer: read-only record display with action bar, related lists - DashboardViewRenderer: metric, card, list, chart widget types - FieldRenderer: maps 40+ ObjectQL field types to native inputs - Action system: ActionExecutor, ActionBar, FloatingActionButton - Wire renderers into existing app routes (object list, detail) - Add new routes: create (new.tsx) and edit ([id]/edit.tsx) - Update ROADMAP.md with Phase 2 progress Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…essages Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Implements Phase 2’s metadata-driven ObjectUI Rendering Engine and wires it into the dynamic object routes, enabling server-driven list/form/detail/dashboard rendering plus an initial action execution system.
Changes:
- Added renderer suite (
ViewRenderer, list/form/detail/dashboard renderers,FieldRenderer) with shared local type contracts for ObjectUI metadata. - Introduced an action system (
ActionExecutor,ActionBar,FloatingActionButton) for executing metadata-defined actions. - Updated object routes and added create/edit routes to use the new renderers and SDK hooks.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/ROADMAP.md | Marks Phase 2 renderer/action milestones as complete. |
| components/renderers/types.ts | Adds local renderer-facing ObjectUI metadata/type contracts. |
| components/renderers/index.ts | Central export surface for renderers and shared types. |
| components/renderers/ViewRenderer.tsx | Dispatcher + extensible registry for view-type renderers. |
| components/renderers/ListViewRenderer.tsx | Metadata-driven list rendering with sort chips/search/refresh/infinite scroll. |
| components/renderers/FormViewRenderer.tsx | Section-based form rendering + basic required validation + conditional visibility. |
| components/renderers/DetailViewRenderer.tsx | Read-only record rendering with action bar + related lists. |
| components/renderers/DashboardViewRenderer.tsx | Metadata-driven dashboard widget dispatcher (metric/card/list/chart placeholder). |
| components/renderers/fields/FieldRenderer.tsx | Field-type-to-native input mapping + display formatting helpers. |
| components/actions/index.ts | Central export surface for action components/utilities. |
| components/actions/ActionExecutor.ts | Executes url/api/flow/modal action types with basic templating. |
| components/actions/ActionBar.tsx | Horizontal toolbar UI for rendering action buttons. |
| components/actions/FloatingActionButton.tsx | FAB component with haptics. |
| app/(app)/[appName]/[objectName]/index.tsx | Swaps legacy list UI for ListViewRenderer + useFields/useView. |
| app/(app)/[appName]/[objectName]/[id].tsx | Swaps legacy detail UI for DetailViewRenderer + adds edit navigation. |
| app/(app)/[appName]/[objectName]/new.tsx | Adds create route using FormViewRenderer + useMutation('create'). |
| app/(app)/[appName]/[objectName]/[id]/edit.tsx | Adds edit route using FormViewRenderer + useMutation('update'). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /* ---- Error state ---- */ | ||
| if (error && !isLoading) { | ||
| return ( | ||
| <View className="flex-1 items-center justify-center px-6"> | ||
| <Text className="text-base text-destructive">{error.message}</Text> | ||
| {onRefresh && ( | ||
| <Pressable | ||
| className="mt-4 rounded-xl bg-primary px-5 py-3" | ||
| onPress={onRefresh} | ||
| > | ||
| <Text className="font-semibold text-primary-foreground">Retry</Text> | ||
| </Pressable> | ||
| )} | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| /* ---- Search state ---- */ | ||
| const [searchQuery, setSearchQuery] = useState(""); | ||
|
|
There was a problem hiding this comment.
useState for searchQuery is declared after an early return (if (error && !isLoading) return …). This violates the Rules of Hooks because the number/order of hooks changes when error is present, and will crash or be flagged by eslint-plugin-react-hooks. Move the useState (and any other hooks) above all conditional returns, or refactor error rendering so hooks are always called.
| if ((type === "url" || type === "email") && typeof value === "string" && value) { | ||
| const href = type === "email" ? `mailto:${value}` : value; | ||
| return ( | ||
| <Pressable onPress={() => Linking.openURL(href)}> | ||
| <Text className="text-base text-primary underline">{display}</Text> | ||
| </Pressable> |
There was a problem hiding this comment.
Linking.openURL(href) is fired from onPress without handling rejections. If the URL cannot be opened, this can cause an unhandled promise rejection. Wrap the call in void Linking.openURL(href).catch(...) (and/or Linking.canOpenURL) to handle failures gracefully.
| multiline={multiline} | ||
| textAlignVertical={multiline ? "top" : "center"} | ||
| placeholderTextColor="#9ca3af" | ||
| autoCapitalize={field.type === "email" ? "none" : "sentences"} |
There was a problem hiding this comment.
Password fields render through TextFieldInput, but the TextInput isn’t configured for sensitive input (e.g., secureTextEntry) and autoCapitalize will default to "sentences". Use secureTextEntry and disable autocapitalize/autocorrect for field.type === "password".
| autoCapitalize={field.type === "email" ? "none" : "sentences"} | |
| secureTextEntry={field.type === "password"} | |
| autoCapitalize={ | |
| field.type === "email" || field.type === "password" ? "none" : "sentences" | |
| } | |
| autoCorrect={field.type === "password" ? false : undefined} |
| case "multiselect": | ||
| case "checkboxes": | ||
| case "tags": | ||
| return <SelectFieldInput {...props} />; | ||
|
|
There was a problem hiding this comment.
multiselect / checkboxes / tags are routed to SelectFieldInput, but Select only supports a single string value. This means multi-valued fields can’t be edited correctly (arrays will be stringified and only one option can be chosen). Implement a multi-select UI (or a chips/checkbox list) and use an array value contract for these field types.
| import { Edit, Trash2, MoreHorizontal } from "lucide-react-native"; | ||
| import { cn } from "~/lib/utils"; |
There was a problem hiding this comment.
Unused imports (MoreHorizontal, cn) are present, which will fail linting under the current ESLint config. Remove them or use them.
| import { Edit, Trash2, MoreHorizontal } from "lucide-react-native"; | |
| import { cn } from "~/lib/utils"; | |
| import { Edit, Trash2 } from "lucide-react-native"; |
| contentContainerStyle={{ padding: 16, paddingBottom: 40 }} | ||
| refreshControl={ | ||
| onRefresh ? ( | ||
| <RefreshControl refreshing={false} onRefresh={onRefresh} /> |
There was a problem hiding this comment.
RefreshControl is hard-coded with refreshing={false}, so pull-to-refresh will never show the refreshing indicator even while onRefresh is running. Track a local isRefreshing state (especially if onRefresh returns a Promise) or accept an isRefreshing prop and pass it through here.
| <RefreshControl refreshing={false} onRefresh={onRefresh} /> | |
| <RefreshControl refreshing={isLoading} onRefresh={onRefresh} /> |
| @@ -0,0 +1,42 @@ | |||
| import React from "react"; | |||
| import { Pressable, View } from "react-native"; | |||
There was a problem hiding this comment.
View is imported but never used, which will fail linting. Remove the unused import.
| import { Pressable, View } from "react-native"; | |
| import { Pressable } from "react-native"; |
| const { appName, objectName } = useLocalSearchParams<{ | ||
| appName: string; |
There was a problem hiding this comment.
appName is destructured from useLocalSearchParams but never used, which will fail linting. Remove it from the destructuring (or use it for navigation if intended).
| const { appName, objectName } = useLocalSearchParams<{ | |
| appName: string; | |
| const { objectName } = useLocalSearchParams<{ |
| const { appName, objectName, id } = useLocalSearchParams<{ | ||
| appName: string; |
There was a problem hiding this comment.
appName is destructured from useLocalSearchParams but never used, which will fail linting. Remove it from the destructuring (or use it if needed).
| const { appName, objectName, id } = useLocalSearchParams<{ | |
| appName: string; | |
| const { objectName, id } = useLocalSearchParams<{ |
| const { data: schema } = useObject(objectName!); | ||
| const { data: viewData } = useView(objectName!, "form"); | ||
| const { data: fieldsData } = useFields(objectName!); |
There was a problem hiding this comment.
schema from useObject(objectName!) is never used. This will fail linting and also triggers an unnecessary query; remove the hook/variable or use it (e.g., to derive display fields).
Implements the metadata-driven rendering engine (Phase 2 of ROADMAP.md) that interprets ObjectUI schemas from the server and renders native mobile components.
View Renderers (
components/renderers/)registerRenderer()for future kanban/calendar/chart types)FormViewMeta, required-field validation, conditional visibility viavisibleOn, wired touseMutation()Action System (
components/actions/)url/api/flow/modalaction types with{field}template resolutionRoute Integration
[objectName]/index.tsx→ now usesListViewRendererwithuseFields()+useView()[objectName]/[id].tsx→ now usesDetailViewRendererwith action bar[objectName]/new.tsx— create form viauseMutation('create')[objectName]/[id]/edit.tsx— edit form viauseMutation('update')Types
components/renderers/types.tsdefines local interfaces (ListViewMeta,FormViewMeta,FormSection,FormFieldMeta,DashboardMeta,ActionMeta,FieldDefinition) mirroring@objectstack/spec/uishapes for stable renderer contracts.Remaining Phase 2 items (not in scope)
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.