Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/(app)/[appName]/[objectName]/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ export default function ObjectDetailScreen() {
}
}, [confirm, client, objectName, id, router, t, toastError]);

/* ---- Inline field edit (select/status badges on the detail) ---- */
const handleFieldEdit = useCallback(
async (field: string, value: unknown) => {
if (!objectName || !id) return;
try {
await client.data.update(objectName, id, { [field]: value });
await fetchRecord();
toastSuccess(t("records.updated"));
} catch {
toastError(t("records.updateFailed"));
}
},
[client, objectName, id, fetchRecord, toastSuccess, toastError, t],
);

/* ---- Object actions (record_header inline, record_more overflow) ---- */
const allActions = useMemo<ActionMeta[]>(
() => ((meta?.actions as ActionMeta[] | undefined) ?? []).filter(isActionVisible),
Expand Down Expand Up @@ -213,6 +228,7 @@ export default function ObjectDetailScreen() {
actions={headerActions}
moreActions={moreActions}
onAction={runAction}
onFieldEdit={handleFieldEdit}
busyActionName={busyName}
relatedLists={relatedLists}
onRelatedRecordPress={handleRelatedRecordPress}
Expand Down
128 changes: 121 additions & 7 deletions components/renderers/DetailViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
ChevronRight,
AlertCircle,
MoreHorizontal,
Check,
Pencil,
} from "lucide-react-native";
import { cn } from "~/lib/utils";
import { getIcon } from "~/lib/getIcon";
Expand All @@ -18,7 +20,7 @@ import { Button } from "~/components/ui/Button";
import { BottomSheet } from "~/components/ui/BottomSheet";
import { Skeleton } from "~/components/ui/Skeleton";
import { isFieldVisible, isSectionVisible } from "~/lib/conditional-fields";
import { FieldRenderer } from "./fields/FieldRenderer";
import { FieldRenderer, isSelectType, OptionBadge } from "./fields/FieldRenderer";
import type { FieldDefinition, FormViewMeta, FormSection, ActionMeta } from "./types";

/** Skeleton placeholder that mirrors the detail's stacked section/field layout. */
Expand Down Expand Up @@ -93,6 +95,12 @@ export interface DetailViewRendererProps {
allowDelete?: boolean;
/** Extra content rendered at the end of the scroll body (e.g. lifecycle diagram). */
footer?: React.ReactNode;
/**
* Inline-edit a select/status field straight from the detail. When provided,
* those fields render a tappable badge that opens an option picker; the
* handler persists the change. Omit to keep the detail strictly read-only.
*/
onFieldEdit?: (field: string, value: unknown) => void | Promise<void>;
}

export interface RelatedListConfig {
Expand Down Expand Up @@ -470,6 +478,89 @@ function RecordNavigator({
);
}

/* ------------------------------------------------------------------ */
/* Inline select edit */
/* ------------------------------------------------------------------ */

/**
* A select/status field shown as its coloured badge with a pencil affordance.
* Tapping opens a picker; choosing a new option calls `onEdit` (which persists)
* and reflects a saving spinner until it resolves.
*/
function EditableSelectField({
label,
field,
value,
onEdit,
}: {
label: string;
field: FieldDefinition;
value: unknown;
onEdit: (value: unknown) => void | Promise<void>;
}) {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
const accent = colorScheme === "dark" ? "#60a5fa" : "#1e40af";
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const options = field.options ?? [];

return (
<View className="gap-1">
<Text className="text-sm font-medium text-muted-foreground">{label}</Text>
<Pressable
className="flex-row items-center gap-2 self-start active:opacity-70"
onPress={() => setOpen(true)}
accessibilityRole="button"
accessibilityLabel={t("records.editField", { field: label })}
>
{value != null && value !== "" ? (
<OptionBadge field={field} value={value} />
) : (
<Text className="text-base text-muted-foreground">—</Text>
)}
{saving ? (
<ActivityIndicator size="small" color={accent} />
) : (
<Pencil size={13} color="#94a3b8" />
)}
</Pressable>

<BottomSheet open={open} onOpenChange={setOpen} title={label}>
{options.map((opt) => {
const selected = String(opt.value) === String(value);
return (
<Pressable
key={String(opt.value)}
className="flex-row items-center justify-between rounded-lg px-2 py-3 active:bg-muted"
disabled={saving}
accessibilityRole="button"
accessibilityState={{ selected }}
accessibilityLabel={opt.label ?? String(opt.value)}
onPress={async () => {
if (selected) {
setOpen(false);
return;
}
setSaving(true);
try {
await onEdit(opt.value);
} finally {
setSaving(false);
setOpen(false);
}
}}
>
<OptionBadge field={field} value={opt.value} />
{selected && <Check size={18} color={accent} />}
</Pressable>
);
})}
</BottomSheet>
</View>
);
}

/* ------------------------------------------------------------------ */
/* Main Component */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -497,6 +588,7 @@ export function DetailViewRenderer({
allowEdit = true,
allowDelete = true,
footer,
onFieldEdit,
}: DetailViewRendererProps) {
const { t } = useTranslation();

Expand Down Expand Up @@ -647,15 +739,37 @@ export function DetailViewRenderer({
fieldDef?.label ??
fieldName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());

const fieldShape = {
name: fieldName,
label,
type: fieldDef?.type ?? "text",
options: fieldDef?.options,
};

// Inline-editable select/status fields (when a handler is wired
// and the field carries options) become a tappable badge.
const editable =
!!onFieldEdit &&
isSelectType(fieldShape.type) &&
(fieldDef?.options?.length ?? 0) > 0 &&
!meta?.readonly;

if (editable) {
return (
<EditableSelectField
key={fieldName}
label={label}
field={fieldShape}
value={record[fieldName]}
onEdit={(v) => onFieldEdit!(fieldName, v)}
/>
);
}

return (
<FieldRenderer
key={fieldName}
field={{
name: fieldName,
label,
type: fieldDef?.type ?? "text",
options: fieldDef?.options,
}}
field={fieldShape}
value={record[fieldName]}
readonly
/>
Expand Down
3 changes: 3 additions & 0 deletions locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
"deleteConfirm": "هل أنت متأكد من أنك تريد حذف هذا السجل؟",
"deleteConfirmNamed": "هل أنت متأكد من أنك تريد حذف \"{{label}}\"؟",
"deleteFailed": "فشل حذف السجل.",
"updated": "تم التحديث",
"updateFailed": "فشل تحديث السجل.",
"editField": "تعديل {{field}}",
"recordSaved": "تم حفظ السجل بنجاح.",
"recordDeleted": "تم حذف السجل بنجاح.",
"previous": "السابق",
Expand Down
3 changes: 3 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@
"deleteConfirm": "Are you sure you want to delete this record?",
"deleteConfirmNamed": "Are you sure you want to delete \"{{label}}\"?",
"deleteFailed": "Failed to delete the record.",
"updated": "Updated",
"updateFailed": "Failed to update the record.",
"editField": "Edit {{field}}",
"recordSaved": "Record saved successfully.",
"recordDeleted": "Record deleted successfully.",
"previous": "Previous",
Expand Down
3 changes: 3 additions & 0 deletions locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
"deleteConfirm": "确定要删除此记录吗?",
"deleteConfirmNamed": "确定要删除“{{label}}”吗?",
"deleteFailed": "删除记录失败。",
"updated": "已更新",
"updateFailed": "更新记录失败。",
"editField": "编辑{{field}}",
"recordSaved": "记录保存成功。",
"recordDeleted": "记录删除成功。",
"previous": "上一条",
Expand Down
Loading