diff --git a/frontend/src/components/FieldsSelector/FieldsSelector.tsx b/frontend/src/components/FieldsSelector/FieldsSelector.tsx index 22f3093c20a..da1947d79e3 100644 --- a/frontend/src/components/FieldsSelector/FieldsSelector.tsx +++ b/frontend/src/components/FieldsSelector/FieldsSelector.tsx @@ -28,6 +28,9 @@ interface FieldsSelectorProps { signal: DataSource; maxFields?: number; requiredFields?: readonly string[]; + // Lets users add a free-typed field which + // does not show up in the suggestions + allowCustomFields?: boolean; width?: number; height?: number; defaultPosition?: { x: number; y: number }; @@ -46,6 +49,7 @@ function FieldsSelectorContent({ signal, maxFields, requiredFields, + allowCustomFields, width = DEFAULT_PANEL_WIDTH, height, defaultPosition, @@ -67,7 +71,7 @@ function FieldsSelectorContent({ const handleInputChange = useCallback( (e: React.ChangeEvent): void => { - const value = e.target.value.trim().toLowerCase(); + const value = e.target.value.trim(); setInputValue(value); debouncedUpdate(value); }, @@ -153,6 +157,7 @@ function FieldsSelectorContent({ addedFields={draftFields} onAdd={handleAdd} isAtLimit={isAtLimit} + allowCustomFields={allowCustomFields} /> {hasUnsavedChanges && ( @@ -192,7 +197,7 @@ function FieldsSelector({ () => fields.map((f) => ({ ...f, - key: f.key ?? buildCompositeKey(f.name, f.fieldContext), + key: buildCompositeKey(f.name, f.fieldContext), })), [fields], ); diff --git a/frontend/src/components/FieldsSelector/OtherFields.tsx b/frontend/src/components/FieldsSelector/OtherFields.tsx index d30a91b9da4..6f9a3a5dddb 100644 --- a/frontend/src/components/FieldsSelector/OtherFields.tsx +++ b/frontend/src/components/FieldsSelector/OtherFields.tsx @@ -21,6 +21,7 @@ interface OtherFieldsProps { addedFields: TelemetryFieldKey[]; onAdd: (field: TelemetryFieldKey) => void; isAtLimit: boolean; + allowCustomFields?: boolean; } function OtherFields({ @@ -29,6 +30,7 @@ function OtherFields({ addedFields, onAdd, isAtLimit, + allowCustomFields, }: OtherFieldsProps): JSX.Element { const { data, isFetching } = useGetQueryKeySuggestions( { @@ -45,25 +47,45 @@ function OtherFields({ }, ); - const otherFields: TelemetryFieldKey[] = useMemo(() => { - const suggestions = Object.values(data?.data.data.keys || {}).flat(); + const otherFields = useMemo(() => { + const rawSuggestions = Object.values(data?.data.data.keys || {}).flat(); // Normalize: synthesize `key` once so downstream reads can trust it. - const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map( - (attr) => ({ - ...attr, - key: buildCompositeKey(attr.name, attr.fieldContext as string), - signal: attr.signal as SignalType, - fieldContext: attr.fieldContext as FieldContext, - fieldDataType: attr.fieldDataType, - }), - ); + const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({ + ...attr, + key: buildCompositeKey(attr.name, attr.fieldContext as string), + signal: attr.signal as SignalType, + fieldContext: attr.fieldContext as FieldContext, + fieldDataType: attr.fieldDataType, + })); const addedIds = new Set( - addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)), + addedFields.map((f) => buildCompositeKey(f.name, f.fieldContext)), ); - return normalizedSuggestions.filter( + const available = suggestions.filter( (attr) => !addedIds.has(attr.key as string), ); - }, [data, addedFields]); + + // Prepend the custom field when its name is not in suggestions and + // not already added. + const typed = debouncedInputValue.trim(); + const nameMatches = (list: TelemetryFieldKey[]): boolean => + list.some((f) => f.name.toLowerCase() === typed.toLowerCase()); + const showCustom = + !!allowCustomFields && + typed.length > 0 && + !nameMatches(suggestions) && + !nameMatches(addedFields); + + if (!showCustom) { + return available; + } + const customField: TelemetryFieldKey = { + name: typed, + fieldContext: '', + fieldDataType: '', + key: buildCompositeKey(typed, ''), + }; + return [customField, ...available]; + }, [data, addedFields, allowCustomFields, debouncedInputValue]); if (isFetching) { return ( diff --git a/frontend/src/components/FieldsSelector/__tests__/AddedFields.test.tsx b/frontend/src/components/FieldsSelector/__tests__/AddedFields.test.tsx index bf4697ee430..ba6da5276ad 100644 --- a/frontend/src/components/FieldsSelector/__tests__/AddedFields.test.tsx +++ b/frontend/src/components/FieldsSelector/__tests__/AddedFields.test.tsx @@ -11,7 +11,7 @@ const makeField = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({ signal: 'logs', fieldContext: fieldContext as TelemetryFieldKey['fieldContext'], fieldDataType: 'string', - key: `${fieldContext}.${name}`, + key: `${fieldContext}:${name}`, }); describe('AddedFields — requiredFields', () => { @@ -33,7 +33,7 @@ describe('AddedFields — requiredFields', () => { inputValue="" fields={fields} onFieldsChange={jest.fn()} - requiredFields={['log.a', 'log.c']} + requiredFields={['log:a', 'log:c']} />, ); @@ -50,7 +50,7 @@ describe('AddedFields — requiredFields', () => { inputValue="" fields={fields} onFieldsChange={jest.fn()} - requiredFields={['log.a']} + requiredFields={['log:a']} />, ); @@ -68,7 +68,7 @@ describe('AddedFields — requiredFields', () => { inputValue="" fields={fields} onFieldsChange={jest.fn()} - requiredFields={['log.body']} + requiredFields={['log:body']} />, ); @@ -101,11 +101,11 @@ describe('AddedFields — requiredFields', () => { inputValue="" fields={fields} onFieldsChange={jest.fn()} - requiredFields={['log.body']} + requiredFields={['log:body']} />, ); - // 'log.body' locked, 'log.body_extra' removable. + // 'log:body' locked, 'log:body_extra' removable. expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1); }); }); diff --git a/frontend/src/components/FieldsSelector/__tests__/FieldsSelector.test.tsx b/frontend/src/components/FieldsSelector/__tests__/FieldsSelector.test.tsx new file mode 100644 index 00000000000..26189843c36 --- /dev/null +++ b/frontend/src/components/FieldsSelector/__tests__/FieldsSelector.test.tsx @@ -0,0 +1,188 @@ +import { act, fireEvent, render, screen } from 'tests/test-utils'; +import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions'; +import { TelemetryFieldKey } from 'types/api/v5/queryRange'; +import { DataSource } from 'types/common/queryBuilder'; + +import FieldsSelector from '../FieldsSelector'; + +jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions'); + +jest.mock('@signozhq/ui/sonner', () => ({ + ...jest.requireActual('@signozhq/ui/sonner'), + toast: { success: jest.fn(), error: jest.fn() }, +})); + +// FloatingPanel is a react-rnd/portal shell — presentation only. Render its +// children directly so the test exercises the column-editing behavior. +jest.mock('periscope/components/FloatingPanel', () => ({ + FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => ( +
{children}
+ ), +})); + +const mockSuggestions = (names: string[]): void => { + (useGetQueryKeySuggestions as jest.Mock).mockReturnValue({ + data: { + data: { + data: { + keys: { + attributeKeys: names.map((name) => ({ + name, + signal: 'logs', + fieldDataType: 'string', + fieldContext: '', + })), + }, + }, + }, + }, + isFetching: false, + }); +}; + +const field = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({ + name, + signal: 'logs', + fieldContext: fieldContext as TelemetryFieldKey['fieldContext'], + fieldDataType: 'string', +}); + +const renderPanel = ( + props: Partial> = {}, +): { onFieldsChange: jest.Mock } => { + const onFieldsChange = jest.fn(); + render( + , + ); + return { onFieldsChange }; +}; + +// Type into the search box and flush the 400ms debounce so OtherFields (driven +// by the debounced value) recomputes. +const typeSearch = (value: string): void => { + const input = screen.getByPlaceholderText('Search for a field...'); + act(() => { + fireEvent.change(input, { target: { value } }); + }); + act(() => { + jest.advanceTimersByTime(400); + }); +}; + +describe('FieldsSelector — edit columns (integration)', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockSuggestions([]); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('adds a free-typed field end to end and saves the synthesized key', () => { + const { onFieldsChange } = renderPanel({ fields: [field('body')] }); + + typeSearch('orderId'); + + // custom option surfaces in OTHER FIELDS (only Add button, no suggestions) + expect(screen.getByText('orderId')).toBeInTheDocument(); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: /^add$/i })); + }); + + // moved into ADDED FIELDS → OTHER FIELDS has nothing left to offer + expect(screen.getByText('No values found')).toBeInTheDocument(); + + // Save commits the draft + act(() => { + fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + }); + + expect(onFieldsChange).toHaveBeenCalledTimes(1); + const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[]; + expect(saved).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'orderId', + fieldContext: '', + fieldDataType: '', + key: 'orderId', + }), + ]), + ); + }); + + it('adds a suggested field: it moves from OTHER FIELDS into ADDED FIELDS', () => { + mockSuggestions(['service.name']); + const { onFieldsChange } = renderPanel({ fields: [] }); + + const addButton = screen.getByRole('button', { name: /^add$/i }); + act(() => { + fireEvent.click(addButton); + }); + + // now removable in ADDED FIELDS, no longer offered in OTHER FIELDS + expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /^add$/i }), + ).not.toBeInTheDocument(); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: /save changes/i })); + }); + const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[]; + expect(saved.map((f) => f.name)).toContain('service.name'); + }); + + it('hides the custom option when the typed name is already added', () => { + renderPanel({ fields: [field('orderId')] }); + + typeSearch('ORDERID'); + + // exact name already added → nothing left to offer in OTHER FIELDS + expect(screen.queryByText('ORDERID')).not.toBeInTheDocument(); + expect(screen.getByText('No values found')).toBeInTheDocument(); + }); + + it('does not offer a custom option when allowCustomFields is off', () => { + renderPanel({ fields: [], allowCustomFields: false }); + + typeSearch('unknown.a.b.c'); + + // no custom row and nothing addable + expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /^add$/i }), + ).not.toBeInTheDocument(); + }); + + it('discards an added field, reverting the draft', () => { + const { onFieldsChange } = renderPanel({ fields: [field('body')] }); + + typeSearch('orderId'); + act(() => { + fireEvent.click(screen.getByRole('button', { name: /^add$/i })); + }); + + // clear the search so the added list is not filtered + typeSearch(''); + + act(() => { + fireEvent.click(screen.getByRole('button', { name: /discard/i })); + }); + + expect(screen.queryByText('orderId')).not.toBeInTheDocument(); + expect(onFieldsChange).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/FieldsSelector/__tests__/OtherFields.test.tsx b/frontend/src/components/FieldsSelector/__tests__/OtherFields.test.tsx new file mode 100644 index 00000000000..b86247f7916 --- /dev/null +++ b/frontend/src/components/FieldsSelector/__tests__/OtherFields.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen } from 'tests/test-utils'; +import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions'; +import { TelemetryFieldKey } from 'types/api/v5/queryRange'; +import { DataSource } from 'types/common/queryBuilder'; + +import OtherFields from '../OtherFields'; + +jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions'); + +const mockSuggestions = (names: string[]): void => { + (useGetQueryKeySuggestions as jest.Mock).mockReturnValue({ + data: { + data: { + data: { + keys: { + attributeKeys: names.map((name) => ({ + name, + signal: 'logs', + fieldDataType: 'string', + fieldContext: '', + })), + }, + }, + }, + }, + isFetching: false, + }); +}; + +const renderOtherFields = ( + props: Partial> = {}, +): { onAdd: jest.Mock } => { + const onAdd = jest.fn(); + render( + , + ); + return { onAdd }; +}; + +const addedField = (name: string): TelemetryFieldKey => ({ + name, + signal: 'logs', + fieldContext: '', + fieldDataType: '', + key: name, +}); + +describe('OtherFields — custom (free-typed) option', () => { + beforeEach(() => { + mockSuggestions([]); + }); + + it('shows a custom option for a typed name that is not a suggestion', () => { + renderOtherFields({ debouncedInputValue: 'unknown.a.b.c' }); + + expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument(); + }); + + it('synthesizes the field with raw name, empty context/type, on add', () => { + const { onAdd } = renderOtherFields({ debouncedInputValue: 'orderId' }); + + fireEvent.click(screen.getByRole('button', { name: /add/i })); + + expect(onAdd).toHaveBeenCalledWith({ + name: 'orderId', + fieldContext: '', + fieldDataType: '', + key: 'orderId', + }); + }); + + it('hides the custom option when an exact suggestion exists (case-insensitive)', () => { + mockSuggestions(['orderId']); + renderOtherFields({ debouncedInputValue: 'orderid' }); + + // the real suggestion shows, the lowercased custom name does not + expect(screen.getByText('orderId')).toBeInTheDocument(); + expect(screen.queryByText('orderid')).not.toBeInTheDocument(); + }); + + it('hides the custom option when the name is already added (case-insensitive)', () => { + renderOtherFields({ + debouncedInputValue: 'ORDERID', + addedFields: [addedField('orderId')], + }); + + expect(screen.queryByText('ORDERID')).not.toBeInTheDocument(); + expect(screen.getByText('No values found')).toBeInTheDocument(); + }); + + it('does not show the custom option when allowCustomFields is off', () => { + renderOtherFields({ + debouncedInputValue: 'unknown.a.b.c', + allowCustomFields: false, + }); + + expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument(); + expect(screen.getByText('No values found')).toBeInTheDocument(); + }); + + it('does not show the custom option for an empty input', () => { + renderOtherFields({ debouncedInputValue: ' ' }); + + expect(screen.getByText('No values found')).toBeInTheDocument(); + }); + + it('shows the custom option at the field limit but hides its Add button', () => { + renderOtherFields({ debouncedInputValue: 'unknown.a.b.c', isAtLimit: true }); + + // same as every other row at the limit: name shown, no Add button + expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /add/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Logs/TableView/__tests__/useLogsTableColumns.test.tsx b/frontend/src/components/Logs/TableView/__tests__/useLogsTableColumns.test.tsx index 9f433f1ef96..09ce26ad2b1 100644 --- a/frontend/src/components/Logs/TableView/__tests__/useLogsTableColumns.test.tsx +++ b/frontend/src/components/Logs/TableView/__tests__/useLogsTableColumns.test.tsx @@ -51,13 +51,13 @@ describe('useLogsTableColumns — selectColumns-order respected', () => { ); // body/timestamp appear where the caller placed them, keyed by their - // composite IDs ('log.*'); contextless user fields collapse to bare name. + // composite IDs ('log:*'); contextless user fields collapse to bare name. expect(result.current.map((c) => c.id)).toStrictEqual([ 'state-indicator', 'service.name', - 'log.body', + 'log:body', 'request.id', - 'log.timestamp', + 'log:timestamp', ]); }); @@ -70,14 +70,14 @@ describe('useLogsTableColumns — selectColumns-order respected', () => { ); const byId = new Map(result.current.map((c) => [c.id, c])); - // Attribute variant is its own column, not a duplicate 'log.body'. + // Attribute variant is its own column, not a duplicate 'log:body'. expect(result.current.map((c) => c.id)).toStrictEqual([ 'state-indicator', - 'log.body', - 'attribute.body', + 'log:body', + 'attribute:body', ]); - expect(byId.get('log.body')?.enableRemove).toBe(false); - expect(byId.get('attribute.body')?.enableRemove).toBe(true); + expect(byId.get('log:body')?.enableRemove).toBe(false); + expect(byId.get('attribute:body')?.enableRemove).toBe(true); }); it('applies the same distinct-column treatment to timestamp variants', () => { @@ -91,11 +91,11 @@ describe('useLogsTableColumns — selectColumns-order respected', () => { const byId = new Map(result.current.map((c) => [c.id, c])); expect(result.current.map((c) => c.id)).toStrictEqual([ 'state-indicator', - 'log.timestamp', - 'attribute.timestamp', + 'log:timestamp', + 'attribute:timestamp', ]); - expect(byId.get('log.timestamp')?.enableRemove).toBe(false); - expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true); + expect(byId.get('log:timestamp')?.enableRemove).toBe(false); + expect(byId.get('attribute:timestamp')?.enableRemove).toBe(true); }); it('skips the synthetic "id" field name', () => { @@ -127,10 +127,10 @@ describe('useLogsTableColumns — selectColumns-order respected', () => { const byId = new Map(result.current.map((c) => [c.id, c])); // body + timestamp are locked from the table-X removal pathway. - expect(byId.get('log.body')?.canBeHidden).toBe(false); - expect(byId.get('log.body')?.enableRemove).toBe(false); - expect(byId.get('log.timestamp')?.canBeHidden).toBe(false); - expect(byId.get('log.timestamp')?.enableRemove).toBe(false); + expect(byId.get('log:body')?.canBeHidden).toBe(false); + expect(byId.get('log:body')?.enableRemove).toBe(false); + expect(byId.get('log:timestamp')?.canBeHidden).toBe(false); + expect(byId.get('log:timestamp')?.enableRemove).toBe(false); // User-added fields stay removable. User field has type='' so composite // collapses to bare name. expect(byId.get('user_field')?.enableRemove).toBe(true); diff --git a/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx b/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx index 3efc4afce06..af865e547e1 100644 --- a/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx +++ b/frontend/src/container/LiveLogs/LiveLogsContainer/index.tsx @@ -275,6 +275,7 @@ function LiveLogsContainer({ onClose={(): void => setIsFieldsSelectorOpen(false)} signal={DataSource.LOGS} requiredFields={LOGS_REQUIRED_COLUMNS} + allowCustomFields /> )} diff --git a/frontend/src/container/LogsExplorerViews/LogsActionsContainer.tsx b/frontend/src/container/LogsExplorerViews/LogsActionsContainer.tsx index fca7430bc3a..f82fdd8aea6 100644 --- a/frontend/src/container/LogsExplorerViews/LogsActionsContainer.tsx +++ b/frontend/src/container/LogsExplorerViews/LogsActionsContainer.tsx @@ -113,6 +113,7 @@ function LogsActionsContainer({ onClose={(): void => setIsFieldsSelectorOpen(false)} signal={DataSource.LOGS} requiredFields={LOGS_REQUIRED_COLUMNS} + allowCustomFields /> )} diff --git a/frontend/src/container/OptionsMenu/__test__/useOptionsMenu.test.ts b/frontend/src/container/OptionsMenu/__test__/useOptionsMenu.test.ts index 8d1650312ba..6933c01390f 100644 --- a/frontend/src/container/OptionsMenu/__test__/useOptionsMenu.test.ts +++ b/frontend/src/container/OptionsMenu/__test__/useOptionsMenu.test.ts @@ -296,12 +296,12 @@ describe('useOptionsMenu', () => { }), ); - // New order: [attribute.service.name, log.body, resource.service.name, log.timestamp] + // New order: [attribute:service.name, log:body, resource:service.name, log:timestamp] result.current.config.addColumn?.onReorder([ - 'attribute.service.name', - 'log.body', - 'resource.service.name', - 'log.timestamp', + 'attribute:service.name', + 'log:body', + 'resource:service.name', + 'log:timestamp', ]); expect(mockUpdateColumns).toHaveBeenCalledTimes(1); @@ -309,13 +309,13 @@ describe('useOptionsMenu', () => { expect( reordered.map( (c: { name: string; fieldContext: string }) => - `${c.fieldContext}.${c.name}`, + `${c.fieldContext}:${c.name}`, ), ).toStrictEqual([ - 'attribute.service.name', - 'log.body', - 'resource.service.name', - 'log.timestamp', + 'attribute:service.name', + 'log:body', + 'resource:service.name', + 'log:timestamp', ]); }); @@ -329,11 +329,11 @@ describe('useOptionsMenu', () => { result.current.config.addColumn?.onReorder([ 'state-indicator', - 'log.timestamp', + 'log:timestamp', 'unknown.composite', - 'log.body', - 'resource.service.name', - 'attribute.service.name', + 'log:body', + 'resource:service.name', + 'attribute:service.name', ]); const reordered = mockUpdateColumns.mock.calls[0][0]; @@ -341,13 +341,13 @@ describe('useOptionsMenu', () => { expect( reordered.map( (c: { name: string; fieldContext: string }) => - `${c.fieldContext}.${c.name}`, + `${c.fieldContext}:${c.name}`, ), ).toStrictEqual([ - 'log.timestamp', - 'log.body', - 'resource.service.name', - 'attribute.service.name', + 'log:timestamp', + 'log:body', + 'resource:service.name', + 'attribute:service.name', ]); }); @@ -359,17 +359,17 @@ describe('useOptionsMenu', () => { }), ); - // Removing 'resource.service.name' should drop ONLY the resource variant. - result.current.config.addColumn?.onRemove('resource.service.name'); + // Removing 'resource:service.name' should drop ONLY the resource variant. + result.current.config.addColumn?.onRemove('resource:service.name'); expect(mockUpdateColumns).toHaveBeenCalledTimes(1); const remaining = mockUpdateColumns.mock.calls[0][0]; expect( remaining.map( (c: { name: string; fieldContext: string }) => - `${c.fieldContext}.${c.name}`, + `${c.fieldContext}:${c.name}`, ), - ).toStrictEqual(['log.body', 'attribute.service.name', 'log.timestamp']); + ).toStrictEqual(['log:body', 'attribute:service.name', 'log:timestamp']); }); it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => { diff --git a/frontend/src/container/OptionsMenu/utils.ts b/frontend/src/container/OptionsMenu/utils.ts index 234da54c752..81e2055e22a 100644 --- a/frontend/src/container/OptionsMenu/utils.ts +++ b/frontend/src/container/OptionsMenu/utils.ts @@ -16,7 +16,7 @@ export const getOptionsFromKeys = ( }; // Composite identity for a column. Disambiguates same-name fields across -// different fieldContexts (e.g. resource.service.name vs attribute.service.name). +// different fieldContexts (e.g. resource:service.name vs attribute:service.name). // Falls back to bare name when context is missing. export const buildCompositeKey = (name: string, context?: string): string => - context ? `${context}.${name}` : name; + context ? `${context}:${name}` : name;