Skip to content

Commit 3546c3a

Browse files
os-zhuangclaude
andcommitted
feat(automation): object-form screen-flow steps + CRM lead→customer/opportunity wizard
Extend the screen-flow runtime so a `screen` node can render a target object's FULL create/edit form — including inline master-detail child grids — instead of a flat field list. A step that declares `config.objectName` emits an `object-form` ScreenSpec; the client renders the real ObjectForm, persists the record (and its children, atomically), and resumes the run with the new id bound to `config.idVariable` so a later step can reference it. CRM example: convert-lead.flow is now a two-step wizard — Step 1 a full Customer (Account) form prefilled from the lead, Step 2 a full Opportunity form WITH an editable product line-item grid (new crm_opportunity_line_item, master_detail + inlineEdit:'grid'; Opportunity gains a line_total rollup). The flow then marks the lead converted and links it to the new account + opportunity. Also fixes get_lead: get_record reads `config.filter`, not a bare `config.recordId`, so the node now filters by `{ id: '{recordId}' }` instead of silently returning the first record. - spec: ScreenSpec gains kind/objectName/mode/recordId/defaults/idVariable - service-automation: screen node emits object-form specs + interpolates title/description/field-defaults/object-form-defaults against flow vars - app-crm: new line-item object, Opportunity line_total summary, rewritten convert-lead wizard, smoke test updated for the 6th object Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ea9e0e6 commit 3546c3a

7 files changed

Lines changed: 231 additions & 142 deletions

File tree

examples/app-crm/src/flows/convert-lead.flow.ts

Lines changed: 76 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -3,64 +3,65 @@
33
import { defineFlow } from '@objectstack/spec';
44

55
/**
6-
* Convert Lead to Opportunity — Screen Flow
6+
* Convert Lead → Customer + Opportunity — Master-Detail Screen Flow
77
*
8-
* A user-triggered wizard that walks a sales rep through converting a
9-
* qualified lead into an Opportunity record. Demonstrates every key
10-
* screen-flow node type:
8+
* A user-triggered wizard that walks a sales rep through converting a qualified
9+
* lead into a full Account + Opportunity. Unlike a flat field-list wizard, each
10+
* step renders the target object's COMPLETE create form via an `object-form`
11+
* screen node (`config.objectName`): the client renders the real ObjectForm —
12+
* including inline master-detail child grids — persists the record (and its
13+
* children, atomically), and resumes the run with the new record's id bound to
14+
* `config.idVariable`.
1115
*
1216
* start → get_lead → decision (already converted?)
13-
* → already_converted_screen (abort path)
14-
* → screen_qualify (collect opportunity details from rep)
15-
* → screen_confirm (review + confirm before writing)
16-
* → create_opp (create crm_opportunity)
17-
* → update_lead (mark lead as converted)
18-
* → screen_success (celebrate + navigate)
17+
* → screen_already_converted (abort path)
18+
* → screen_account (Step 1 — full Customer form → account_id)
19+
* → screen_opportunity (Step 2 — full Opportunity form WITH product
20+
* line-items grid, prefilled account → opportunity_id)
21+
* → update_lead (mark converted + link account & opportunity)
1922
* → end
2023
*/
2124
export const ConvertLeadScreenFlow = defineFlow({
2225
name: 'crm_convert_lead_wizard',
23-
label: 'Convert Lead to Opportunity',
26+
label: 'Convert Lead to Customer + Opportunity',
2427
description:
25-
'Screen flow wizard that converts a qualified CRM lead into an Opportunity, marks the lead as converted, and links the two records.',
28+
'Screen-flow wizard that walks the rep through a full Customer form then a full Opportunity form (with product line items), creates both, and marks the lead converted.',
2629
type: 'screen',
2730
status: 'active',
2831
runAs: 'user',
2932

3033
variables: [
31-
// ── inputs (from action trigger) ───────────────────────────────────
32-
{ name: 'recordId', type: 'text', isInput: true, isOutput: false },
33-
// ── screen-collected inputs (filled by wizard steps) ──────────────
34-
// Marked isInput:true so the trigger API can pre-populate them for
35-
// automated testing or when the Studio wizard submits all values at once.
36-
{ name: 'opp_name', type: 'text', isInput: true, isOutput: false },
37-
{ name: 'opp_account', type: 'text', isInput: true, isOutput: false },
38-
{ name: 'opp_amount', type: 'number', isInput: true, isOutput: false },
39-
{ name: 'opp_close_date', type: 'date', isInput: true, isOutput: false },
40-
{ name: 'opp_stage', type: 'text', isInput: true, isOutput: false },
41-
// ── intermediate (populated at runtime) ───────────────────────────
42-
{ name: 'lead_record', type: 'object', isInput: false, isOutput: false },
43-
// ── outputs ───────────────────────────────────────────────────────
44-
{ name: 'opportunity_id', type: 'text', isInput: false, isOutput: true },
34+
// ── input (from the action trigger) ───────────────────────────────────
35+
{ name: 'recordId', type: 'text', isInput: true, isOutput: false },
36+
// ── intermediate (populated at runtime) ───────────────────────────────
37+
{ name: 'lead_record', type: 'object', isInput: false, isOutput: false },
38+
// ── produced by the object-form steps (the saved record ids) ──────────
39+
// `isInput: true` lets the trigger API pre-populate them for automated
40+
// testing; at runtime each is bound by its step's `idVariable` on resume.
41+
{ name: 'account_id', type: 'text', isInput: true, isOutput: true },
42+
{ name: 'opportunity_id', type: 'text', isInput: true, isOutput: true },
4543
],
4644

4745
nodes: [
48-
// ── 1. Start ──────────────────────────────────────────────────────
46+
// ── 1. Start ──────────────────────────────────────────────────────────
4947
{ id: 'start', type: 'start', label: 'Start' },
5048

51-
// ── 2. Load the lead record ───────────────────────────────────────
49+
// ── 2. Load the lead record ───────────────────────────────────────────
5250
{
5351
id: 'get_lead',
5452
type: 'get_record',
5553
label: 'Load Lead',
5654
config: {
5755
objectName: 'crm_lead',
58-
recordId: '{recordId}',
56+
// get_record filters via `filter` (it ignores a bare `recordId`), so
57+
// load THIS lead by id — otherwise findOne with an empty filter returns
58+
// the first lead in the table.
59+
filter: { id: '{recordId}' },
5960
outputVariable: 'lead_record',
6061
},
6162
},
6263

63-
// ── 3. Guard: already converted? ──────────────────────────────────
64+
// ── 3. Guard: already converted? ──────────────────────────────────────
6465
{
6566
id: 'check_converted',
6667
type: 'decision',
@@ -73,102 +74,60 @@ export const ConvertLeadScreenFlow = defineFlow({
7374
},
7475
},
7576

76-
// ── 3a. Already-converted abort screen ────────────────────────────
77+
// ── 3a. Already-converted abort screen ────────────────────────────────
7778
{
7879
id: 'screen_already_converted',
7980
type: 'screen',
8081
label: 'Already Converted',
8182
config: {
82-
message: 'This lead has already been converted to an opportunity.',
83-
buttons: [
84-
{ label: 'Close', action: 'finish' },
85-
],
83+
waitForInput: true,
84+
title: 'Already Converted',
85+
description: 'This lead has already been converted to an opportunity.',
8686
},
8787
},
8888

89-
// ── 4. Screen 1: Collect qualification + opportunity details ───────
89+
// ── 4. Step 1 — full Customer (Account) form ──────────────────────────
90+
// `objectName` ⇒ object-form screen: the client renders the real
91+
// crm_account create form, persists it, and resumes with the new id under
92+
// `account_id`.
9093
{
91-
id: 'screen_qualify',
94+
id: 'screen_account',
9295
type: 'screen',
93-
label: 'Opportunity Details',
96+
label: 'Customer',
9497
config: {
95-
fields: [
96-
{
97-
name: 'opp_name',
98-
label: 'Opportunity Name',
99-
type: 'text',
100-
required: true,
101-
defaultValue: '{lead_record.name}',
102-
helpText: 'Descriptive name for the new opportunity',
103-
},
104-
{
105-
name: 'opp_account',
106-
label: 'Account',
107-
type: 'lookup',
108-
object: 'crm_account',
109-
required: false,
110-
defaultValue: '{lead_record.account}',
111-
helpText: 'The account this opportunity belongs to',
112-
},
113-
{
114-
name: 'opp_amount',
115-
label: 'Estimated Value ($)',
116-
type: 'number',
117-
required: false,
118-
min: 0,
119-
},
120-
{
121-
name: 'opp_close_date',
122-
label: 'Expected Close Date',
123-
type: 'date',
124-
required: false,
125-
},
126-
{
127-
name: 'opp_stage',
128-
label: 'Stage',
129-
type: 'select',
130-
required: true,
131-
options: ['prospecting', 'qualification', 'proposal', 'closed_won', 'closed_lost'],
132-
defaultValue: 'qualification',
133-
},
134-
],
98+
objectName: 'crm_account',
99+
idVariable: 'account_id',
100+
title: 'Step 1 of 2 · Customer',
101+
description: 'Review and complete the customer record carried over from the lead.',
102+
defaults: {
103+
name: '{lead_record.company}',
104+
},
135105
},
136106
},
137107

138-
// ── 5. Screen 2: Confirm before write ─────────────────────────────
108+
// ── 5. Step 2 — full Opportunity form WITH product line items ──────────
109+
// crm_opportunity_line_item.opportunity declares inlineEdit: 'grid', so the
110+
// standard Opportunity form (and therefore this step) renders an editable
111+
// product line-item grid below the header — the master-detail entry the
112+
// user asked for. The account FK is prefilled with Step 1's new id.
139113
{
140-
id: 'screen_confirm',
114+
id: 'screen_opportunity',
141115
type: 'screen',
142-
label: 'Confirm Conversion',
143-
config: {
144-
message: 'Create opportunity "{opp_name}" for lead "{lead_record.name}"?\n\nStage: {opp_stage} · Amount: ${opp_amount} · Close: {opp_close_date}',
145-
buttons: [
146-
{ label: 'Convert', action: 'next' },
147-
{ label: 'Back', action: 'back' },
148-
{ label: 'Cancel', action: 'finish' },
149-
],
150-
},
151-
},
152-
153-
// ── 6. Create the Opportunity ─────────────────────────────────────
154-
{
155-
id: 'create_opp',
156-
type: 'create_record',
157-
label: 'Create Opportunity',
116+
label: 'Opportunity',
158117
config: {
159118
objectName: 'crm_opportunity',
160-
fields: {
161-
name: '{opp_name}',
162-
account: '{opp_account}',
163-
amount: '{opp_amount}',
164-
close_date: '{opp_close_date}',
165-
stage: '{opp_stage}',
119+
idVariable: 'opportunity_id',
120+
title: 'Step 2 of 2 · Opportunity',
121+
description: 'Add the opportunity and its product line items. Saved together in one transaction.',
122+
defaults: {
123+
name: '{lead_record.name}',
124+
account: '{account_id}',
125+
stage: 'qualification',
166126
},
167-
outputVariable: 'opportunity_id',
168127
},
169128
},
170129

171-
// ── 7. Mark the lead as converted ─────────────────────────────────
130+
// ── 6. Mark the lead converted + link both new records ────────────────
172131
{
173132
id: 'update_lead',
174133
type: 'update_record',
@@ -177,47 +136,28 @@ export const ConvertLeadScreenFlow = defineFlow({
177136
objectName: 'crm_lead',
178137
filter: { id: '{recordId}' },
179138
fields: {
180-
status: 'converted',
181-
converted_opportunity: '{opportunity_id}',
182-
is_closed: true,
139+
status: 'converted',
140+
account: '{account_id}',
141+
converted_opportunity: '{opportunity_id}',
183142
},
184143
},
185144
},
186145

187-
// ── 8. Success screen ─────────────────────────────────────────────
188-
{
189-
id: 'screen_success',
190-
type: 'screen',
191-
label: 'Conversion Complete',
192-
config: {
193-
message: '✅ Lead converted! Opportunity "{opp_name}" has been created.',
194-
buttons: [
195-
{ label: 'View Opportunity', action: 'navigate', target: '/crm_opportunity/{opportunity_id}' },
196-
{ label: 'Done', action: 'finish' },
197-
],
198-
},
199-
},
200-
201-
// ── 9. End ────────────────────────────────────────────────────────
146+
// ── 7. End ────────────────────────────────────────────────────────────
202147
{ id: 'end', type: 'end', label: 'End' },
203148
],
204149

205150
edges: [
206-
{ id: 'e1', source: 'start', target: 'get_lead', type: 'default' },
207-
{ id: 'e2', source: 'get_lead', target: 'check_converted', type: 'default' },
151+
{ id: 'e1', source: 'start', target: 'get_lead', type: 'default' },
152+
{ id: 'e2', source: 'get_lead', target: 'check_converted', type: 'default' },
208153
// guard branches
209-
{ id: 'e3a', source: 'check_converted', target: 'screen_already_converted', type: 'default', condition: "lead_record.status == 'converted'", label: 'Yes' },
210-
{ id: 'e3b', source: 'check_converted', target: 'screen_qualify', type: 'default', label: 'No' },
211-
{ id: 'e3c', source: 'screen_already_converted', target: 'end', type: 'default' },
212-
// main path
213-
{ id: 'e4', source: 'screen_qualify', target: 'screen_confirm', type: 'default' },
214-
// "Convert" proceeds to create the record; "Back" is handled client-side
215-
// by the screen-flow runner's history stack — no server back-edge needed
216-
// (a back-edge would create a cycle and fail DAG validation).
217-
{ id: 'e5', source: 'screen_confirm', target: 'create_opp', type: 'default' },
218-
{ id: 'e7', source: 'create_opp', target: 'update_lead', type: 'default' },
219-
{ id: 'e8', source: 'update_lead', target: 'screen_success', type: 'default' },
220-
{ id: 'e9', source: 'screen_success', target: 'end', type: 'default' },
154+
{ id: 'e3a', source: 'check_converted', target: 'screen_already_converted', type: 'default', condition: "lead_record.status == 'converted'", label: 'Yes' },
155+
{ id: 'e3b', source: 'check_converted', target: 'screen_account', type: 'default', label: 'No' },
156+
{ id: 'e3c', source: 'screen_already_converted', target: 'end', type: 'default' },
157+
// main path — full Customer form → full Opportunity form → link
158+
{ id: 'e4', source: 'screen_account', target: 'screen_opportunity', type: 'default' },
159+
{ id: 'e5', source: 'screen_opportunity', target: 'update_lead', type: 'default' },
160+
{ id: 'e6', source: 'update_lead', target: 'end', type: 'default' },
221161
],
222162

223163
errorHandling: {

examples/app-crm/src/objects/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
export { Account } from './account.object.js';
44
export { Contact } from './contact.object.js';
55
export { Opportunity } from './opportunity.object.js';
6+
export { OpportunityLineItem } from './opportunity-line-item.object.js';
67
export { Lead } from './lead.object.js';
78
export { Activity } from './activity.object.js';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
import { cel } from '@objectstack/spec';
5+
6+
/**
7+
* Opportunity Line Item — the product/quantity detail rows of an Opportunity.
8+
*
9+
* This is the canonical master-detail "header + line items" shape (mirrors the
10+
* showcase Invoice ↔ InvoiceLine pair): an opportunity is quoted together with
11+
* the products on it, entered in ONE atomic transaction. So the back-pointer
12+
* `opportunity` field declares `inlineEdit: 'grid'` — every standard New/Edit
13+
* Opportunity form (and the lead-conversion wizard's Opportunity step) renders
14+
* an editable line-item grid, and the parent `Opportunity.line_total` rolls the
15+
* line amounts up server-side.
16+
*/
17+
export const OpportunityLineItem = ObjectSchema.create({
18+
name: 'crm_opportunity_line_item',
19+
label: 'Line Item',
20+
pluralLabel: 'Line Items',
21+
icon: 'list',
22+
description: 'A single product line on an opportunity quote.',
23+
24+
fields: {
25+
opportunity: Field.masterDetail('crm_opportunity', {
26+
label: 'Opportunity',
27+
required: true,
28+
deleteBehavior: 'cascade',
29+
// Thin, high-volume product rows → the editable grid form factor.
30+
inlineEdit: 'grid',
31+
inlineTitle: 'Products',
32+
inlineAmountField: 'amount',
33+
}),
34+
product: Field.text({
35+
label: 'Product',
36+
required: true,
37+
maxLength: 200,
38+
}),
39+
quantity: Field.number({
40+
label: 'Qty',
41+
required: true,
42+
min: 1,
43+
defaultValue: 1,
44+
}),
45+
unit_price: Field.currency({
46+
label: 'Unit Price',
47+
scale: 2,
48+
min: 0,
49+
}),
50+
// Amount = Qty × Unit Price. Kept as a *stored* currency column (so the
51+
// parent Opportunity.line_total summary can roll it up — summary aggregation
52+
// reads stored columns, not on-read formula fields), but the `expression`
53+
// makes the line-item grid render it READ-ONLY and recompute it live
54+
// client-side as quantity/unit_price change, then persist the computed
55+
// value. The server stores the client-sent value as-is. (Mirrors the
56+
// showcase InvoiceLine.amount pattern.)
57+
amount: Field.currency({
58+
label: 'Amount',
59+
scale: 2,
60+
min: 0,
61+
expression: cel`record.quantity * record.unit_price`,
62+
}),
63+
},
64+
});

examples/app-crm/src/objects/opportunity.object.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ export const Opportunity = ObjectSchema.create({
7878
renewal_of: Field.lookup('crm_opportunity', {
7979
label: 'Renewal Of',
8080
}),
81+
// Roll-up of the opportunity's product line items — recomputed server-side
82+
// as lines are inserted/updated/deleted (child FK auto-detected:
83+
// crm_opportunity_line_item.opportunity). The line-item grid also shows a
84+
// live running total during entry (inlineAmountField: 'amount').
85+
line_total: Field.summary({
86+
label: 'Products Total',
87+
summaryOperations: { object: 'crm_opportunity_line_item', field: 'amount', function: 'sum' },
88+
}),
8189
},
8290

8391
validations: [

examples/app-crm/test/smoke.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@ describe('app-crm minimal metadata bundle', () => {
1111
expect(stack.manifest.type).toBe('app');
1212
});
1313

14-
it('registers the 5 core objects', () => {
14+
it('registers the 6 core objects', () => {
1515
const names = (stack.objects ?? []).map((o) => o.name).sort();
16-
expect(names).toEqual(['crm_account', 'crm_activity', 'crm_contact', 'crm_lead', 'crm_opportunity']);
16+
expect(names).toEqual([
17+
'crm_account',
18+
'crm_activity',
19+
'crm_contact',
20+
'crm_lead',
21+
'crm_opportunity',
22+
'crm_opportunity_line_item',
23+
]);
1724
});
1825

1926
it('registers exactly one app, one dashboard, one hook, and at least 4 flows', () => {

0 commit comments

Comments
 (0)