Skip to content

Commit a3471e4

Browse files
authored
Merge pull request #783 from objectstack-ai/copilot/evaluate-metadata-standards
2 parents 78946eb + 05405d3 commit a3471e4

30 files changed

Lines changed: 2813 additions & 38 deletions

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,8 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
724724
- [x] **P2: Dashboard Dynamic Data** — "Revenue by Account" widget using `provider: 'object'` aggregation
725725
- [x] **P2: App Branding**`logo`, `favicon`, `backgroundColor` fields on CRM app
726726
- [x] **P3: Pages** — Settings page (utility) and Getting Started page (onboarding)
727+
- [x] **P2: Spec Compliance Audit** — Fixed `variant: 'danger'``'destructive'` (4 actions), `columns: string``number` (33 form sections), added `type: 'dashboard'` to dashboard
728+
- [x] **P2: i18n (10 locales)** — Full CRM metadata translations for en, zh, ja, ko, de, fr, es, pt, ru, ar — objects, fields, fieldOptions, navigation, actions, views, formSections, dashboard, reports, pages (24 tests)
727729

728730
### Ecosystem & Marketplace
729731
- Plugin marketplace website with search, ratings, and install count

examples/crm/objectstack.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,4 @@ export default defineStack({
119119
]
120120
},
121121
plugins: [],
122-
}, { strict: false }); // Defer validation to `objectstack compile` CLI to avoid Zod double-parse transform bug on form.sections.columns
122+
}, { strict: false }); // Defer validation to `objectstack compile` CLI
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
// --- Metadata imports ---
4+
import { AccountObject } from '../objects/account.object';
5+
import { ContactObject } from '../objects/contact.object';
6+
import { OpportunityObject } from '../objects/opportunity.object';
7+
import { ProductObject } from '../objects/product.object';
8+
import { OrderObject } from '../objects/order.object';
9+
import { OrderItemObject } from '../objects/order_item.object';
10+
import { UserObject } from '../objects/user.object';
11+
import { ProjectObject } from '../objects/project.object';
12+
import { EventObject } from '../objects/event.object';
13+
import { OpportunityContactObject } from '../objects/opportunity_contact.object';
14+
15+
import { AccountView } from '../views/account.view';
16+
import { ContactView } from '../views/contact.view';
17+
import { OpportunityView } from '../views/opportunity.view';
18+
import { ProductView } from '../views/product.view';
19+
import { OrderView } from '../views/order.view';
20+
import { OrderItemView } from '../views/order_item.view';
21+
import { UserView } from '../views/user.view';
22+
import { EventView } from '../views/event.view';
23+
import { ProjectView } from '../views/project.view';
24+
import { OpportunityContactView } from '../views/opportunity_contact.view';
25+
26+
import { AccountActions } from '../actions/account.actions';
27+
import { ContactActions } from '../actions/contact.actions';
28+
import { OpportunityActions } from '../actions/opportunity.actions';
29+
import { ProductActions } from '../actions/product.actions';
30+
import { OrderActions } from '../actions/order.actions';
31+
import { OrderItemActions } from '../actions/order_item.actions';
32+
import { UserActions } from '../actions/user.actions';
33+
import { ProjectActions } from '../actions/project.actions';
34+
import { EventActions } from '../actions/event.actions';
35+
import { OpportunityContactActions } from '../actions/opportunity_contact.actions';
36+
37+
import { CrmDashboard } from '../dashboards/crm.dashboard';
38+
import { SalesReport } from '../reports/sales.report';
39+
import { PipelineReport } from '../reports/pipeline.report';
40+
import { GettingStartedPage } from '../pages/getting_started.page';
41+
import { SettingsPage } from '../pages/settings.page';
42+
import { HelpPage } from '../pages/help.page';
43+
import { CrmApp } from '../apps/crm.app';
44+
45+
// --- i18n imports ---
46+
import { crmLocales, type CrmTranslationKeys } from '../i18n';
47+
48+
// ====================================================================
49+
// 1. Metadata Spec Compliance Tests
50+
// ====================================================================
51+
52+
const allObjects = [
53+
AccountObject, ContactObject, OpportunityObject, ProductObject,
54+
OrderObject, OrderItemObject, UserObject, ProjectObject,
55+
EventObject, OpportunityContactObject,
56+
];
57+
58+
const allViews = [
59+
AccountView, ContactView, OpportunityView, ProductView,
60+
OrderView, OrderItemView, UserView, EventView,
61+
ProjectView, OpportunityContactView,
62+
];
63+
64+
const allActions = [
65+
...AccountActions, ...ContactActions, ...OpportunityActions,
66+
...ProductActions, ...OrderActions, ...OrderItemActions,
67+
...UserActions, ...ProjectActions, ...EventActions,
68+
...OpportunityContactActions,
69+
];
70+
71+
describe('CRM Metadata Spec Compliance', () => {
72+
73+
describe('Objects', () => {
74+
it('all objects have name, label, and fields', () => {
75+
for (const obj of allObjects) {
76+
expect(obj).toHaveProperty('name');
77+
expect(obj).toHaveProperty('label');
78+
expect(obj).toHaveProperty('fields');
79+
expect(typeof obj.name).toBe('string');
80+
expect(typeof obj.label).toBe('string');
81+
expect(typeof obj.fields).toBe('object');
82+
}
83+
});
84+
85+
it('all objects have at least one required field', () => {
86+
for (const obj of allObjects) {
87+
const fields = Object.values(obj.fields) as Array<{ required?: boolean }>;
88+
const hasRequired = fields.some((f) => f.required === true);
89+
expect(hasRequired).toBe(true);
90+
}
91+
});
92+
});
93+
94+
describe('Views', () => {
95+
it('all views have listViews and form sections', () => {
96+
for (const view of allViews) {
97+
expect(view).toHaveProperty('listViews');
98+
expect(view).toHaveProperty('form');
99+
expect(view.form).toHaveProperty('sections');
100+
}
101+
});
102+
103+
it('form section columns are numbers, not strings', () => {
104+
for (const view of allViews) {
105+
for (const section of view.form.sections) {
106+
if (section.columns !== undefined) {
107+
expect(typeof section.columns).toBe('number');
108+
}
109+
}
110+
}
111+
});
112+
113+
it('list views have required fields (name, type, data, columns)', () => {
114+
for (const view of allViews) {
115+
for (const lv of Object.values(view.listViews) as Array<Record<string, unknown>>) {
116+
expect(lv).toHaveProperty('name');
117+
expect(lv).toHaveProperty('type');
118+
expect(lv).toHaveProperty('data');
119+
expect(lv).toHaveProperty('columns');
120+
}
121+
}
122+
});
123+
});
124+
125+
describe('Actions', () => {
126+
it('all actions have name, label, type, and locations', () => {
127+
for (const action of allActions) {
128+
expect(action).toHaveProperty('name');
129+
expect(action).toHaveProperty('label');
130+
expect(action).toHaveProperty('type');
131+
expect(action).toHaveProperty('locations');
132+
expect(action.type).toBe('api');
133+
}
134+
});
135+
136+
it('no action uses variant: "danger" (must use "destructive")', () => {
137+
for (const action of allActions) {
138+
if ('variant' in action) {
139+
expect(action.variant).not.toBe('danger');
140+
expect(['default', 'primary', 'secondary', 'destructive', 'outline', 'ghost']).toContain(action.variant);
141+
}
142+
}
143+
});
144+
145+
it('destructive actions have confirmText', () => {
146+
for (const action of allActions) {
147+
if ('variant' in action && action.variant === 'destructive') {
148+
expect(action).toHaveProperty('confirmText');
149+
}
150+
}
151+
});
152+
});
153+
154+
describe('Dashboard', () => {
155+
it('has type: "dashboard"', () => {
156+
expect(CrmDashboard.type).toBe('dashboard');
157+
});
158+
159+
it('has name and label', () => {
160+
expect(CrmDashboard.name).toBe('crm_dashboard');
161+
expect(CrmDashboard.label).toBeDefined();
162+
});
163+
164+
it('has widgets array', () => {
165+
expect(Array.isArray(CrmDashboard.widgets)).toBe(true);
166+
expect(CrmDashboard.widgets.length).toBeGreaterThan(0);
167+
});
168+
169+
it('all widgets have type and layout', () => {
170+
for (const widget of CrmDashboard.widgets) {
171+
expect(widget).toHaveProperty('type');
172+
expect(widget).toHaveProperty('layout');
173+
expect(widget.layout).toHaveProperty('x');
174+
expect(widget.layout).toHaveProperty('y');
175+
expect(widget.layout).toHaveProperty('w');
176+
expect(widget.layout).toHaveProperty('h');
177+
}
178+
});
179+
});
180+
181+
describe('Reports', () => {
182+
it('reports have name, label, and columns', () => {
183+
for (const report of [SalesReport, PipelineReport]) {
184+
expect(report).toHaveProperty('name');
185+
expect(report).toHaveProperty('label');
186+
expect(report).toHaveProperty('columns');
187+
expect(Array.isArray(report.columns)).toBe(true);
188+
}
189+
});
190+
});
191+
192+
describe('Pages', () => {
193+
it('all pages have name, label, type, and regions', () => {
194+
for (const page of [GettingStartedPage, SettingsPage, HelpPage]) {
195+
expect(page).toHaveProperty('name');
196+
expect(page).toHaveProperty('label');
197+
expect(page).toHaveProperty('type');
198+
expect(page).toHaveProperty('regions');
199+
expect(['app', 'utility', 'record', 'home', 'dashboard']).toContain(page.type);
200+
}
201+
});
202+
});
203+
204+
describe('App', () => {
205+
it('has name, label, description, and navigation', () => {
206+
expect(CrmApp).toHaveProperty('name');
207+
expect(CrmApp).toHaveProperty('label');
208+
expect(CrmApp).toHaveProperty('description');
209+
expect(CrmApp).toHaveProperty('navigation');
210+
expect(Array.isArray(CrmApp.navigation)).toBe(true);
211+
});
212+
213+
it('navigation items have id, type, and label', () => {
214+
const items = CrmApp.navigation as Array<Record<string, unknown>>;
215+
for (const item of items) {
216+
expect(item).toHaveProperty('id');
217+
expect(item).toHaveProperty('type');
218+
expect(item).toHaveProperty('label');
219+
}
220+
});
221+
});
222+
});
223+
224+
// ====================================================================
225+
// 2. i18n Completeness Tests
226+
// ====================================================================
227+
228+
const SUPPORTED_LOCALES = ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar'] as const;
229+
const enLocale = crmLocales.en;
230+
231+
/** Collect all leaf keys from a nested object as dot-separated paths */
232+
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
233+
const keys: string[] = [];
234+
for (const [key, value] of Object.entries(obj)) {
235+
const path = prefix ? `${prefix}.${key}` : key;
236+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
237+
keys.push(...collectKeys(value as Record<string, unknown>, path));
238+
} else {
239+
keys.push(path);
240+
}
241+
}
242+
return keys;
243+
}
244+
245+
/** Resolve a dot-path into a nested object */
246+
function resolvePath(obj: Record<string, unknown>, path: string): unknown {
247+
const parts = path.split('.');
248+
let current: unknown = obj;
249+
for (const part of parts) {
250+
if (current === null || typeof current !== 'object') return undefined;
251+
current = (current as Record<string, unknown>)[part];
252+
}
253+
return current;
254+
}
255+
256+
describe('CRM i18n Completeness', () => {
257+
it('all 10 locales are exported', () => {
258+
for (const code of SUPPORTED_LOCALES) {
259+
expect(crmLocales[code]).toBeDefined();
260+
}
261+
});
262+
263+
it('all locales have top-level sections matching English', () => {
264+
const enTopKeys = Object.keys(enLocale).sort();
265+
for (const code of SUPPORTED_LOCALES) {
266+
const locale = crmLocales[code];
267+
const localeTopKeys = Object.keys(locale).sort();
268+
expect(localeTopKeys).toEqual(enTopKeys);
269+
}
270+
});
271+
272+
it('all locales cover every key defined in the English locale', () => {
273+
const enKeys = collectKeys(enLocale as unknown as Record<string, unknown>);
274+
expect(enKeys.length).toBeGreaterThan(100);
275+
276+
for (const code of SUPPORTED_LOCALES) {
277+
if (code === 'en') continue;
278+
const locale = crmLocales[code] as unknown as Record<string, unknown>;
279+
const missingKeys: string[] = [];
280+
for (const key of enKeys) {
281+
const val = resolvePath(locale, key);
282+
if (val === undefined) {
283+
missingKeys.push(key);
284+
}
285+
}
286+
expect(missingKeys).toEqual([]);
287+
}
288+
});
289+
290+
it('all locales have non-empty string values for leaf keys', () => {
291+
for (const code of SUPPORTED_LOCALES) {
292+
const locale = crmLocales[code] as unknown as Record<string, unknown>;
293+
const leafKeys = collectKeys(locale);
294+
for (const key of leafKeys) {
295+
const val = resolvePath(locale, key);
296+
expect(typeof val).toBe('string');
297+
expect((val as string).length).toBeGreaterThan(0);
298+
}
299+
}
300+
});
301+
302+
describe('Object labels coverage', () => {
303+
const objectNames = allObjects.map((o) => o.name);
304+
305+
it('English locale has a label for every CRM object', () => {
306+
for (const name of objectNames) {
307+
const objectKey = name as keyof typeof enLocale.objects;
308+
expect(enLocale.objects[objectKey]).toBeDefined();
309+
expect(enLocale.objects[objectKey].label).toBeDefined();
310+
}
311+
});
312+
});
313+
314+
describe('Navigation labels coverage', () => {
315+
it('English locale has all navigation labels', () => {
316+
const navKeys = Object.keys(enLocale.navigation);
317+
expect(navKeys.length).toBeGreaterThanOrEqual(17);
318+
expect(navKeys).toContain('dashboard');
319+
expect(navKeys).toContain('contacts');
320+
expect(navKeys).toContain('accounts');
321+
expect(navKeys).toContain('opportunities');
322+
expect(navKeys).toContain('pipeline');
323+
expect(navKeys).toContain('settings');
324+
expect(navKeys).toContain('help');
325+
});
326+
});
327+
328+
describe('Action labels coverage', () => {
329+
it('English locale has a label for every CRM action', () => {
330+
for (const action of allActions) {
331+
const actionKey = action.name as keyof typeof enLocale.actions;
332+
expect(enLocale.actions[actionKey]).toBeDefined();
333+
expect(enLocale.actions[actionKey].label).toBeDefined();
334+
}
335+
});
336+
});
337+
338+
describe('Dashboard widget labels coverage', () => {
339+
it('English locale has widget labels for all dashboard KPIs', () => {
340+
expect(enLocale.dashboard.widgets.totalRevenue).toBeDefined();
341+
expect(enLocale.dashboard.widgets.activeDeals).toBeDefined();
342+
expect(enLocale.dashboard.widgets.winRate).toBeDefined();
343+
expect(enLocale.dashboard.widgets.avgDealSize).toBeDefined();
344+
expect(enLocale.dashboard.widgets.revenueTrends).toBeDefined();
345+
expect(enLocale.dashboard.widgets.leadSource).toBeDefined();
346+
expect(enLocale.dashboard.widgets.pipelineByStage).toBeDefined();
347+
expect(enLocale.dashboard.widgets.topProducts).toBeDefined();
348+
expect(enLocale.dashboard.widgets.recentOpportunities).toBeDefined();
349+
expect(enLocale.dashboard.widgets.revenueByAccount).toBeDefined();
350+
});
351+
});
352+
});

examples/crm/src/actions/account.actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const AccountActions = [
3131
type: 'api' as const,
3232
locations: ['record_more' as const],
3333
confirmText: 'Are you sure you want to merge these accounts? This action cannot be undone.',
34-
variant: 'danger' as const,
34+
variant: 'destructive' as const,
3535
refreshAfter: true,
3636
},
3737
];

examples/crm/src/actions/event.actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const EventActions = [
2626
icon: 'x-circle',
2727
type: 'api' as const,
2828
locations: ['record_more' as const],
29-
variant: 'danger' as const,
29+
variant: 'destructive' as const,
3030
params: [
3131
{ name: 'cancel_reason', label: 'Cancellation Reason', type: 'text' as const },
3232
{ name: 'notify_participants', label: 'Notify Participants', type: 'boolean' as const },

examples/crm/src/actions/opportunity.actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const OpportunityActions = [
3838
icon: 'x-circle',
3939
type: 'api' as const,
4040
locations: ['record_more' as const],
41-
variant: 'danger' as const,
41+
variant: 'destructive' as const,
4242
params: [
4343
{ name: 'loss_reason', label: 'Reason for Loss', type: 'text' as const, required: true },
4444
],

0 commit comments

Comments
 (0)