Skip to content

Commit 8e2e494

Browse files
authored
Merge pull request #6 from objectstack-ai/copilot/start-development-phase-2
2 parents 59d2c48 + 9b5ce36 commit 8e2e494

17 files changed

Lines changed: 2260 additions & 108 deletions
Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1-
import { View, Text, ScrollView, ActivityIndicator, Pressable } from "react-native";
21
import { SafeAreaView } from "react-native-safe-area-context";
3-
import { useLocalSearchParams, Stack } from "expo-router";
4-
import { useClient, useObject } from "@objectstack/client-react";
2+
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
3+
import { useClient, useObject, useView, useFields } from "@objectstack/client-react";
54
import { useEffect, useState, useCallback } from "react";
5+
import { DetailViewRenderer } from "~/components/renderers";
6+
import type { FieldDefinition, FormViewMeta } from "~/components/renderers";
67

78
export default function ObjectDetailScreen() {
8-
const { objectName, id } = useLocalSearchParams<{
9+
const { appName, objectName, id } = useLocalSearchParams<{
910
appName: string;
1011
objectName: string;
1112
id: string;
1213
}>();
1314
const client = useClient();
15+
const router = useRouter();
1416
const { data: schema } = useObject(objectName!);
17+
const { data: viewData } = useView(objectName!, "form");
18+
const { data: fieldsData } = useFields(objectName!);
1519

1620
const [record, setRecord] = useState<Record<string, any> | null>(null);
1721
const [isLoading, setIsLoading] = useState(true);
@@ -38,44 +42,23 @@ export default function ObjectDetailScreen() {
3842
const displayName =
3943
record?.name ?? record?.label ?? record?.title ?? record?.subject ?? "Record Detail";
4044

45+
const fields: FieldDefinition[] = fieldsData ?? [];
46+
const formView: FormViewMeta | undefined = viewData ?? undefined;
47+
4148
return (
4249
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
4350
<Stack.Screen options={{ title: String(displayName) }} />
44-
{isLoading ? (
45-
<View className="flex-1 items-center justify-center">
46-
<ActivityIndicator size="large" color="#1e40af" />
47-
</View>
48-
) : error ? (
49-
<View className="flex-1 items-center justify-center px-6">
50-
<Text className="text-base text-destructive">{error.message}</Text>
51-
<Pressable
52-
className="mt-4 rounded-xl bg-primary px-5 py-3"
53-
onPress={fetchRecord}
54-
>
55-
<Text className="font-semibold text-primary-foreground">Retry</Text>
56-
</Pressable>
57-
</View>
58-
) : record ? (
59-
<ScrollView className="flex-1" contentContainerClassName="px-5 pb-8 pt-4">
60-
<View className="gap-4">
61-
{Object.entries(record)
62-
.filter(([key]) => !key.startsWith("_") && key !== "id")
63-
.map(([key, value]) => {
64-
const fieldLabel = key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
65-
return (
66-
<View key={key} className="rounded-xl border border-border bg-card p-4">
67-
<Text className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
68-
{fieldLabel}
69-
</Text>
70-
<Text className="mt-1 text-base text-card-foreground">
71-
{value != null ? String(value) : "—"}
72-
</Text>
73-
</View>
74-
);
75-
})}
76-
</View>
77-
</ScrollView>
78-
) : null}
51+
<DetailViewRenderer
52+
view={formView}
53+
fields={fields}
54+
record={record}
55+
isLoading={isLoading}
56+
error={error}
57+
onRetry={fetchRecord}
58+
onEdit={() =>
59+
router.push(`/(app)/${appName}/${objectName}/${id}/edit` as any)
60+
}
61+
/>
7962
</SafeAreaView>
8063
);
8164
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { SafeAreaView } from "react-native-safe-area-context";
2+
import { View, Text, ActivityIndicator, Pressable } from "react-native";
3+
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
4+
import { useClient, useFields, useMutation } from "@objectstack/client-react";
5+
import { useEffect, useState, useCallback } from "react";
6+
import { FormViewRenderer } from "~/components/renderers";
7+
import type { FieldDefinition } from "~/components/renderers";
8+
9+
export default function EditRecordScreen() {
10+
const { appName, objectName, id } = useLocalSearchParams<{
11+
appName: string;
12+
objectName: string;
13+
id: string;
14+
}>();
15+
const client = useClient();
16+
const router = useRouter();
17+
const { data: fieldsData } = useFields(objectName!);
18+
const { mutate, isLoading: isSubmitting } = useMutation(objectName!, "update", {
19+
onSuccess: () => {
20+
router.back();
21+
},
22+
});
23+
24+
const [record, setRecord] = useState<Record<string, any> | null>(null);
25+
const [isLoading, setIsLoading] = useState(true);
26+
const [loadError, setLoadError] = useState<string | null>(null);
27+
28+
const fetchRecord = useCallback(async () => {
29+
if (!objectName || !id) return;
30+
setIsLoading(true);
31+
setLoadError(null);
32+
try {
33+
const result = await client.data.get(objectName, id);
34+
setRecord(result.record ?? result);
35+
} catch (err) {
36+
setLoadError(
37+
err instanceof Error ? err.message : "Failed to load record",
38+
);
39+
} finally {
40+
setIsLoading(false);
41+
}
42+
}, [client, objectName, id]);
43+
44+
useEffect(() => {
45+
fetchRecord();
46+
}, [fetchRecord]);
47+
48+
const displayName =
49+
objectName?.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) ?? "Record";
50+
51+
const fields: FieldDefinition[] = fieldsData ?? [];
52+
53+
if (isLoading) {
54+
return (
55+
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
56+
<Stack.Screen options={{ title: `Edit ${displayName}` }} />
57+
<View className="flex-1 items-center justify-center">
58+
<ActivityIndicator size="large" color="#1e40af" />
59+
</View>
60+
</SafeAreaView>
61+
);
62+
}
63+
64+
if (loadError) {
65+
return (
66+
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
67+
<Stack.Screen options={{ title: `Edit ${displayName}` }} />
68+
<View className="flex-1 items-center justify-center px-6">
69+
<Text className="text-base text-destructive">{loadError}</Text>
70+
<Pressable
71+
className="mt-4 rounded-xl bg-primary px-5 py-3"
72+
onPress={fetchRecord}
73+
>
74+
<Text className="font-semibold text-primary-foreground">Retry</Text>
75+
</Pressable>
76+
</View>
77+
</SafeAreaView>
78+
);
79+
}
80+
81+
return (
82+
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
83+
<Stack.Screen options={{ title: `Edit ${displayName}` }} />
84+
<FormViewRenderer
85+
fields={fields}
86+
initialValues={record ?? {}}
87+
onSubmit={(values) => mutate({ id, data: values } as any)}
88+
onCancel={() => router.back()}
89+
isSubmitting={isSubmitting}
90+
submitLabel="Save"
91+
/>
92+
</SafeAreaView>
93+
);
94+
}
Lines changed: 19 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { View, Text, FlatList, Pressable, ActivityIndicator } from "react-native";
21
import { SafeAreaView } from "react-native-safe-area-context";
32
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
4-
import { useQuery, useView } from "@objectstack/client-react";
3+
import { useQuery, useView, useFields } from "@objectstack/client-react";
4+
import { ListViewRenderer } from "~/components/renderers";
5+
import type { FieldDefinition, ListViewMeta } from "~/components/renderers";
56

67
export default function ObjectListScreen() {
78
const { appName, objectName } = useLocalSearchParams<{
@@ -11,6 +12,7 @@ export default function ObjectListScreen() {
1112
const router = useRouter();
1213

1314
const { data: viewData, isLoading: viewLoading } = useView(objectName!, "list");
15+
const { data: fieldsData } = useFields(objectName!);
1416
const { data, isLoading, error, refetch } = useQuery(objectName!, {
1517
top: 50,
1618
enabled: !!objectName,
@@ -20,56 +22,25 @@ export default function ObjectListScreen() {
2022
objectName?.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) ?? "Objects";
2123

2224
const records = data?.records ?? [];
25+
const fields: FieldDefinition[] = fieldsData ?? [];
26+
const listView: ListViewMeta | undefined = viewData ?? undefined;
2327

2428
return (
2529
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
2630
<Stack.Screen options={{ title: displayName }} />
27-
{isLoading || viewLoading ? (
28-
<View className="flex-1 items-center justify-center">
29-
<ActivityIndicator size="large" color="#1e40af" />
30-
</View>
31-
) : error ? (
32-
<View className="flex-1 items-center justify-center px-6">
33-
<Text className="text-base text-destructive">{error.message}</Text>
34-
<Pressable
35-
className="mt-4 rounded-xl bg-primary px-5 py-3"
36-
onPress={refetch}
37-
>
38-
<Text className="font-semibold text-primary-foreground">Retry</Text>
39-
</Pressable>
40-
</View>
41-
) : (
42-
<FlatList
43-
data={records}
44-
keyExtractor={(item: any, index: number) => item.id ?? item._id ?? String(index)}
45-
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
46-
renderItem={({ item }: { item: any }) => (
47-
<Pressable
48-
className="mb-2 rounded-xl border border-border bg-card p-4"
49-
onPress={() =>
50-
router.push(`/(app)/${appName}/${objectName}/${item.id ?? item._id}`)
51-
}
52-
>
53-
<Text className="text-base font-medium text-card-foreground">
54-
{item.name ?? item.label ?? item.title ?? item.subject ?? item.id ?? "Record"}
55-
</Text>
56-
{item.description ? (
57-
<Text className="mt-1 text-sm text-muted-foreground" numberOfLines={2}>
58-
{item.description}
59-
</Text>
60-
) : null}
61-
</Pressable>
62-
)}
63-
ListEmptyComponent={
64-
<View className="items-center justify-center pt-20">
65-
<Text className="text-lg font-semibold text-foreground">No Records</Text>
66-
<Text className="mt-2 text-sm text-muted-foreground">
67-
No records found for this object.
68-
</Text>
69-
</View>
70-
}
71-
/>
72-
)}
31+
<ListViewRenderer
32+
view={listView}
33+
fields={fields}
34+
records={records}
35+
isLoading={isLoading || viewLoading}
36+
error={error}
37+
onRefresh={refetch}
38+
onRowPress={(record) =>
39+
router.push(
40+
`/(app)/${appName}/${objectName}/${(record.id ?? record._id) as string}`,
41+
)
42+
}
43+
/>
7344
</SafeAreaView>
7445
);
7546
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { SafeAreaView } from "react-native-safe-area-context";
2+
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
3+
import { useFields, useMutation } from "@objectstack/client-react";
4+
import { FormViewRenderer } from "~/components/renderers";
5+
import type { FieldDefinition } from "~/components/renderers";
6+
7+
export default function CreateRecordScreen() {
8+
const { appName, objectName } = useLocalSearchParams<{
9+
appName: string;
10+
objectName: string;
11+
}>();
12+
const router = useRouter();
13+
const { data: fieldsData } = useFields(objectName!);
14+
const { mutate, isLoading: isSubmitting } = useMutation(objectName!, "create", {
15+
onSuccess: () => {
16+
router.back();
17+
},
18+
});
19+
20+
const displayName =
21+
objectName?.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) ?? "Record";
22+
23+
const fields: FieldDefinition[] = fieldsData ?? [];
24+
25+
return (
26+
<SafeAreaView className="flex-1 bg-background" edges={["left", "right"]}>
27+
<Stack.Screen options={{ title: `New ${displayName}` }} />
28+
<FormViewRenderer
29+
fields={fields}
30+
onSubmit={(values) => mutate(values as any)}
31+
onCancel={() => router.back()}
32+
isSubmitting={isSubmitting}
33+
submitLabel="Create"
34+
/>
35+
</SafeAreaView>
36+
);
37+
}

components/actions/ActionBar.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from "react";
2+
import { View, Text, Pressable, ScrollView } from "react-native";
3+
import { cn } from "~/lib/utils";
4+
import type { ActionMeta } from "../renderers/types";
5+
6+
/* ------------------------------------------------------------------ */
7+
/* Props */
8+
/* ------------------------------------------------------------------ */
9+
10+
export interface ActionBarProps {
11+
actions: ActionMeta[];
12+
onAction: (action: ActionMeta) => void;
13+
className?: string;
14+
}
15+
16+
/* ------------------------------------------------------------------ */
17+
/* Component */
18+
/* ------------------------------------------------------------------ */
19+
20+
export function ActionBar({ actions, onAction, className }: ActionBarProps) {
21+
if (actions.length === 0) return null;
22+
23+
return (
24+
<ScrollView
25+
horizontal
26+
showsHorizontalScrollIndicator={false}
27+
className={cn("border-b border-border bg-card", className)}
28+
contentContainerClassName="flex-row items-center gap-2 px-4 py-3"
29+
>
30+
{actions.map((action) => {
31+
const variant = getButtonVariant(action);
32+
return (
33+
<Pressable
34+
key={action.name}
35+
className={cn(
36+
"flex-row items-center rounded-lg px-4 py-2",
37+
variant === "primary" && "bg-primary",
38+
variant === "destructive" && "bg-destructive",
39+
variant === "outline" && "border border-border",
40+
)}
41+
onPress={() => onAction(action)}
42+
>
43+
<Text
44+
className={cn(
45+
"text-sm font-semibold",
46+
variant === "primary" && "text-primary-foreground",
47+
variant === "destructive" && "text-destructive-foreground",
48+
variant === "outline" && "text-foreground",
49+
)}
50+
>
51+
{action.label}
52+
</Text>
53+
</Pressable>
54+
);
55+
})}
56+
</ScrollView>
57+
);
58+
}
59+
60+
/* ------------------------------------------------------------------ */
61+
/* Helpers */
62+
/* ------------------------------------------------------------------ */
63+
64+
function getButtonVariant(
65+
action: ActionMeta,
66+
): "primary" | "destructive" | "outline" {
67+
if (action.name.includes("delete") || action.name.includes("remove")) {
68+
return "destructive";
69+
}
70+
if (
71+
action.component === "action:button" ||
72+
action.type === "api" ||
73+
action.type === "flow"
74+
) {
75+
return "primary";
76+
}
77+
return "outline";
78+
}

0 commit comments

Comments
 (0)