Skip to content

Commit caae6d2

Browse files
committed
fix(dashboard): add context to operation value previews
1 parent 8f0c1c7 commit caae6d2

8 files changed

Lines changed: 298 additions & 40 deletions

packages/dashboard/e2e/tests/marketing/promotions.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@ test.describe('Promotions CRUD', () => {
8787

8888
const dp = detailPage(page);
8989

90-
// The condition renders as a sentence with the amount as a chip
91-
// (default value 100 minor units → displayed as 1)
90+
// The condition renders as a sentence with the amount and active
91+
// channel currency as a chip (default value 100 minor units → $1.00).
9292
const amountChip = page.getByRole('button', { name: 'amount' });
93-
await expect(amountChip).toHaveText('1');
93+
await expect(amountChip).toHaveText('$1.00');
9494

9595
// Edit the amount in the chip popover — changes apply live.
9696
// (MoneyInput does not forward the name attribute, so locate the
@@ -102,14 +102,14 @@ test.describe('Promotions CRUD', () => {
102102
.getByRole('textbox')
103103
.fill('50');
104104
await page.keyboard.press('Escape');
105-
await expect(amountChip).toHaveText('50');
105+
await expect(amountChip).toHaveText('$50.00');
106106

107107
await dp.clickUpdate();
108108
await dp.expectSuccessToast(/Successfully updated promotion/);
109109

110110
// The edited value persists across a reload
111111
await page.reload();
112-
await expect(page.getByRole('button', { name: 'amount' })).toHaveText('50');
112+
await expect(page.getByRole('button', { name: 'amount' })).toHaveText('$50.00');
113113
});
114114

115115
test('should update the promotion', async ({ page }) => {

packages/dashboard/src/lib/components/data-input/affixed-input.tsx

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,14 @@ export type AffixedInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>
2828
* @docsCategory form-components
2929
* @docsPage AffixedInput
3030
*/
31-
export function AffixedInput({ prefix, suffix, className = '', ...props }: Readonly<AffixedInputProps>) {
32-
const readOnly = props.disabled || isReadonlyField(props.fieldDef);
31+
export function AffixedInput({
32+
prefix,
33+
suffix,
34+
className = '',
35+
fieldDef,
36+
...inputProps
37+
}: Readonly<AffixedInputProps>) {
38+
const readOnly = inputProps.disabled || isReadonlyField(fieldDef);
3339
const prefixRef = useRef<HTMLSpanElement>(null);
3440
const suffixRef = useRef<HTMLSpanElement>(null);
3541
const [prefixWidth, setPrefixWidth] = useState(0);
@@ -44,7 +50,7 @@ export function AffixedInput({ prefix, suffix, className = '', ...props }: Reado
4450
}
4551
}, [prefix, suffix]);
4652

47-
const style = {
53+
const affixStyle = {
4854
paddingLeft: prefix ? `calc(1rem + ${prefixWidth}px)` : undefined,
4955
paddingRight: suffix ? `calc(1rem + ${suffixWidth}px)` : undefined,
5056
};
@@ -57,19 +63,10 @@ export function AffixedInput({ prefix, suffix, className = '', ...props }: Reado
5763
</span>
5864
)}
5965
<Input
60-
value={props.value}
61-
onChange={props.onChange}
62-
onBlur={props.onBlur}
63-
onFocus={props.onFocus}
64-
onKeyDown={props.onKeyDown}
65-
type={props.type}
66-
ref={props.ref}
66+
{...inputProps}
6767
className={className}
68-
style={style}
68+
style={{ ...inputProps.style, ...affixStyle }}
6969
disabled={readOnly}
70-
min={props.min}
71-
max={props.max}
72-
step={props.step}
7370
/>
7471
{suffix && (
7572
<span ref={suffixRef} className="absolute right-3 text-muted-foreground whitespace-nowrap">

packages/dashboard/src/lib/components/data-input/text-input.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Simple built-in components using the single DashboardFormComponent interface
1+
import { AffixedInput } from '@/vdb/components/data-input/affixed-input.js';
22
import { Input } from '@/vdb/components/ui/input.js';
33
import { DashboardFormComponent } from '@/vdb/framework/form-engine/form-engine-types.js';
44
import { isFieldDisabled } from '@/vdb/framework/form-engine/utils.js';
@@ -12,5 +12,22 @@ import { isFieldDisabled } from '@/vdb/framework/form-engine/utils.js';
1212
*/
1313
export const TextInput: DashboardFormComponent = ({ value, onChange, fieldDef, disabled, ...rest }) => {
1414
const readOnly = isFieldDisabled(disabled, fieldDef);
15-
return <Input value={value ?? ''} onChange={e => onChange(e.target.value)} disabled={readOnly} {...rest} />;
15+
const prefix = fieldDef?.ui?.prefix;
16+
const suffix = fieldDef?.ui?.suffix;
17+
if (prefix || suffix) {
18+
return (
19+
<AffixedInput
20+
{...rest}
21+
fieldDef={fieldDef}
22+
value={value ?? ''}
23+
onChange={event => onChange(event.target.value)}
24+
disabled={readOnly}
25+
prefix={prefix}
26+
suffix={suffix}
27+
/>
28+
);
29+
}
30+
return (
31+
<Input value={value ?? ''} onChange={e => onChange(e.target.value)} disabled={readOnly} {...rest} />
32+
);
1633
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
compactText,
5+
getJsonSummary,
6+
shouldUseListCountSummary,
7+
} from './configurable-operation-arg-summary.js';
8+
9+
describe('shouldUseListCountSummary', () => {
10+
it('uses a count when a scalar component is wrapped for a list field', () => {
11+
expect(shouldUseListCountSummary(true, [100, 200], undefined)).toBe(true);
12+
});
13+
14+
it('keeps semantic summaries for list-aware components', () => {
15+
expect(shouldUseListCountSummary(true, ['1', '2'], true)).toBe(false);
16+
expect(shouldUseListCountSummary(true, ['1', '2'], 'dynamic')).toBe(false);
17+
});
18+
});
19+
20+
describe('compactText', () => {
21+
it('strips HTML and normalizes whitespace', () => {
22+
expect(
23+
compactText(
24+
'<style>.hidden { display: none }</style><p>Hello <strong>world</strong></p><p>Next</p>',
25+
).full,
26+
).toBe('Hello world Next');
27+
});
28+
29+
it('truncates long content for sentence chips', () => {
30+
const result = compactText('a'.repeat(100));
31+
expect(result.compact).toHaveLength(80);
32+
expect(result.compact.endsWith('\u2026')).toBe(true);
33+
});
34+
});
35+
36+
describe('getJsonSummary', () => {
37+
it('summarizes arrays and objects', () => {
38+
expect(getJsonSummary('[1, 2, 3]')).toEqual({ type: 'items', count: 3 });
39+
expect(getJsonSummary('{"one": 1, "two": 2}')).toEqual({ type: 'properties', count: 2 });
40+
});
41+
42+
it('uses compact text for invalid JSON', () => {
43+
expect(getJsonSummary('not json')).toEqual({ type: 'text', value: 'not json' });
44+
});
45+
});

packages/dashboard/src/lib/components/shared/configurable-operation-arg-summary.tsx

Lines changed: 152 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { extractFieldOptions } from '@/vdb/framework/form-engine/utils.js';
77
import { transformValue } from '@/vdb/framework/form-engine/value-transformers.js';
88
import { api } from '@/vdb/graphql/api.js';
99
import { graphql } from '@/vdb/graphql/graphql.js';
10+
import { useChannel } from '@/vdb/hooks/use-channel.js';
1011
import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
1112
import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
1213
import { useLingui } from '@lingui/react/macro';
@@ -33,6 +34,17 @@ export interface ArgSummaryProps {
3334
fieldDef: ConfigurableFieldDef;
3435
/** The raw json-string encoded arg value */
3536
value: string;
37+
omitPrefix?: boolean;
38+
omitSuffix?: boolean;
39+
}
40+
41+
export function shouldUseListCountSummary(
42+
isListField: boolean,
43+
value: unknown,
44+
listInputMode: boolean | 'dynamic' | undefined,
45+
): boolean {
46+
const componentHandlesList = listInputMode === true || listInputMode === 'dynamic';
47+
return isListField && Array.isArray(value) && !componentHandlesList;
3648
}
3749

3850
/**
@@ -47,17 +59,32 @@ export interface ArgSummaryProps {
4759
* 3. A default summary based on the arg type (boolean, datetime, list count,
4860
* plain value)
4961
*/
50-
export function ArgSummary({ fieldDef, value }: Readonly<ArgSummaryProps>) {
62+
export function ArgSummary({ fieldDef, value, omitPrefix, omitSuffix }: Readonly<ArgSummaryProps>) {
5163
const componentId = fieldDef.ui?.component as string | undefined;
5264
const CustomComponent = getInputComponent(componentId);
5365
const parsedValue = transformValue(value, fieldDef, 'json-string', 'parse');
66+
const isListValue = fieldDef.list === true && Array.isArray(parsedValue);
67+
const listInputMode = CustomComponent?.metadata?.isListInput;
68+
const useListCountSummary = shouldUseListCountSummary(fieldDef.list, parsedValue, listInputMode);
5469

55-
const Summary =
56-
CustomComponent?.metadata?.summary ??
57-
(componentId ? BUILT_IN_SUMMARIES[componentId] : undefined) ??
58-
DefaultArgSummary;
70+
const Summary = useListCountSummary
71+
? DefaultArgSummary
72+
: (CustomComponent?.metadata?.summary ??
73+
(componentId ? BUILT_IN_SUMMARIES[componentId] : undefined) ??
74+
DefaultArgSummary);
5975

60-
return <Summary value={parsedValue} fieldDef={fieldDef} />;
76+
const isAffixedComponent =
77+
!isListValue && (componentId === 'number-form-input' || componentId === 'text-form-input');
78+
const prefix = isAffixedComponent && !omitPrefix ? fieldDef.ui?.prefix : undefined;
79+
const suffix = isAffixedComponent && !omitSuffix ? fieldDef.ui?.suffix : undefined;
80+
81+
return (
82+
<>
83+
{prefix}
84+
<Summary value={parsedValue} fieldDef={fieldDef} />
85+
{suffix}
86+
</>
87+
);
6188
}
6289

6390
function DefaultArgSummary({ value, fieldDef }: Readonly<DashboardFormComponentSummaryProps>) {
@@ -77,8 +104,83 @@ function DefaultArgSummary({ value, fieldDef }: Readonly<DashboardFormComponentS
77104
}
78105

79106
function CurrencySummary({ value }: Readonly<DashboardFormComponentSummaryProps>) {
80-
const { toMajorUnits, formatNumber } = useLocalFormat();
81-
return <>{formatNumber(toMajorUnits(Number(value)))}</>;
107+
const { activeChannel } = useChannel();
108+
const { formatCurrency, toMajorUnits, formatNumber } = useLocalFormat();
109+
const currencyCode = activeChannel?.defaultCurrencyCode;
110+
return (
111+
<>
112+
{currencyCode
113+
? formatCurrency(Number(value), currencyCode)
114+
: formatNumber(toMajorUnits(Number(value)))}
115+
</>
116+
);
117+
}
118+
119+
function PasswordSummary() {
120+
return <>{'\u2022'.repeat(8)}</>;
121+
}
122+
123+
const MAX_TEXT_SUMMARY_LENGTH = 80;
124+
125+
export function compactText(value: unknown): { full: string; compact: string } {
126+
const raw = String(value ?? '')
127+
.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '')
128+
.replace(
129+
/<\/(address|article|aside|blockquote|div|figcaption|figure|footer|header|h[1-6]|li|main|nav|ol|p|pre|section|table|tr|ul)>/gi,
130+
' ',
131+
);
132+
let plainText: string;
133+
if (typeof DOMParser === 'undefined') {
134+
plainText = raw.replace(/<[^>]*>/g, ' ');
135+
} else {
136+
plainText = new DOMParser().parseFromString(raw, 'text/html').body.textContent ?? '';
137+
}
138+
const full = plainText.replace(/\s+/g, ' ').trim();
139+
const compact =
140+
full.length > MAX_TEXT_SUMMARY_LENGTH
141+
? `${full.slice(0, MAX_TEXT_SUMMARY_LENGTH - 1).trimEnd()}\u2026`
142+
: full;
143+
return { full, compact };
144+
}
145+
146+
function RichTextSummary({ value }: Readonly<DashboardFormComponentSummaryProps>) {
147+
const { full, compact } = compactText(value);
148+
return <span title={full}>{compact}</span>;
149+
}
150+
151+
export type JsonSummary =
152+
| { type: 'items'; count: number }
153+
| { type: 'properties'; count: number }
154+
| { type: 'text'; value: string };
155+
156+
export function getJsonSummary(value: unknown): JsonSummary {
157+
let parsed = value;
158+
if (typeof value === 'string') {
159+
try {
160+
parsed = JSON.parse(value);
161+
} catch {
162+
return { type: 'text', value: compactText(value).compact };
163+
}
164+
}
165+
if (Array.isArray(parsed)) {
166+
return { type: 'items', count: parsed.length };
167+
}
168+
if (parsed !== null && typeof parsed === 'object') {
169+
return { type: 'properties', count: Object.keys(parsed).length };
170+
}
171+
return { type: 'text', value: String(parsed) };
172+
}
173+
174+
function JsonSummary({ value }: Readonly<DashboardFormComponentSummaryProps>) {
175+
const { t } = useLingui();
176+
const summary = getJsonSummary(value);
177+
if (summary.type === 'items') {
178+
return <>{summary.count === 1 ? t`1 item` : t`${summary.count} items`}</>;
179+
}
180+
if (summary.type === 'properties') {
181+
return <>{summary.count === 1 ? t`1 property` : t`${summary.count} properties`}</>;
182+
}
183+
return <>{summary.value}</>;
82184
}
83185

84186
function SelectOptionsSummary({ value, fieldDef }: Readonly<DashboardFormComponentSummaryProps>) {
@@ -143,8 +245,7 @@ function ProductVariantSummary({ value }: Readonly<DashboardFormComponentSummary
143245
const ids = toIdArray(value);
144246
const { data } = useQuery({
145247
queryKey: ['ProductVariantSummary', ids],
146-
queryFn: () =>
147-
api.query(productVariantSummaryDocument, { options: { filter: { id: { in: ids } } } }),
248+
queryFn: () => api.query(productVariantSummaryDocument, { options: { filter: { id: { in: ids } } } }),
148249
enabled: ids.length > 0,
149250
placeholderData: undefined,
150251
});
@@ -155,6 +256,40 @@ function ProductVariantSummary({ value }: Readonly<DashboardFormComponentSummary
155256
return <NameList names={items.map(item => item.name)} />;
156257
}
157258

259+
const productSummaryDocument = graphql(`
260+
query ProductSummary($options: ProductListOptions) {
261+
products(options: $options) {
262+
items {
263+
id
264+
name
265+
}
266+
}
267+
}
268+
`);
269+
270+
function ProductSummary({ value }: Readonly<DashboardFormComponentSummaryProps>) {
271+
const ids = toIdArray(value);
272+
const { data } = useQuery({
273+
queryKey: ['ProductSummary', ids],
274+
queryFn: () => api.query(productSummaryDocument, { options: { filter: { id: { in: ids } } } }),
275+
enabled: ids.length > 0,
276+
placeholderData: undefined,
277+
});
278+
const items = data?.products.items;
279+
if (!items) {
280+
return <EntityCountFallback count={ids.length} />;
281+
}
282+
return <NameList names={items.map(item => item.name)} />;
283+
}
284+
285+
function ProductMultiSummary({ value, fieldDef }: Readonly<DashboardFormComponentSummaryProps>) {
286+
return fieldDef.ui?.selectionMode === 'variant' ? (
287+
<ProductVariantSummary value={value} fieldDef={fieldDef} />
288+
) : (
289+
<ProductSummary value={value} fieldDef={fieldDef} />
290+
);
291+
}
292+
158293
const customerGroupSummaryDocument = graphql(`
159294
query CustomerGroupSummary($options: CustomerGroupListOptions) {
160295
customerGroups(options: $options) {
@@ -170,8 +305,7 @@ function CustomerGroupSummary({ value }: Readonly<DashboardFormComponentSummaryP
170305
const ids = toIdArray(value);
171306
const { data } = useQuery({
172307
queryKey: ['CustomerGroupSummary', ids],
173-
queryFn: () =>
174-
api.query(customerGroupSummaryDocument, { options: { filter: { id: { in: ids } } } }),
308+
queryFn: () => api.query(customerGroupSummaryDocument, { options: { filter: { id: { in: ids } } } }),
175309
enabled: ids.length > 0,
176310
placeholderData: undefined,
177311
});
@@ -212,6 +346,12 @@ function toIdArray(value: any): string[] {
212346

213347
const BUILT_IN_SUMMARIES: Record<string, ComponentType<DashboardFormComponentSummaryProps>> = {
214348
'currency-form-input': CurrencySummary,
349+
'html-editor-form-input': RichTextSummary,
350+
'json-editor-form-input': JsonSummary,
351+
'password-form-input': PasswordSummary,
352+
'product-multi-form-input': ProductMultiSummary,
353+
'product-multi-input': ProductMultiSummary,
354+
'rich-text-form-input': RichTextSummary,
215355
'select-form-input': SelectOptionsSummary,
216356
'facet-value-form-input': FacetValueSummary,
217357
'facet-value-input': FacetValueSummary,

0 commit comments

Comments
 (0)