Render rv_table custom action column inline (config-driven)

This commit is contained in:
Bhanu Prakash Sai Potteri 2026-05-22 23:56:13 +05:30
parent fe23207e5a
commit 5dfdab0d69

View File

@ -2,39 +2,97 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
X, Inbox, MoreHorizontal, ArrowRight, RefreshCw, X, Inbox, ArrowRight, RefreshCw, CheckCircle2,
} from "lucide-react"; } from "lucide-react";
import { getRVScreen, getRecordView, type RecordViewField, type SearchQuery } from "../api/viewService"; import { getRVScreen, getRecordView, type RecordViewField, type SearchQuery } from "../api/viewService";
import FormModal from "./FormModal"; import FormModal from "./FormModal";
import { STATE_IDS, ACTIVITY_IDS } from "../config";
import { CheckCircle2, RefreshCw as RefreshIcon } from "lucide-react";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Row actions by state // Action column (config-driven from rv-screen layout)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ROW_ACTIONS: Record<string, Array<{ activityId: string; label: string; icon: React.ReactNode }>> = { interface ActionCondition {
[STATE_IDS.SCHEDULED]: [ field_key: string;
{ activityId: ACTIVITY_IDS.MARK_TEST_DRIVE_DONE, label: "Mark Test Drive Done", icon: <CheckCircle2 size={13} /> }, operator: string;
], value: unknown;
[STATE_IDS.SCHEDULING_CALL_FAILED]: [ data_type?: string;
{ activityId: ACTIVITY_IDS.RETRY_SCHEDULING_CALL, label: "Retry Scheduling Call", icon: <RefreshIcon size={13} /> }, }
], interface ActionConditions {
[STATE_IDS.FEEDBACK_CALL_FAILED]: [ logic?: "AND" | "OR";
{ activityId: ACTIVITY_IDS.RETRY_FEEDBACK_CALL, label: "Retry Feedback Call", icon: <RefreshIcon size={13} /> }, conditions: ActionCondition[];
], }
interface ActionButton {
name: string;
icon?: string;
classes?: string[];
job_template?: string;
params?: { activity_uid?: string; workflow_uuid?: string; [k: string]: unknown };
show_conditions?: ActionConditions | null;
action_end?: Array<{ type: string }>;
}
interface ActionColumn {
id: string;
display: string;
buttons: ActionButton[];
}
const ICONS: Record<string, React.ReactNode> = {
refresh: <RefreshCw size={13} />,
task_alt: <CheckCircle2 size={13} />,
}; };
// State name (as returned by record view) → state UID. Used to resolve row actions. function iconFor(name?: string): React.ReactNode {
const STATE_BY_NAME: Record<string, string> = { return (name && ICONS[name]) || <RefreshCw size={13} />;
"Awaiting Scheduler Call": STATE_IDS.AWAITING_SCHEDULER_CALL, }
"Scheduled": STATE_IDS.SCHEDULED,
"Awaiting Feedback Call": STATE_IDS.AWAITING_FEEDBACK_CALL, // Walks the rv-screen layout to find columns with `type: "custom"` and their
"Feedback Collected": STATE_IDS.FEEDBACK_COLLECTED, // inner button configs.
"Scheduling Call Failed": STATE_IDS.SCHEDULING_CALL_FAILED, function extractActionColumns(layout: any): ActionColumn[] {
"Feedback Call Failed": STATE_IDS.FEEDBACK_CALL_FAILED, const out: ActionColumn[] = [];
"End State": STATE_IDS.END_STATE, const walk = (nodes: any[]): void => {
}; for (const n of nodes ?? []) {
const cfg = n?.config;
if (cfg?.type === "rv_table" && Array.isArray(cfg.columns)) {
for (const col of cfg.columns) {
if (col?.type === "custom" && Array.isArray(col.config)) {
const buttons = col.config.filter((b: any) => b?.type === "button");
if (buttons.length > 0) {
out.push({ id: col.id, display: col.display || "Actions", buttons });
}
}
}
}
if (Array.isArray(n?.children)) walk(n.children);
}
};
walk(Array.isArray(layout) ? layout : []);
return out;
}
function evalShowConditions(c: ActionConditions | null | undefined, row: Record<string, unknown>): boolean {
if (!c || !Array.isArray(c.conditions) || c.conditions.length === 0) return true;
const results = c.conditions.map((cond) => {
const v = row[cond.field_key];
switch (cond.operator) {
case "equals": return v === cond.value;
case "not_equals": return v !== cond.value;
case "is_empty": return v == null || v === "";
case "is_not_empty": return v != null && v !== "";
case "in": return Array.isArray(cond.value) && cond.value.includes(v as never);
case "not_in": return Array.isArray(cond.value) && !cond.value.includes(v as never);
default: return false;
}
});
return (c.logic === "OR") ? results.some(Boolean) : results.every(Boolean);
}
function buttonClass(classes?: string[]): string {
const ghost = classes?.includes("z-btn-secondary");
const base = "h-7 px-2.5 text-[11px] font-semibold rounded-lg inline-flex items-center gap-1 transition-all";
return ghost
? `${base} text-stone-600 dark:text-zinc-300 bg-white dark:bg-zinc-800 border border-stone-200 dark:border-zinc-700 hover:bg-stone-50 dark:hover:bg-zinc-700`
: `${base} text-white mh-glow hover:scale-[1.02]`;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Skeleton // Skeleton
@ -109,6 +167,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
const navigate = useNavigate(); const navigate = useNavigate();
const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null); const [rvTemplateUid, setRvTemplateUid] = useState<string | null>(null);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [actionColumns, setActionColumns] = useState<ActionColumn[]>([]);
const [fields, setFields] = useState<RecordViewField[]>([]); const [fields, setFields] = useState<RecordViewField[]>([]);
const [rows, setRows] = useState<Record<string, unknown>[]>([]); const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 10, total_count: 0, total_pages: 0 }); const [pagination, setPagination] = useState({ page: 1, limit: 10, total_count: 0, total_pages: 0 });
@ -124,9 +183,11 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
setLoading(true); setLoading(true);
setRvTemplateUid(null); setRvTemplateUid(null);
setColumnLabels({}); setColumnLabels({});
setActionColumns([]);
getRVScreen(viewId) getRVScreen(viewId)
.then((rv) => { .then((rv) => {
setColumnLabels(extractColumnLabels(rv.layout)); setColumnLabels(extractColumnLabels(rv.layout));
setActionColumns(extractActionColumns(rv.layout));
setRvTemplateUid(rv.rv_template_uid); setRvTemplateUid(rv.rv_template_uid);
}) })
.catch((err) => { setError(err.message); setLoading(false); }); .catch((err) => { setError(err.message); setLoading(false); });
@ -298,6 +359,11 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
</th> </th>
); );
})} })}
{actionColumns.map((ac) => (
<th key={ac.id} className="py-2.5 px-4 text-left text-[12px] font-semibold text-stone-500 dark:text-zinc-400 whitespace-nowrap">
{ac.display}
</th>
))}
<th className="py-2.5 pr-4 w-16" /> <th className="py-2.5 pr-4 w-16" />
</tr> </tr>
</thead> </thead>
@ -305,7 +371,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
<tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60"> <tbody className="divide-y divide-stone-100/80 dark:divide-zinc-800/60">
{rows.length === 0 ? ( {rows.length === 0 ? (
<tr> <tr>
<td colSpan={fields.length + 1} className="py-20 text-center"> <td colSpan={fields.length + 1 + actionColumns.length} className="py-20 text-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="w-14 h-14 rounded-2xl flex items-center justify-center" <div className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: "linear-gradient(135deg,#fff5f5,#ffe4e6)" }}> style={{ background: "linear-gradient(135deg,#fff5f5,#ffe4e6)" }}>
@ -325,12 +391,7 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
</div> </div>
</td> </td>
</tr> </tr>
) : rows.map((row, i) => { ) : rows.map((row, i) => (
const stateName = String(row.current_state_name ?? "");
const stateId = STATE_BY_NAME[stateName];
const actions = stateId ? ROW_ACTIONS[stateId] : undefined;
return (
<tr <tr
key={String(row.instance_id ?? i)} key={String(row.instance_id ?? i)}
onClick={() => handleRowClick(row)} onClick={() => handleRowClick(row)}
@ -358,27 +419,48 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
); );
})} })}
<td className="pr-4 py-3 w-16 text-right" onClick={(e) => e.stopPropagation()}> {actionColumns.map((ac) => {
<div className="flex items-center justify-end gap-1 opacity-0 group-hover/row:opacity-100 transition-opacity"> const visible = ac.buttons.filter((b) => evalShowConditions(b.show_conditions, row));
{actions && actions.length > 0 && ( return (
<RowMenu <td key={ac.id} className="px-4 py-3 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
actions={actions} {visible.length === 0 ? (
onAction={(activityId, label) => <span className="text-stone-300 dark:text-zinc-700"></span>
setFormModal({ activityId, instanceId: String(row.instance_id), title: label }) ) : (
} <div className="flex items-center gap-1.5">
/> {visible.map((b, bi) => {
const aid = b.params?.activity_uid;
if (!aid) return null;
return (
<button
key={bi}
onClick={() => setFormModal({
activityId: aid,
instanceId: String(row.instance_id),
title: b.name,
})}
className={buttonClass(b.classes)}
style={!b.classes?.includes("z-btn-secondary") ? { background: "linear-gradient(135deg, #C8102E, #E84258)" } : {}}
>
{iconFor(b.icon)}{b.name}
</button>
);
})}
</div>
)} )}
</td>
);
})}
<td className="pr-4 py-3 w-16 text-right" onClick={(e) => e.stopPropagation()}>
<button <button
onClick={() => handleRowClick(row)} onClick={() => handleRowClick(row)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-100 dark:hover:bg-rose-950/50 transition-colors" className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-100 dark:hover:bg-rose-950/50 transition-colors opacity-0 group-hover/row:opacity-100 transition-opacity"
> >
<ArrowRight size={14} /> <ArrowRight size={14} />
</button> </button>
</div>
</td> </td>
</tr> </tr>
); ))}
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -458,55 +540,6 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
); );
} }
// ---------------------------------------------------------------------------
// Row menu
// ---------------------------------------------------------------------------
interface RowMenuProps {
actions: Array<{ activityId: string; label: string; icon: React.ReactNode }>;
onAction: (activityId: string, label: string) => void;
}
function RowMenu({ actions, onAction }: RowMenuProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-stone-400 dark:text-zinc-500 hover:text-stone-600 dark:hover:text-zinc-300 hover:bg-stone-100 dark:hover:bg-zinc-800 transition-colors"
>
<MoreHorizontal size={15} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-40 bg-white dark:bg-zinc-900 rounded-xl shadow-xl shadow-stone-900/10 dark:shadow-black/40 border border-stone-200 dark:border-zinc-700/70 py-1 overflow-hidden">
{actions.map((a) => (
<button
key={a.activityId}
onClick={() => { setOpen(false); onAction(a.activityId, a.label); }}
className="w-full text-left px-3.5 py-2 text-[13px] text-stone-600 dark:text-zinc-300 hover:bg-stone-50 dark:hover:bg-zinc-800 flex items-center gap-2.5 transition-colors"
>
<span className="text-stone-400 dark:text-zinc-500">{a.icon}</span>
{a.label}
</button>
))}
</div>
)}
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------