render the workflow-launch buttons configured on rv_screens
The "Create New Invoice" button was configured all along, on the rv_screen layout rather than the screen layout, and was being dropped twice over: RecordViewTable fetched the rv_screen but kept only rv_template_uid and threw away `layout`, and ScreenRenderer matched only the legacy params.activity_id_init while current studio writes activity_uid_init. collectStartButtons deep-walks either shape — the button nested under a node's `config` as screen layouts do, or carrying type/params/job_template at its own top level as rv_screens do — and accepts activity_uid_init, activity_id_init, activity_uid and activity_id, newest key first. RecordViewTable renders any start_state_machine buttons from its rv_screen in the toolbar alongside the injected toolbarAction; ScreenRenderer routes through the same helper and now skips buttons with no resolvable activity instead of rendering a dead control. Verified against app 169's live rv_screen config: yields exactly one button, "Create New Invoice" -> Submit Invoice, ignoring the show_detailview_popup entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
db8233fa45
commit
dc2e281347
@ -3,12 +3,13 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
|
ChevronLeft, ChevronRight, Search, ArrowUp, ArrowDown,
|
||||||
X, CheckCircle2, XCircle, PauseCircle, MessageSquare,
|
X, CheckCircle2, XCircle, PauseCircle, MessageSquare,
|
||||||
Inbox, MoreHorizontal, ArrowRight, RefreshCw,
|
Inbox, MoreHorizontal, ArrowRight, RefreshCw, Plus,
|
||||||
} 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 { TableSkeleton } from "./Skeleton";
|
import { TableSkeleton } from "./Skeleton";
|
||||||
import FormModal from "./FormModal";
|
import FormModal from "./FormModal";
|
||||||
import { STATE_IDS, ACTIVITY_IDS } from "../config";
|
import { STATE_IDS, ACTIVITY_IDS } from "../config";
|
||||||
|
import { startWorkflowButtons, type StartButton } from "../lib/startButtons";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Row actions by state
|
// Row actions by state
|
||||||
@ -46,13 +47,21 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dataLoading, setDataLoading] = useState(false);
|
const [dataLoading, setDataLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null);
|
// instanceId is absent for start-workflow buttons (they create the instance).
|
||||||
|
const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title: string } | null>(null);
|
||||||
|
// Workflow-launching buttons configured on the rv_screen ("Create New
|
||||||
|
// Invoice" and friends). They live in the rv_screen layout, not the screen
|
||||||
|
// layout, so this is the only place they can be picked up.
|
||||||
|
const [startButtons, setStartButtons] = useState<StartButton[]>([]);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getRVScreen(viewId)
|
getRVScreen(viewId)
|
||||||
.then((rv) => setRvTemplateId(rv.rv_template_uid))
|
.then((rv) => {
|
||||||
|
setRvTemplateId(rv.rv_template_uid);
|
||||||
|
setStartButtons(startWorkflowButtons(rv.layout));
|
||||||
|
})
|
||||||
.catch((err) => { setError(err.message); setLoading(false); });
|
.catch((err) => { setError(err.message); setLoading(false); });
|
||||||
}, [viewId]);
|
}, [viewId]);
|
||||||
|
|
||||||
@ -145,8 +154,18 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toolbarAction && (
|
{(startButtons.length > 0 || toolbarAction) && (
|
||||||
<div className="pl-3 border-l border-slate-200 dark:border-zinc-700">
|
<div className="pl-3 border-l border-slate-200 dark:border-zinc-700 flex items-center gap-2">
|
||||||
|
{startButtons.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.key}
|
||||||
|
onClick={() => setFormModal({ activityId: b.activityId, title: b.label })}
|
||||||
|
className="h-8 px-3.5 text-[12px] font-semibold text-white rounded-xl inline-flex items-center gap-1.5 transition-all hover:scale-[1.02] active:scale-[0.98] shadow-md shadow-violet-500/20"
|
||||||
|
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }}
|
||||||
|
>
|
||||||
|
<Plus size={13} strokeWidth={2.5} />{b.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
{toolbarAction}
|
{toolbarAction}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { LayoutElement } from "../api/viewService";
|
|||||||
import RecordViewTable from "./RecordViewTable";
|
import RecordViewTable from "./RecordViewTable";
|
||||||
import DetailViewPanel from "./DetailViewPanel";
|
import DetailViewPanel from "./DetailViewPanel";
|
||||||
import FormModal from "./FormModal";
|
import FormModal from "./FormModal";
|
||||||
|
import { collectStartButtons } from "../lib/startButtons";
|
||||||
|
|
||||||
interface Props { layout: LayoutElement[]; instanceId?: string; onRefresh?: () => void; onRowClick?: (instanceId: string) => void; }
|
interface Props { layout: LayoutElement[]; instanceId?: string; onRefresh?: () => void; onRowClick?: (instanceId: string) => void; }
|
||||||
|
|
||||||
@ -25,7 +26,11 @@ export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowCli
|
|||||||
const visit = (el: any) => {
|
const visit = (el: any) => {
|
||||||
const cfg = el.config || {};
|
const cfg = el.config || {};
|
||||||
if (cfg.type === "button" || cfg.job_template) {
|
if (cfg.type === "button" || cfg.job_template) {
|
||||||
const aid = cfg.params?.activity_id_init || cfg.params?.activity_id;
|
// Current studio writes params.activity_uid_init; older configs used
|
||||||
|
// activity_id_init. collectStartButtons accepts both — see lib/startButtons.
|
||||||
|
const [btn] = collectStartButtons(cfg);
|
||||||
|
const aid = btn?.activityId;
|
||||||
|
if (!aid) return; // a button with no activity to run is not renderable
|
||||||
const label = (cfg.name || "").replace(/^\+\s*/, "");
|
const label = (cfg.name || "").replace(/^\+\s*/, "");
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<button
|
<button
|
||||||
|
|||||||
86
src/lib/startButtons.ts
Normal file
86
src/lib/startButtons.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
// collectStartButtons — find the workflow-launching buttons in a studio layout.
|
||||||
|
//
|
||||||
|
// Studio puts these buttons in two different places, in two different shapes,
|
||||||
|
// which is why they were previously missed entirely:
|
||||||
|
//
|
||||||
|
// * screen layout (tbl_appconfig_screens.layout) — the button lives under a
|
||||||
|
// node's `config` key, nested anywhere in a `children` tree.
|
||||||
|
// * rv_screen layout (tbl_appconfig_rv_screens.layout) — the button object
|
||||||
|
// itself carries `type` / `params` / `job_template` at its top level.
|
||||||
|
//
|
||||||
|
// The activity identifier has also changed key over time. Current studio writes
|
||||||
|
// `params.activity_uid_init`; older configs (and the dev-era app) used
|
||||||
|
// `params.activity_id_init`. Both are accepted here, newest first.
|
||||||
|
//
|
||||||
|
// Rather than model either shape, this walks every object in the tree and picks
|
||||||
|
// out anything that looks like a button carrying an activity id.
|
||||||
|
|
||||||
|
export interface StartButton {
|
||||||
|
/** React key — the activity uid is unique per button in practice. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** Activity uid to launch. For start_state_machine this is the INIT activity. */
|
||||||
|
activityId: string;
|
||||||
|
/** 'start_state_machine' (new instance) | 'perform_activity' (existing) | other. */
|
||||||
|
jobTemplate?: string;
|
||||||
|
workflowUuid?: string;
|
||||||
|
versionUuid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickActivityId = (params: any): string | undefined =>
|
||||||
|
params?.activity_uid_init ||
|
||||||
|
params?.activity_id_init ||
|
||||||
|
params?.activity_uid ||
|
||||||
|
params?.activity_id ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
// A node is button-ish if it says so, or if it carries a job_template (studio
|
||||||
|
// omits type on some button variants).
|
||||||
|
const isButtonish = (o: any): boolean =>
|
||||||
|
o?.type === "button" || typeof o?.job_template === "string";
|
||||||
|
|
||||||
|
export function collectStartButtons(layout: unknown): StartButton[] {
|
||||||
|
const out: StartButton[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const visit = (node: any): void => {
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
node.forEach(visit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isButtonish(node)) {
|
||||||
|
const activityId = pickActivityId(node.params);
|
||||||
|
if (activityId) {
|
||||||
|
const jobTemplate = node.job_template;
|
||||||
|
const key = `${jobTemplate ?? "button"}:${activityId}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push({
|
||||||
|
key,
|
||||||
|
// Studio labels sometimes carry a leading "+ ".
|
||||||
|
label: String(node.name ?? "New").replace(/^\+\s*/, ""),
|
||||||
|
activityId,
|
||||||
|
jobTemplate,
|
||||||
|
workflowUuid: node.params?.workflow_uuid,
|
||||||
|
versionUuid: node.params?.version_uuid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into everything — `config`, `children`, and any other nesting.
|
||||||
|
for (const v of Object.values(node)) visit(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
visit(layout);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buttons that start a brand-new workflow instance. */
|
||||||
|
export const startWorkflowButtons = (layout: unknown): StartButton[] =>
|
||||||
|
collectStartButtons(layout).filter(
|
||||||
|
(b) => b.jobTemplate === "start_state_machine" || b.jobTemplate === undefined,
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user