diff --git a/examples/app-showcase/src/apps/index.ts b/examples/app-showcase/src/apps/index.ts index 81a13d99c..89939b9eb 100644 --- a/examples/app-showcase/src/apps/index.ts +++ b/examples/app-showcase/src/apps/index.ts @@ -23,6 +23,7 @@ export const ShowcaseApp = App.create({ { id: 'nav_projects', type: 'object', objectName: 'showcase_project', label: 'Projects', icon: 'folder-kanban' }, { id: 'nav_tasks', type: 'object', objectName: 'showcase_task', label: 'Tasks', icon: 'check-square' }, { id: 'nav_accounts', type: 'object', objectName: 'showcase_account', label: 'Accounts', icon: 'building' }, + { id: 'nav_invoices', type: 'object', objectName: 'showcase_invoice', label: 'Invoices', icon: 'receipt' }, { id: 'nav_teams', type: 'object', objectName: 'showcase_team', label: 'Teams', icon: 'users' }, { id: 'nav_categories', type: 'object', objectName: 'showcase_category', label: 'Categories', icon: 'list-tree' }, { id: 'nav_field_zoo', type: 'object', objectName: 'showcase_field_zoo', label: 'Field Zoo', icon: 'shapes' }, diff --git a/examples/app-showcase/src/objects/index.ts b/examples/app-showcase/src/objects/index.ts index 4ee837ed4..edcbeac80 100644 --- a/examples/app-showcase/src/objects/index.ts +++ b/examples/app-showcase/src/objects/index.ts @@ -5,4 +5,5 @@ export { Project } from './project.object.js'; export { Task } from './task.object.js'; export { Category } from './category.object.js'; export { Team, ProjectMembership } from './team.object.js'; +export { Invoice, InvoiceLine } from './invoice.object.js'; export { FieldZoo } from './field-zoo.object.js'; diff --git a/examples/app-showcase/src/objects/invoice.object.ts b/examples/app-showcase/src/objects/invoice.object.ts new file mode 100644 index 000000000..0e4746850 --- /dev/null +++ b/examples/app-showcase/src/objects/invoice.object.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Invoice + Invoice Line — the canonical master-detail "header + line items" + * shape. Unlike project↔task (a task is added to a project over time), an + * invoice is meaningless without its lines: you enter the header AND its lines + * together, in one atomic transaction. So `invoice_line.invoice` declares + * `inlineEdit: 'grid'` — every standard New/Edit Invoice form renders an + * editable line-item grid, and the invoice `total` rolls the line amounts up + * server-side. This is where inline master-detail entry belongs. + */ +export const Invoice = ObjectSchema.create({ + name: 'showcase_invoice', + label: 'Invoice', + pluralLabel: 'Invoices', + icon: 'receipt', + description: 'A customer invoice entered together with its line items.', + + fields: { + name: Field.text({ label: 'Invoice Number', required: true, searchable: true, maxLength: 60 }), + account: Field.lookup('showcase_account', { label: 'Account', required: true }), + status: Field.select({ + label: 'Status', + required: true, + options: [ + { label: 'Draft', value: 'draft', default: true, color: '#94A3B8' }, + { label: 'Sent', value: 'sent', color: '#3B82F6' }, + { label: 'Paid', value: 'paid', color: '#10B981' }, + { label: 'Void', value: 'void', color: '#EF4444' }, + ], + }), + issued_on: Field.date({ label: 'Issued On' }), + // Roll-up: recomputed server-side as line items are inserted/updated/deleted + // (child FK auto-detected: showcase_invoice_line.invoice). + total: Field.summary({ + label: 'Total', + summaryOperations: { object: 'showcase_invoice_line', field: 'amount', function: 'sum' }, + }), + }, +}); + +/** Invoice line item — owned by its invoice, entered inline in the grid. */ +export const InvoiceLine = ObjectSchema.create({ + name: 'showcase_invoice_line', + label: 'Invoice Line', + pluralLabel: 'Invoice Lines', + icon: 'list', + description: 'A single billable line on an invoice.', + + fields: { + invoice: Field.masterDetail('showcase_invoice', { + label: 'Invoice', + required: true, + deleteBehavior: 'cascade', + // Thin, high-volume line items → the editable grid form factor. + inlineEdit: 'grid', + inlineTitle: 'Line Items', + }), + product: Field.text({ label: 'Product', required: true, maxLength: 200 }), + quantity: Field.number({ label: 'Qty', required: true, min: 0, defaultValue: 1 }), + unit_price: Field.currency({ label: 'Unit Price', scale: 2, min: 0 }), + amount: Field.currency({ label: 'Amount', scale: 2, min: 0 }), + }, +}); diff --git a/examples/app-showcase/src/objects/task.object.ts b/examples/app-showcase/src/objects/task.object.ts index 8bb437cea..85d12264f 100644 --- a/examples/app-showcase/src/objects/task.object.ts +++ b/examples/app-showcase/src/objects/task.object.ts @@ -24,21 +24,15 @@ export const Task = ObjectSchema.create({ fields: { title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 200 }), - // `inlineEdit` declares (in the data model) that tasks are entered inline - // within their project's form — so the standard New/Edit Project form - // auto-renders an atomic Tasks subtable, with no form view config and no - // bespoke page. `relatedList*` is the read-side mirror: the Project's - // record DETAIL page auto-renders a Tasks related list, with a focused - // column set — again, derived from the relationship, no page config. + // NOTE: no `inlineEdit` here. A task is added to a project over time, not + // entered together with it — so "New Project" must NOT force a Tasks + // subtable (that felt heavy/odd). Tasks are added later from the Project + // DETAIL page's Tasks related list (`relatedList*` below — the read side). + // Inline master-detail entry is reserved for true header+line shapes like + // Invoice + Invoice Line (see invoice.object.ts). project: Field.masterDetail('showcase_project', { label: 'Project', required: true, - // Pin the editable-grid form factor (fast bulk line-item entry, with the - // column chooser + per-row expand). Left at `true`, the smart default - // would pick `form` for this fat child — the right call for many apps; - // here we keep the grid demo. Use `'form'` to force the per-row form. - inlineEdit: 'grid', - inlineTitle: 'Tasks', relatedListTitle: 'Tasks', relatedListColumns: ['title', 'status', 'priority', 'assignee', 'due_date'], }), diff --git a/examples/app-showcase/src/pages/project-workspace.page.ts b/examples/app-showcase/src/pages/project-workspace.page.ts index 37c275446..20b5c3112 100644 --- a/examples/app-showcase/src/pages/project-workspace.page.ts +++ b/examples/app-showcase/src/pages/project-workspace.page.ts @@ -49,6 +49,11 @@ export const ProjectWorkspacePage: Page = { // columns are auto-derived from the child object's metadata — no // hand-authored columns block. Add `columns`/`relationshipField` // here only to override the derived defaults. + // `showcase_task` has rich fields (notes/location/cover), so the + // relationship's smart default resolves to the per-row `form` + // factor: a read-only Tasks list with an "Add task" button that + // opens the child's full form inline. (Thin children like invoice + // lines get the editable `grid` instead — see invoice.object.ts.) details: [ { title: 'Tasks', childObject: 'showcase_task', addLabel: 'Add task' }, ],