diff --git a/examples/app-showcase/e2e/showcase-smoke.spec.ts b/examples/app-showcase/e2e/showcase-smoke.spec.ts index 92f2acd25..427d0baa7 100644 --- a/examples/app-showcase/e2e/showcase-smoke.spec.ts +++ b/examples/app-showcase/e2e/showcase-smoke.spec.ts @@ -25,6 +25,7 @@ const SURFACES: { name: string; path: string; chart?: boolean }[] = [ { name: 'Field Zoo', path: base('showcase_field_zoo') }, { name: 'Delivery Operations', path: base('dashboard/showcase_ops_dashboard'), chart: true }, { name: 'Chart Gallery', path: base('dashboard/showcase_chart_gallery'), chart: true }, + { name: 'Command Center', path: base('page/showcase_command_center'), chart: true }, { name: 'Hours by Status', path: base('report/showcase_hours_by_status') }, { name: 'Status × Priority', path: base('report/showcase_status_priority_matrix') }, { name: 'Task Overview', path: base('report/showcase_task_overview') }, @@ -64,7 +65,9 @@ for (const surface of SURFACES) { await svg.waitFor({ state: 'visible', timeout: 25_000 }); const box = await svg.boundingBox(); expect(box, `${surface.name}: no chart SVG`).not.toBeNull(); - expect(box!.width, `${surface.name}: chart width`).toBeGreaterThan(0); + // >0 alone previously passed even on a collapsed ~130px-wide chart panel + // (#2616 D) — require a width a real chart panel would actually have. + expect(box!.width, `${surface.name}: chart width`).toBeGreaterThan(200); expect(box!.height, `${surface.name}: chart height`).toBeGreaterThan(0); } }); diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 3efb3d123..ab8a029eb 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -20,6 +20,17 @@ import { FieldZoo } from '../objects/field-zoo.object.js'; * work location (map), and projects span every status and health. */ +/** + * Local, offline-safe placeholder cover image. Task `cover` seeds used to + * point at picsum.photos, which renders as a wall of broken images in + * offline/restricted-network environments (Gallery, All Views). A data: URI + * needs no network at all and still gives each card a distinct color + number. + */ +function placeholderCover(seed: number, color: string): string { + const svg = `${seed}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + const accounts = defineSeed(Account, { mode: 'upsert', externalId: 'name', @@ -77,16 +88,16 @@ const tasks = defineSeed(Task, { mode: 'upsert', externalId: 'title', records: [ - { cover: 'https://picsum.photos/seed/showcasetask1/480/300', title: 'Audit current IA', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } }, - { cover: 'https://picsum.photos/seed/showcasetask2/480/300', title: 'Design system', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'in_review', priority: 'high', estimate_hours: 24, progress: 80, done: false, created_at: cel`daysAgo(14)`, start_date: cel`daysAgo(12)`, end_date: cel`daysFromNow(2)`, due_date: cel`daysFromNow(2)`, location: { lat: 37.7749, lng: -122.4194 } }, - { cover: 'https://picsum.photos/seed/showcasetask3/480/300', title: 'Build homepage', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'in_progress', priority: 'high', estimate_hours: 40, progress: 45, done: false, created_at: cel`daysAgo(8)`, start_date: cel`daysAgo(6)`, end_date: cel`daysFromNow(10)`, due_date: cel`daysFromNow(10)`, location: { lat: 40.7128, lng: -74.0060 } }, - { cover: 'https://picsum.photos/seed/showcasetask4/480/300', title: 'SEO migration plan', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'todo', priority: 'medium', estimate_hours: 16, progress: 0, done: false, created_at: cel`daysAgo(3)`, start_date: cel`daysFromNow(5)`, end_date: cel`daysFromNow(15)`, due_date: cel`daysFromNow(15)`, location: { lat: 30.2672, lng: -97.7431 } }, - { cover: 'https://picsum.photos/seed/showcasetask5/480/300', title: 'Content backlog', project: 'Website Relaunch', assignee: 'grace@example.com', status: 'backlog', priority: 'low', estimate_hours: 12, progress: 0, done: false, created_at: cel`daysAgo(2)`, due_date: cel`daysFromNow(30)`, location: { lat: 41.8781, lng: -87.6298 } }, - { cover: 'https://picsum.photos/seed/showcasetask6/480/300', title: 'Ingest pipeline', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_progress', priority: 'urgent', estimate_hours: 60, progress: 55, done: false, created_at: cel`daysAgo(40)`, start_date: cel`daysAgo(35)`, end_date: cel`daysFromNow(20)`, due_date: cel`daysFromNow(20)`, location: { lat: 39.7392, lng: -104.9903 } }, - { cover: 'https://picsum.photos/seed/showcasetask7/480/300', title: 'Warehouse schema', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_review', priority: 'high', estimate_hours: 30, progress: 90, done: false, created_at: cel`daysAgo(25)`, start_date: cel`daysAgo(22)`, end_date: cel`daysFromNow(3)`, due_date: cel`daysFromNow(3)`, location: { lat: 42.3601, lng: -71.0589 } }, - { cover: 'https://picsum.photos/seed/showcasetask8/480/300', title: 'PII access review', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'todo', priority: 'urgent', estimate_hours: 20, progress: 0, done: false, created_at: cel`daysAgo(5)`, start_date: cel`daysFromNow(2)`, end_date: cel`daysFromNow(12)`, due_date: cel`daysFromNow(12)`, location: { lat: 38.9072, lng: -77.0369 } }, - { cover: 'https://picsum.photos/seed/showcasetask9/480/300', title: 'Evidence collection', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'backlog', priority: 'medium', estimate_hours: 18, progress: 0, done: false, created_at: cel`daysAgo(1)`, due_date: cel`daysFromNow(25)`, location: { lat: 34.0522, lng: -118.2437 } }, - { cover: 'https://picsum.photos/seed/showcasetask10/480/300', title: 'App wireframes', project: 'Mobile App', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 16, progress: 100, done: true, created_at: cel`daysAgo(10)`, start_date: cel`daysAgo(10)`, end_date: cel`daysAgo(6)`, due_date: cel`daysAgo(6)`, location: { lat: 45.5152, lng: -122.6784 } }, + { cover: placeholderCover(1, '#10B981'), title: 'Audit current IA', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } }, + { cover: placeholderCover(2, '#8B5CF6'), title: 'Design system', project: 'Website Relaunch', assignee: 'ada@example.com', status: 'in_review', priority: 'high', estimate_hours: 24, progress: 80, done: false, created_at: cel`daysAgo(14)`, start_date: cel`daysAgo(12)`, end_date: cel`daysFromNow(2)`, due_date: cel`daysFromNow(2)`, location: { lat: 37.7749, lng: -122.4194 } }, + { cover: placeholderCover(3, '#F59E0B'), title: 'Build homepage', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'in_progress', priority: 'high', estimate_hours: 40, progress: 45, done: false, created_at: cel`daysAgo(8)`, start_date: cel`daysAgo(6)`, end_date: cel`daysFromNow(10)`, due_date: cel`daysFromNow(10)`, location: { lat: 40.7128, lng: -74.0060 } }, + { cover: placeholderCover(4, '#3B82F6'), title: 'SEO migration plan', project: 'Website Relaunch', assignee: 'sam@example.com', status: 'todo', priority: 'medium', estimate_hours: 16, progress: 0, done: false, created_at: cel`daysAgo(3)`, start_date: cel`daysFromNow(5)`, end_date: cel`daysFromNow(15)`, due_date: cel`daysFromNow(15)`, location: { lat: 30.2672, lng: -97.7431 } }, + { cover: placeholderCover(5, '#94A3B8'), title: 'Content backlog', project: 'Website Relaunch', assignee: 'grace@example.com', status: 'backlog', priority: 'low', estimate_hours: 12, progress: 0, done: false, created_at: cel`daysAgo(2)`, due_date: cel`daysFromNow(30)`, location: { lat: 41.8781, lng: -87.6298 } }, + { cover: placeholderCover(6, '#F59E0B'), title: 'Ingest pipeline', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_progress', priority: 'urgent', estimate_hours: 60, progress: 55, done: false, created_at: cel`daysAgo(40)`, start_date: cel`daysAgo(35)`, end_date: cel`daysFromNow(20)`, due_date: cel`daysFromNow(20)`, location: { lat: 39.7392, lng: -104.9903 } }, + { cover: placeholderCover(7, '#8B5CF6'), title: 'Warehouse schema', project: 'Data Platform', assignee: 'linus@example.com', status: 'in_review', priority: 'high', estimate_hours: 30, progress: 90, done: false, created_at: cel`daysAgo(25)`, start_date: cel`daysAgo(22)`, end_date: cel`daysFromNow(3)`, due_date: cel`daysFromNow(3)`, location: { lat: 42.3601, lng: -71.0589 } }, + { cover: placeholderCover(8, '#3B82F6'), title: 'PII access review', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'todo', priority: 'urgent', estimate_hours: 20, progress: 0, done: false, created_at: cel`daysAgo(5)`, start_date: cel`daysFromNow(2)`, end_date: cel`daysFromNow(12)`, due_date: cel`daysFromNow(12)`, location: { lat: 38.9072, lng: -77.0369 } }, + { cover: placeholderCover(9, '#94A3B8'), title: 'Evidence collection', project: 'Compliance Audit', assignee: 'grace@example.com', status: 'backlog', priority: 'medium', estimate_hours: 18, progress: 0, done: false, created_at: cel`daysAgo(1)`, due_date: cel`daysFromNow(25)`, location: { lat: 34.0522, lng: -118.2437 } }, + { cover: placeholderCover(10, '#10B981'), title: 'App wireframes', project: 'Mobile App', assignee: 'ada@example.com', status: 'done', priority: 'medium', estimate_hours: 16, progress: 100, done: true, created_at: cel`daysAgo(10)`, start_date: cel`daysAgo(10)`, end_date: cel`daysAgo(6)`, due_date: cel`daysAgo(6)`, location: { lat: 45.5152, lng: -122.6784 } }, ], }); diff --git a/examples/app-showcase/src/ui/pages/command-center.page.ts b/examples/app-showcase/src/ui/pages/command-center.page.ts index db98986a6..465c2eddb 100644 --- a/examples/app-showcase/src/ui/pages/command-center.page.ts +++ b/examples/app-showcase/src/ui/pages/command-center.page.ts @@ -84,9 +84,16 @@ function kpi(id: string, object: string, label: string, colorVariant: string, ag }; } -/** A dataset-bound chart with the shared brand palette. */ -function chart(id: string, chartType: string, dataset: string, dimensions: string[], values: string[]): any { - return { id, type: 'object-chart', responsiveStyles: { large: { width: '100%', minWidth: '0' } }, properties: { dataset, dimensions, values, chartType, colors: PALETTE } }; +/** + * A dataset-bound chart with the shared brand palette. `integerYAxis` pins the + * tick step to 1 — without it, count aggregates (small integers like 0-5) + * render fractional gridlines (0.8, 2.3, …) on the default Recharts axis. + */ +function chart(id: string, chartType: string, dataset: string, dimensions: string[], values: string[], integerYAxis?: boolean): any { + return { + id, type: 'object-chart', responsiveStyles: { large: { width: '100%', minWidth: '0' } }, + properties: { dataset, dimensions, values, chartType, colors: PALETTE, ...(integerYAxis ? { yAxis: [{ field: values[0], stepSize: 1 }] } : {}) }, + }; } const CHART_H = '376px'; @@ -148,14 +155,14 @@ export const CommandCenterPage = definePage({ // ── Row A — three equal chart panels ───────────────────────── band('cc_rowA', 3, [ - panel({ id: 'cc_status', title: '任务状态分布', accent: A.c1, minHeight: CHART_H, child: chart('cc_status_c', 'bar', 'showcase_task_metrics', ['status'], ['task_count']) }), + panel({ id: 'cc_status', title: '任务状态分布', accent: A.c1, minHeight: CHART_H, child: chart('cc_status_c', 'bar', 'showcase_task_metrics', ['status'], ['task_count'], true) }), panel({ id: 'cc_health', title: '项目健康度', accent: A.c4, minHeight: CHART_H, child: chart('cc_health_c', 'donut', 'showcase_project_metrics', ['health'], ['project_count']) }), - panel({ id: 'cc_priority', title: '优先级分布', accent: A.c5, minHeight: CHART_H, child: chart('cc_pri_c', 'bar', 'showcase_task_metrics', ['priority'], ['task_count']) }), + panel({ id: 'cc_priority', title: '优先级分布', accent: A.c5, minHeight: CHART_H, child: chart('cc_pri_c', 'bar', 'showcase_task_metrics', ['priority'], ['task_count'], true) }), ]), // ── Row B — wide trend (span 2) + spend ────────────────────── band('cc_rowB', 3, [ - panel({ id: 'cc_throughput', title: '任务吞吐趋势 (月)', accent: A.c2, span: 'span 2', minHeight: CHART_H, child: chart('cc_thr_c', 'area', 'showcase_task_metrics', ['created_at'], ['task_count']) }), + panel({ id: 'cc_throughput', title: '任务吞吐趋势 (月)', accent: A.c2, span: 'span 2', minHeight: CHART_H, child: chart('cc_thr_c', 'area', 'showcase_task_metrics', ['created_at'], ['task_count'], true) }), panel({ id: 'cc_budget', title: '预算 vs 支出 (按客户)', accent: A.c4, minHeight: CHART_H, child: chart('cc_bud_c', 'bar', 'showcase_project_metrics', ['account'], ['budget_sum', 'spent_sum']) }), ]), diff --git a/examples/app-showcase/src/ui/pages/crm-workbench.page.ts b/examples/app-showcase/src/ui/pages/crm-workbench.page.ts index bfeea1906..98de23e88 100644 --- a/examples/app-showcase/src/ui/pages/crm-workbench.page.ts +++ b/examples/app-showcase/src/ui/pages/crm-workbench.page.ts @@ -31,10 +31,13 @@ function Page() { const refreshStats = React.useCallback(async () => { if (!adapter) return; try { - const all = await adapter.find('showcase_project', { top: 200 }); + // Canonical QueryOptionsV2 keys only (limit, not the legacy top alias + // removed in 11.0) — the KPI cards silently stuck at 0 whenever adapter.find + // rejected on the unrecognized param, and the empty catch hid the failure. + const all = await adapter.find('showcase_project', { limit: 200 }); const rows = Array.isArray(all) ? all : (all && all.records) || []; setStats({ total: rows.length, active: rows.filter((r) => r.status === 'active').length }); - } catch (e) { /* ignore in demo */ } + } catch (e) { console.warn('[CRM Workbench] failed to refresh stats', e); } }, [adapter]); React.useEffect(() => { refreshStats(); }, [refreshStats, reloadKey]); diff --git a/examples/app-showcase/src/ui/pages/new-project-wizard.page.ts b/examples/app-showcase/src/ui/pages/new-project-wizard.page.ts index a158dbcd3..2b9152c93 100644 --- a/examples/app-showcase/src/ui/pages/new-project-wizard.page.ts +++ b/examples/app-showcase/src/ui/pages/new-project-wizard.page.ts @@ -35,6 +35,11 @@ export const NewProjectWizardPage = definePage({ { label: 'Status & Health', description: 'Where does it stand today?', fields: ['status', 'health'] }, { label: 'Budget & Schedule', description: 'Money and dates.', fields: ['budget', 'spent', 'start_date', 'end_date'] }, ], + // Without this, a successful submit left the filled step-3 form in + // place with only a toast — re-clicking "Create" duplicated the + // record. `thank-you` swaps the form for a confirmation panel so + // there's nothing left to resubmit. + submitBehavior: { kind: 'thank-you', title: 'Project created', message: 'Your new project is ready — find it in Projects, or reopen this wizard to start another.' }, }, }, ], diff --git a/examples/app-showcase/src/ui/pages/review-queue.page.ts b/examples/app-showcase/src/ui/pages/review-queue.page.ts index 756abc14b..691de4644 100644 --- a/examples/app-showcase/src/ui/pages/review-queue.page.ts +++ b/examples/app-showcase/src/ui/pages/review-queue.page.ts @@ -8,9 +8,13 @@ import { definePage } from '@objectstack/spec/ui'; * sees what is waiting on them; this is that surface. * * An interface (list) page over tasks currently `in_review` — the work - * awaiting a decision — with the source object's actions surfaced as toolbar - * buttons (Mark done = approve & complete) and a drawer to inspect each item. - * Tabs let the reviewer pivot to urgent or blocked work. + * awaiting a decision — with a drawer to inspect each item. "Mark Done" is + * deliberately NOT wired as a page-level `buttons:` toolbar entry: that + * surface has no bound record, so `MarkDoneAction`'s `visible: '!record.done'` + * expression has nothing to evaluate against and the button would render + * regardless of state. `MarkDoneAction.locations` already includes + * `list_item`, so it correctly appears per-row (with that row's record bound) + * instead. Tabs let the reviewer pivot to urgent or blocked work. */ export const ReviewQueuePage = definePage({ name: 'showcase_review_queue', @@ -29,8 +33,9 @@ export const ReviewQueuePage = definePage({ sort: [{ field: 'due_date', order: 'asc' }], appearance: { showDescription: true, allowedVisualizations: ['grid'] }, userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, - // Object actions as toolbar buttons — "approve & complete" the reviewed item. - buttons: ['showcase_mark_done'], + // No `buttons:` entry here — see file header comment: "Mark Done" is a + // record-scoped action and belongs per-row (`list_item`), not as an + // unbound page-toolbar button. // Click a row → drawer with the full record to review before deciding. recordAction: 'drawer', showRecordCount: true, diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 018b084e1..75776d6c1 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2203,6 +2203,16 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { } if (options.select !== undefined) delete options.select; + // fields: comma-separated string → array. Clients may pass `?fields=name` + // directly (not only via the `?select=` alias above) — a single-value + // querystring param arrives as a bare string, which drivers' `.map()` + // calls over `query.fields` would otherwise throw on. + if (typeof options.fields === 'string') { + options.fields = options.fields.split(',').map((s: string) => s.trim()).filter(Boolean); + } else if (options.fields !== undefined && !Array.isArray(options.fields)) { + delete options.fields; + } + // Sort/orderBy → orderBy: string → SortNode[] array const sortValue = options.orderBy ?? options.sort; if (typeof sortValue === 'string') { diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 6eea543e9..f6995f4d7 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -633,7 +633,7 @@ export class SqlDriver implements IDataDriver { const builder = buildBase(); // SELECT - if (query.fields) { + if (Array.isArray(query.fields) && query.fields.length > 0) { builder.select((query.fields as string[]).map((f: string) => this.mapSortField(f))); } else { builder.select('*'); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 412e8b98b..cb4531100 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -3280,6 +3280,15 @@ export class HttpDispatcher { } if (!aiService) { + // The console polls `GET /ai/agents` on every navigation to decide + // whether to show AI affordances. Reporting that as a 404 turns the + // normal "no AI service configured" state (the open-source default — + // service-ai is a Cloud/Enterprise package) into console error-log + // spam on every page. An empty list conveys the same information + // without looking like a fault. Every other /ai/* route still 404s. + if (method === 'GET' && subPath === '/ai/agents') { + return { handled: true, response: { status: 200, body: { agents: [] } } }; + } return { handled: true, response: {