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
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Numeric fields preserve decimal edits and nullable API values

- **Status:** Accepted
- **Revisions:** v1 2026-10-12 (initial)
- **Deciders:** Harshit Shah
- **Guard:** core `number-input.test.tsx`, `ProfilerSettingsModal.test.tsx`, `ContractSLAFormTab.utils.test.ts`
- **Related:** open-metadata/openmetadata-collate#7302, open-metadata/openmetadata-collate#7303

## Context
Migrating numeric fields must retain decimal editing, clearing, bounds, and numeric payloads.
A grid-based stepper changes 3.5 to 5 with a step of 1; existing fields increment it to 4.5.
Browser step/min validation can also reject stored profiler samples of zero when saving an
otherwise untouched configuration. Clearing a field must remain distinguishable from zero.

## Decision
Core `NumberInput` maintains an editable string and reports finite numbers or `null`. Bounds
apply when editing is committed; loading a controlled value does not silently rewrite it.
Arrow keys add the requested step relative to the edited value rather than snapping to a grid.
Native spinner buttons are hidden because they use grid stepping. Decimal arithmetic avoids
binary rounding drift; explicit precision is limited to 0–20 fractional digits.

Forms bind numeric callbacks directly to React Hook Form. Serializers distinguish `null` from
zero and omit cleared optional durations. Wall-clock SLA availability remains an HH:mm string;
selecting a timezone does not convert that clock time.

## Consequences
Core owns numeric behavior and styling. Callers keep domain validation and serialization,
including compatibility with existing stored values. These contracts are covered by interaction
and payload tests rather than assertions about Ant Design markup.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { TreeSelect } from './tree-select';
import { TreeSelectNode } from './tree-select.types';

const children: TreeSelectNode[] = [
{ id: 'a', label: 'A', value: 'a' },
{ id: 'b', label: 'B', value: 'b' },
];
const parent: TreeSelectNode = {
id: 'all',
label: 'All',
value: 'all',
children,
};
const fetchData = async () => ({ nodes: [parent] });

describe('TreeSelect migrated selection contracts', () => {
it('moves keyboard focus into asynchronously loaded options and selects with Enter', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(
<TreeSelect
multiple
searchable
aria-label="Choose teams"
fetchData={async () => ({ nodes: children })}
id="teams"
onChange={onChange}
/>
);
const input = screen.getByRole('combobox', { name: 'Choose teams' });
expect(input).toHaveAttribute('id', 'teams');
await user.click(input);
await user.keyboard('{ArrowDown}');
const firstRow = await screen.findByRole('row', { name: 'A' });
await waitFor(() => expect(firstRow).toHaveFocus());
expect(input).toHaveAttribute(
'aria-controls',
screen.getByRole('treegrid').id
);
await user.keyboard('{Enter}');
await waitFor(() => expect(onChange).toHaveBeenCalledWith([children[0]]));
});

it('limits visible chips without discarding hidden selections', () => {
render(
<TreeSelect
multiple
fetchData={fetchData}
maxVisibleItems={1}
placeholder="Teams"
value={children}
/>
);
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.getByText('+1')).toBeInTheDocument();
});
it('collapses a selected branch in the trigger and forwards focus events', async () => {
const onFocus = vi.fn();
const onBlur = vi.fn();
const user = userEvent.setup();
render(
<TreeSelect
cascadeSelection
multiple
fetchData={fetchData}
placeholder="Metrics"
selectedDisplayStrategy="parent"
value={[parent, ...children]}
onBlur={onBlur}
onFocus={onFocus}
/>
);
expect(screen.getByText('All')).toBeInTheDocument();
expect(screen.queryByText('A')).not.toBeInTheDocument();
await user.click(screen.getByRole('combobox', { name: 'Metrics' }));
expect(onFocus).toHaveBeenCalled();
await user.tab();
expect(onBlur).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type RefObject,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -215,6 +216,8 @@ const collectSelectableNodes = <T,>(

export const TreeSelect = <T = unknown,>({
label,
id,
'aria-label': ariaLabel,
placeholder,
hint,
tooltip,
Expand Down Expand Up @@ -247,6 +250,11 @@ export const TreeSelect = <T = unknown,>({
fullWidthTrigger = false,
onFetchError,
renderSelectedItem,
isLoading = false,
maxVisibleItems,
selectedDisplayStrategy = 'all',
onFocus,
onBlur,
bordered = false,
showSelectAll = false,
commitMode = 'immediate',
Expand All @@ -264,6 +272,11 @@ export const TreeSelect = <T = unknown,>({
filterNode,
}: TreeSelectProps<T>): ReactElement => {
const { t } = useCoreTranslation();
const treeId = useId();
const inputId = id ?? `${treeId}-input`;
const [pendingTreeFocus, setPendingTreeFocus] = useState<
'first' | 'last' | null
>(null);
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = controlledIsOpen ?? internalOpen;
const [expandedKeys, setExpandedKeys] = useState<Set<Key>>(
Expand Down Expand Up @@ -321,7 +334,7 @@ export const TreeSelect = <T = unknown,>({
// The button badge excludes root ids, so it needs the tree while closed.
const {
treeData,
loading,
loading: dataLoading,
loadingNodes,
loadChildren,
loadMoreChildren,
Expand All @@ -337,6 +350,8 @@ export const TreeSelect = <T = unknown,>({
enabled: hasOpened || isButtonVariant,
});

const loading = dataLoading || isLoading;

const visibleNodeIds = useMemo(
() =>
searchable ? getVisibleNodeIds(treeData, searchTerm, filterNode) : null,
Expand Down Expand Up @@ -752,6 +767,51 @@ export const TreeSelect = <T = unknown,>({
[selectedData, toggleNodeSelection]
);

const displayedNodes = useMemo(() => {
if (selectedDisplayStrategy === 'all') {
return selectedData;
}
const parents = new Map<string, string>();
const walk = (nodes: TreeSelectNode<T>[], parentId?: string) => {
nodes.forEach((node) => {
if (parentId) {
parents.set(node.id, parentId);
}
if (node.children) {
walk(node.children, node.id);
}
});
};
walk(treeData);
// Selected branches can arrive before their async options have loaded.
walk(selectedData);
selectedData.forEach((node) => {
if (node.parentId && !parents.has(node.id)) {
parents.set(node.id, node.parentId);
}
});
const selectedIds = new Set(selectedData.map((node) => node.id));

return selectedData.filter((node) => {
let parentId = parents.get(node.id);
while (parentId) {
if (selectedIds.has(parentId)) {
return false;
}
parentId = parents.get(parentId);
}

return true;
});
}, [selectedData, selectedDisplayStrategy, treeData]);
const visibleNodes = displayedNodes.slice(
0,
maxVisibleItems === undefined
? displayedNodes.length
: Math.max(0, maxVisibleItems)
);
const hiddenNodes = displayedNodes.slice(visibleNodes.length);

const displayValue = isOpen
? inputValue
: !multiple && !renderSelectedItem
Expand Down Expand Up @@ -914,6 +974,27 @@ export const TreeSelect = <T = unknown,>({
setShowSelectedOnly(false);
}, [onChange, multiple, selectedData, clearSearch, setOpen]);

// Wait for async rows before handing focus to React Aria's keyboard navigation.
useEffect(() => {
if (!isOpen) {
setPendingTreeFocus(null);

return;
}
if (!pendingTreeFocus || loading) {
return;
}
const rows = popoverRef.current?.querySelectorAll<HTMLElement>(
'[role="row"]:not([aria-disabled="true"])'
);
const row =
pendingTreeFocus === 'first' ? rows?.[0] : rows?.[rows.length - 1];
Comment on lines +977 to +991

@gitar-bot gitar-bot Bot Oct 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: Pending tree focus outlives empty results and steals focus while typing

If ArrowDown is pressed while the dropdown shows the no-data branch (lines 1035-1038 render no role="row"), the effect finds no row, so pendingTreeFocus stays set. Because the effect depends on filteredTreeData, the user can then type a search term in the searchable input, and as soon as results appear row.focus() moves focus off the input in the middle of typing. The same thing happens when ArrowDown is pressed and the user keeps typing while a search fetch is loading. Fix: clear the pending focus once loading has finished without finding a row, and also clear it whenever the input value changes.

Drop the pending request when no row exists after loading, and on any search edit:

  const row =
    pendingTreeFocus === 'first' ? rows?.[0] : rows?.[rows.length - 1];
  row?.focus();
  setPendingTreeFocus(null);
}, [isOpen, pendingTreeFocus, loading, filteredTreeData]);

// and in the input onChange:
// onChange={(event) => { setPendingTreeFocus(null); searchable && setInputValue(event.target.value); }}

Was this helpful? React with 👍 / 👎

if (row) {
row.focus();
setPendingTreeFocus(null);
}
}, [isOpen, pendingTreeFocus, loading, filteredTreeData]);

const treeDropdownContent = (
<div
className="tw:contents"
Expand Down Expand Up @@ -957,8 +1038,9 @@ export const TreeSelect = <T = unknown,>({
</div>
) : (
<Tree
aria-label={label ?? placeholder ?? 'Tree select'}
aria-label={ariaLabel ?? label ?? placeholder ?? t('label.select')}
expandedKeys={filteredExpandedKeys}
id={treeId}
selectionMode="none"
onAction={(key) => {
const id = String(key);
Expand Down Expand Up @@ -1050,7 +1132,10 @@ export const TreeSelect = <T = unknown,>({

if (renderTrigger) {
return (
<div className={cx('tw:relative tw:inline-flex', className)}>
<div
className={cx('tw:relative tw:inline-flex', className)}
onBlur={onBlur}
onFocus={onFocus}>
<div
className={cx('tw:flex tw:min-w-0', fullWidthTrigger && 'tw:w-full')}
ref={triggerRef}>
Expand All @@ -1072,7 +1157,10 @@ export const TreeSelect = <T = unknown,>({
const triggerText = label ?? placeholder ?? '';

return (
<div className={cx('tw:relative tw:inline-flex', className)}>
<div
className={cx('tw:relative tw:inline-flex', className)}
onBlur={onBlur}
onFocus={onFocus}>
<div className="tw:flex" ref={triggerRef}>
<Button
className={cx(
Expand Down Expand Up @@ -1101,9 +1189,12 @@ export const TreeSelect = <T = unknown,>({
}

return (
<div className={cx('tw:flex tw:flex-col tw:gap-1.5', className)}>
<div
className={cx('tw:flex tw:flex-col tw:gap-1.5', className)}
onBlur={onBlur}
onFocus={onFocus}>
{label && (
<Label isRequired={required} tooltip={tooltip}>
<Label htmlFor={inputId} isRequired={required} tooltip={tooltip}>
{label}
</Label>
)}
Expand All @@ -1129,22 +1220,22 @@ export const TreeSelect = <T = unknown,>({
<Search className="tw:size-4 tw:shrink-0 tw:text-fg-quaternary" />

{renderSelectedItem
? selectedData.map((node) => (
? visibleNodes.map((node) => (
<Fragment key={node.id}>{renderSelectedItem(node)}</Fragment>
))
: null}

{!renderSelectedItem &&
multiple &&
selectedData.map((node) => (
visibleNodes.map((node) => (
<span
className="tw:flex tw:items-center tw:gap-1 tw:rounded-md tw:bg-primary tw:py-0.5 tw:pr-1 tw:pl-1.5 tw:outline-1 tw:-outline-offset-1 tw:outline-primary"
key={node.id}>
<p className="tw:max-w-40 tw:truncate tw:text-sm tw:font-medium tw:text-secondary">
{node.label}
</p>
<button
aria-label={`Remove ${node.label}`}
aria-label={`${t('label.remove')} ${node.label}`}
className="tw:flex tw:cursor-pointer tw:rounded-[3px] tw:p-0.5 tw:text-fg-quaternary tw:outline-transparent tw:transition tw:duration-100 tw:ease-linear tw:hover:bg-primary_hover tw:hover:text-fg-quaternary_hover tw:disabled:cursor-not-allowed"
disabled={disabled}
type="button"
Expand All @@ -1157,25 +1248,51 @@ export const TreeSelect = <T = unknown,>({
</span>
))}

{hiddenNodes.length > 0 && (
<span
className="tw:text-sm tw:text-secondary"
title={hiddenNodes.map((node) => node.label).join(', ')}>
+{hiddenNodes.length}
</span>
)}

<FocusScope autoFocus={false} contain={false} restoreFocus={false}>
<input
aria-label={label ?? placeholder}
aria-controls={isOpen ? treeId : undefined}
aria-expanded={isOpen}
aria-haspopup="grid"
aria-invalid={isInvalid || undefined}
aria-label={
ariaLabel ?? label ?? placeholder ?? t('label.select')
}
aria-required={required || undefined}
autoFocus={autoFocus}
className="tw:min-w-[6rem] tw:flex-1 tw:appearance-none tw:bg-transparent tw:text-sm tw:text-primary tw:outline-hidden tw:placeholder:text-placeholder tw:disabled:cursor-not-allowed"
disabled={disabled}
id={inputId}
placeholder={
selectedData.length > 0
? undefined
: searchPlaceholder ?? placeholder
}
readOnly={!searchable}
ref={inputRef}
role="combobox"
value={displayValue}
onChange={(event) =>
searchable && setInputValue(event.target.value)
}
onFocus={openOnFocus}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
openTrigger();
setPendingTreeFocus(
event.key === 'ArrowDown' ? 'first' : 'last'
);

return;
}
if (event.key === 'Escape') {
dismiss();

Expand Down
Loading
Loading