From ccedcb49f8dc2bbe641a22f30baa21958beb2b2d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:59:26 +0800 Subject: [PATCH] feat(showcase): Product catalog + invoice line product lookup with auto-fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a small showcase_product price-book (name, sku, description, unit_price, active) with seeded items, and a Products nav entry. Rewire the invoice line's `product` from free text to a lookup → showcase_product. Picking a product in the line-item grid auto-fills the line's description + unit_price (the grid copies same-named fields from the chosen record) — the catalog typeahead every invoicing tool has. Amount (= quantity × unit_price) then recomputes live. Co-Authored-By: Claude Opus 4.8 --- examples/app-showcase/src/apps/index.ts | 1 + examples/app-showcase/src/data/index.ts | 16 ++++++++++- examples/app-showcase/src/objects/index.ts | 2 +- .../src/objects/invoice.object.ts | 28 ++++++++++++++++++- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/examples/app-showcase/src/apps/index.ts b/examples/app-showcase/src/apps/index.ts index 89939b9eb..e8129f983 100644 --- a/examples/app-showcase/src/apps/index.ts +++ b/examples/app-showcase/src/apps/index.ts @@ -24,6 +24,7 @@ export const ShowcaseApp = App.create({ { 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_products', type: 'object', objectName: 'showcase_product', label: 'Products', icon: 'package' }, { 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/data/index.ts b/examples/app-showcase/src/data/index.ts index 63bb8f9e0..d448fc1a2 100644 --- a/examples/app-showcase/src/data/index.ts +++ b/examples/app-showcase/src/data/index.ts @@ -7,6 +7,7 @@ import { Project } from '../objects/project.object.js'; import { Task } from '../objects/task.object.js'; import { Category } from '../objects/category.object.js'; import { Team, ProjectMembership } from '../objects/team.object.js'; +import { Product } from '../objects/invoice.object.js'; /** * Seed data sized to "feed every view": every Kanban column is populated, @@ -77,6 +78,19 @@ const teams = defineSeed(Team, { ], }); +// Catalog products the invoice line's `product` lookup picks from. Selecting +// one auto-fills the line's description + unit_price (matching field names). +const products = defineSeed(Product, { + mode: 'upsert', + externalId: 'sku', + records: [ + { sku: 'WIDGET-A', name: 'Widget A', description: 'Standard widget', unit_price: 29.99, active: true }, + { sku: 'WIDGET-B', name: 'Widget B', description: 'Deluxe widget', unit_price: 49.99, active: true }, + { sku: 'GADGET-X', name: 'Gadget X', description: 'Premium gadget', unit_price: 99.0, active: true }, + { sku: 'SERVICE-HR', name: 'Consulting Hour', description: 'Professional services, per hour', unit_price: 150.0, active: true }, + ], +}); + const memberships = defineSeed(ProjectMembership, { mode: 'insert', records: [ @@ -86,4 +100,4 @@ const memberships = defineSeed(ProjectMembership, { ], }); -export const ShowcaseSeedData = [accounts, projects, tasks, categories, teams, memberships]; +export const ShowcaseSeedData = [accounts, products, projects, tasks, categories, teams, memberships]; diff --git a/examples/app-showcase/src/objects/index.ts b/examples/app-showcase/src/objects/index.ts index edcbeac80..31dab75bd 100644 --- a/examples/app-showcase/src/objects/index.ts +++ b/examples/app-showcase/src/objects/index.ts @@ -5,5 +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 { Product, 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 index 93c1d35e1..29dd17038 100644 --- a/examples/app-showcase/src/objects/invoice.object.ts +++ b/examples/app-showcase/src/objects/invoice.object.ts @@ -2,6 +2,29 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; +/** + * Product — a small price-book / catalog. The invoice line's `product` lookup + * points here; selecting a product auto-fills the line's `description` and + * `unit_price` (the line-item grid copies matching field names from the chosen + * record — the catalog typeahead every invoicing tool has: QuickBooks + * "Product/Service", Stripe price catalog, NetSuite item column). + */ +export const Product = ObjectSchema.create({ + name: 'showcase_product', + label: 'Product', + pluralLabel: 'Products', + icon: 'package', + description: 'A sellable product with a catalog price.', + + fields: { + name: Field.text({ label: 'Name', required: true, searchable: true, maxLength: 120 }), + sku: Field.text({ label: 'SKU', searchable: true, maxLength: 40 }), + description: Field.text({ label: 'Description', maxLength: 200 }), + unit_price: Field.currency({ label: 'Unit Price', scale: 2, min: 0 }), + active: Field.boolean({ label: 'Active', defaultValue: true }), + }, +}); + /** * Invoice + Invoice Line — the canonical master-detail "header + line items" * shape. Unlike project↔task (a task is added to a project over time), an @@ -58,7 +81,10 @@ export const InvoiceLine = ObjectSchema.create({ inlineEdit: 'grid', inlineTitle: 'Line Items', }), - product: Field.text({ label: 'Product', required: true, maxLength: 200 }), + // Catalog lookup. Picking a product auto-fills `description` + `unit_price` + // (the grid copies same-named fields from the selected product record). + product: Field.lookup('showcase_product', { label: 'Product', required: true }), + description: Field.text({ label: 'Description', 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 = Qty × Unit Price. Kept as a *stored* currency column (so the