Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions renderers/web_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Unreleased

- Add locale support to `SurfaceModel` and `DataContext` in v0.9.
- Update `pluralize`, `formatNumber`, and `formatCurrency` to use the context locale instead of hardcoding 'en-US'.
- Remove `.passthrough()` from `PluralizeApi` schema for stricter validation.
Comment thread
gspencergoog marked this conversation as resolved.

## 0.10.0

- **BREAKING CHANGE**: Rename Icon `path` property to `svgPath` to fix type collision with `DataBindingType`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ const createTestDataContext = (
model: DataModel,
path: string,
functionInvoker: any = testCatalog.invoker,
locale?: string,
) => {
const mockSurface = {
dataModel: model,
catalog: {invoker: functionInvoker},
dispatchError: () => {},
locale: locale,
} as any;
return new DataContext(mockSurface, path);
};
Expand Down Expand Up @@ -354,6 +356,35 @@ describe('BASIC_FUNCTIONS', () => {
'apples',
);
});

it('pluralize with Welsh locale', () => {
const cyContext = createTestDataContext(dataModel, '/', testCatalog.invoker, 'cy');
// Welsh for various numbers of "cat". Welsh because all six cases have different rules.
const args = {
zero: 'cathod',
one: 'gath',
two: 'gath',
few: 'cath',
many: 'chath',
other: 'cath',
};

assert.strictEqual(invoke('pluralize', {...args, value: 0}, cyContext), 'cathod');
assert.strictEqual(invoke('pluralize', {...args, value: 1}, cyContext), 'gath');
assert.strictEqual(invoke('pluralize', {...args, value: 2}, cyContext), 'gath');
assert.strictEqual(invoke('pluralize', {...args, value: 3}, cyContext), 'cath');
assert.strictEqual(invoke('pluralize', {...args, value: 6}, cyContext), 'chath');
assert.strictEqual(invoke('pluralize', {...args, value: 4}, cyContext), 'cath');
});

it('pluralize fallback to other', () => {
assert.strictEqual(
invoke('pluralize', {value: 5, one: 'apple', other: 'apples'}, context),
'apples',
);
Comment thread
gspencergoog marked this conversation as resolved.
assert.strictEqual(invoke('pluralize', {value: 1, other: 'apples'}, context), 'apples');
assert.strictEqual(invoke('pluralize', {value: 0, other: 'apples'}, context), 'apples');
});
});

describe('Actions', () => {
Expand Down
108 changes: 86 additions & 22 deletions renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,37 +267,81 @@ export const FormatStringImplementation = createFunctionImplementation(
});
},
);
const numberFormatCache = new Map<string, Intl.NumberFormat>();

function getNumberFormat(
locale: string | undefined,
decimals?: number,
grouping?: boolean,
): Intl.NumberFormat {
const key = `${locale ?? 'default'}:${decimals ?? 'undef'}:${grouping ?? 'true'}`;
let formatter = numberFormatCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
useGrouping: grouping,
});
numberFormatCache.set(key, formatter);
}
return formatter;
}

/**
* Implementation of the number formatting function.
* Formats a number using Intl.NumberFormat with specified decimals and grouping.
*/
export const FormatNumberImplementation = createFunctionImplementation(FormatNumberApi, args => {
if (isNaN(args.value)) return '';
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: args.decimals,
maximumFractionDigits: args.decimals,
useGrouping: args.grouping,
}).format(args.value);
});
export const FormatNumberImplementation = createFunctionImplementation(
FormatNumberApi,
(args, context) => {
Comment thread
gspencergoog marked this conversation as resolved.
if (isNaN(args.value)) return '';
try {
return getNumberFormat(context.locale, args.decimals, args.grouping).format(args.value);
} catch (e) {
console.warn('Error formatting number:', e);
return args.decimals !== undefined ? args.value.toFixed(args.decimals) : String(args.value);
}
},
);
const currencyFormatCache = new Map<string, Intl.NumberFormat>();

function getCurrencyFormat(
locale: string | undefined,
currency: string,
decimals?: number,
grouping?: boolean,
): Intl.NumberFormat {
const key = `${locale ?? 'default'}:${currency}:${decimals ?? 'undef'}:${grouping ?? 'true'}`;
let formatter = currencyFormatCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
useGrouping: grouping,
});
currencyFormatCache.set(key, formatter);
}
return formatter;
}

/**
* Implementation of the currency formatting function.
* Formats a number as currency using Intl.NumberFormat.
* Falls back to toFixed if formatting fails.
*/
export const FormatCurrencyImplementation = createFunctionImplementation(
FormatCurrencyApi,
args => {
(args, context) => {
if (isNaN(args.value)) return '';
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: args.currency,
minimumFractionDigits: args.decimals,
maximumFractionDigits: args.decimals,
useGrouping: args.grouping,
}).format(args.value);
} catch {
return args.value.toFixed(args.decimals || 2);
return getCurrencyFormat(context.locale, args.currency, args.decimals, args.grouping).format(
args.value,
);
} catch (e) {
console.warn('Error formatting currency:', e);
return args.value.toFixed(args.decimals ?? 2);
}
},
);
Expand All @@ -318,14 +362,34 @@ export const FormatDateImplementation = createFunctionImplementation(FormatDateA
return date.toISOString();
}
});
const pluralRulesCache = new Map<string, Intl.PluralRules>();

function getPluralRules(locale: string | undefined): Intl.PluralRules {
const key = locale ?? 'default';
let rules = pluralRulesCache.get(key);
if (!rules) {
Comment thread
gspencergoog marked this conversation as resolved.
rules = new Intl.PluralRules(locale);
pluralRulesCache.set(key, rules);
}
return rules;
}

/**
* Implementation of the pluralization function.
* Selects the appropriate plural form based on the value using Intl.PluralRules.
*/
export const PluralizeImplementation = createFunctionImplementation(PluralizeApi, args => {
const rule = new Intl.PluralRules('en-US').select(args.value);
return String((args as Record<string, unknown>)[rule] ?? args.other ?? '');
});
export const PluralizeImplementation = createFunctionImplementation(
PluralizeApi,
(args, context) => {
try {
const rule = getPluralRules(context.locale).select(args.value);
return String((args as Record<string, unknown>)[rule] ?? args.other ?? '');
} catch (e) {
console.warn('Error in pluralize:', e);
return String(args.other ?? '');
}
},
);

// Actions
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -432,17 +432,15 @@ export const FormatDateApi = {
export const PluralizeApi = {
name: 'pluralize' as const,
returnType: 'string' as const,
schema: z
.object({
value: z.coerce.number(),
zero: z.coerce.string().optional(),
one: z.coerce.string().optional(),
two: z.coerce.string().optional(),
few: z.coerce.string().optional(),
many: z.coerce.string().optional(),
other: z.coerce.string(),
})
.passthrough(),
schema: z.object({
value: z.coerce.number(),
zero: z.coerce.string().optional(),
one: z.coerce.string().optional(),
two: z.coerce.string().optional(),
few: z.coerce.string().optional(),
many: z.coerce.string().optional(),
other: z.coerce.string(),
}),
};

// Actions
Expand Down
7 changes: 7 additions & 0 deletions renderers/web_core/src/v0_9/rendering/data-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export class DataContext {
this.functionInvoker = surface.catalog.invoker;
}

/**
* Gets the locale for this context, inherited from the surface.
*/
get locale(): string | undefined {
Comment thread
gspencergoog marked this conversation as resolved.
return this.surface.locale;
}

/**
* Mutates the underlying DataModel at the specified path.
*
Expand Down
2 changes: 2 additions & 0 deletions renderers/web_core/src/v0_9/state/surface-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,14 @@ export class SurfaceModel<T extends ComponentApi = ComponentApi> {
* @param catalog The component catalog used by this surface.
* @param theme The theme to apply to this surface.
* @param sendDataModel If true, the client will send the full data model.
* @param locale The locale to use in locale-sensitive functions.
*/
constructor(
readonly id: string,
readonly catalog: Catalog<T>,
readonly theme: any = {},
readonly sendDataModel: boolean = false,
readonly locale?: string,
Comment thread
gspencergoog marked this conversation as resolved.
) {
this.dataModel = new DataModel({});
this.componentsModel = new SurfaceComponentsModel();
Expand Down
Loading