Skip to content
Draft
7 changes: 5 additions & 2 deletions i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"POT-Creation-Date: 2026-06-11T17:54:31.024Z\n"
"PO-Revision-Date: 2026-06-11T17:54:31.025Z\n"
"POT-Creation-Date: 2026-06-26T15:00:43.776Z\n"
"PO-Revision-Date: 2026-06-26T15:00:43.777Z\n"

msgid "The application could not be loaded."
msgstr "The application could not be loaded."
Expand Down Expand Up @@ -593,6 +593,9 @@ msgstr "Type to filter options"
msgid "No match found"
msgstr "No match found"

msgid "Search for an organisation unit"
msgstr "Search for an organisation unit"

msgid "Clear"
msgstr "Clear"

Expand Down
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ type OrgUnitFieldProps = {
disabled?: boolean;
previousOrgUnitId?: string;
dataTest?: string;
hideSearchField?: boolean;
searchText?: string;
};

type Props = OrgUnitFieldProps & WithStyles<typeof getStyles>;
Expand All @@ -60,8 +62,11 @@ const OrgUnitFieldPlain = (props: Props) => {
disabled,
previousOrgUnitId,
dataTest,
hideSearchField,
searchText: externalSearchText,
} = props;
const [searchText, setSearchText] = React.useState<string | undefined>(undefined);
const [internalSearchText, setInternalSearchText] = React.useState<string | undefined>(undefined);
const searchText = hideSearchField ? externalSearchText : internalSearchText;
const [key, setKey] = React.useState<string | undefined>(undefined);

const initialRoots = React.useMemo(() => CurrentUser.get().organisationUnits, []);
Expand Down Expand Up @@ -117,7 +122,7 @@ const OrgUnitFieldPlain = (props: Props) => {
};

const handleFilterChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
setSearchText(event.currentTarget.value);
setInternalSearchText(event.currentTarget.value);
};

const handleBlur = () => {
Expand All @@ -131,6 +136,7 @@ const OrgUnitFieldPlain = (props: Props) => {
className={classes.container}
onBlur={handleBlur}
>
{!hideSearchField &&
<div className={classes.debounceFieldContainer}>
<DebounceField
onDebounced={handleFilterChange}
Expand All @@ -141,6 +147,7 @@ const OrgUnitFieldPlain = (props: Props) => {
dataTest={dataTest}
/>
</div>
}
{!disabled &&
<div className={classes.orgUnitTreeContainer} style={styles}>
{renderOrgUnitTree()}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as React from 'react';
import i18n from '@dhis2/d2-i18n';
import { debounce } from 'lodash';
import { v4 as uuid } from 'uuid';
import { Chip, Popover, IconChevronDown16, colors } from '@dhis2/ui';
import { withStyles, type WithStyles } from 'capture-core-utils/styles';
import { Chip, colors } from '@dhis2/ui';
import { OrgUnitField } from './OrgUnitField.component';
import { TooltipOrgUnit } from '../../../../Tooltips/TooltipOrgUnit/TooltipOrgUnit.component';

Expand All @@ -15,6 +18,57 @@ const getStyles = () => ({
backgroundColor: `${colors.grey200} !important`,
},
},
trigger: {
display: 'flex',
alignItems: 'center',
boxSizing: 'border-box' as const,
minHeight: 40,
padding: '6px 12px',
border: `1px solid ${colors.grey500}`,
borderRadius: 3,
backgroundColor: 'white',
boxShadow: 'inset 0 0 1px 0 rgba(48, 54, 60, 0.1)',
cursor: 'pointer',
'&:focus': {
outline: 'none',
borderColor: colors.blue600,
boxShadow: `inset 0 0 0 2px ${colors.blue600}`,
},
},
triggerOpen: {
borderColor: colors.blue600,
boxShadow: `inset 0 0 0 2px ${colors.blue600}`,
},
triggerDisabled: {
backgroundColor: colors.grey100,
borderColor: colors.grey500,
color: colors.grey600,
cursor: 'not-allowed',
},
searchInput: {
flexGrow: 1,
minWidth: 0,
border: 'none',
outline: 'none',
background: 'transparent',
padding: 0,
fontSize: 14,
lineHeight: '16px',
color: colors.grey900,
cursor: 'inherit',
'&::placeholder': {
color: colors.grey600,
opacity: 1,
},
},
chevron: {
display: 'flex',
alignItems: 'center',
marginLeft: 'auto',
},
popoverContent: {
width: 400,
},
});

type OrgUnitValue = {
Expand All @@ -25,22 +79,59 @@ type OrgUnitValue = {

type SingleOrgUnitSelectFieldState = {
previousOrgUnitId: string | null;
open: boolean;
inputValue: string;
searchText: string;
};

type SingleOrgUnitSelectFieldProps = {
value?: OrgUnitValue;
onBlur: (value: any) => void;
onSelectClick?: (orgUnit: Record<string, any>) => void;
disabled?: boolean;
maxTreeHeight?: number;
};

type Props = SingleOrgUnitSelectFieldProps & WithStyles<typeof getStyles>;

class SingleOrgUnitSelectFieldPlain extends React.Component<Props, SingleOrgUnitSelectFieldState> {
anchorRef: React.RefObject<HTMLDivElement>;
searchInputRef: React.RefObject<HTMLInputElement>;
popoverId: string;
debouncedSetSearchText: ((searchText: string) => void) & { cancel: () => void };

constructor(props: Props) {
super(props);
this.state = {
previousOrgUnitId: null,
open: false,
inputValue: '',
searchText: '',
};
this.anchorRef = React.createRef() as React.RefObject<HTMLDivElement>;
this.searchInputRef = React.createRef() as React.RefObject<HTMLInputElement>;
this.popoverId = `org-unit-selector-popover-${uuid()}`;
this.debouncedSetSearchText = debounce((searchText: string) => {
this.setState({ searchText });
}, 300);
}

componentWillUnmount() {
this.debouncedSetSearchText.cancel();
}

openMenu = () => {
if (this.props.disabled) {
return;
}
this.setState({ open: true }, () => {
this.searchInputRef.current?.focus();
});
}

closeMenu = () => {
this.debouncedSetSearchText.cancel();
this.setState({ open: false, inputValue: '', searchText: '' });
}

onSelectOrgUnit = (orgUnit: Record<string, any>) => {
Expand All @@ -56,6 +147,33 @@ class SingleOrgUnitSelectFieldPlain extends React.Component<Props, SingleOrgUnit
this.props.onBlur(null);
}

handleSelect = (orgUnit: Record<string, any>) => {
if (this.props.onSelectClick) {
this.props.onSelectClick(orgUnit);
} else {
this.onSelectOrgUnit(orgUnit);
}
this.closeMenu();
}

handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = event.currentTarget.value;
this.setState({ inputValue });
this.debouncedSetSearchText(inputValue);
}

handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (this.props.disabled) {
return;
}
if (this.state.open && (event.key === 'Escape' || event.key === 'Tab')) {
this.closeMenu();
} else if (!this.state.open && (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown')) {
event.preventDefault();
this.openMenu();
}
}

renderSelectedOrgUnit = (selectedOrgUnit: OrgUnitValue) => {
const { classes } = this.props;
return (
Expand All @@ -70,20 +188,73 @@ class SingleOrgUnitSelectFieldPlain extends React.Component<Props, SingleOrgUnit
);
}

renderOrgUnitField = () => {
const { classes, ...passOnProps } = this.props;
renderPopover = () => {
const { classes, value, onBlur, onSelectClick, disabled, maxTreeHeight, ...passOnProps } = this.props;
return (
<Popover
reference={this.anchorRef.current || undefined}
arrow={false}
placement="bottom-start"
onClickOutside={this.closeMenu}
maxWidth={400}
>
<div id={this.popoverId} className={classes.popoverContent}>
<OrgUnitField
{...passOnProps}
hideSearchField
searchText={this.state.searchText}
disabled={disabled}
maxTreeHeight={maxTreeHeight ?? 350}
onSelectClick={this.handleSelect}
onBlur={() => undefined}
previousOrgUnitId={this.state.previousOrgUnitId}
/>
</div>
</Popover>
);
}

renderCollapsedOrgUnitField = () => {
const { classes, disabled } = this.props;
const { open, inputValue } = this.state;
const triggerClassName = [
classes.trigger,
open && classes.triggerOpen,
disabled && classes.triggerDisabled,
].filter(Boolean).join(' ');

return (
<OrgUnitField
onSelectClick={this.onSelectOrgUnit}
previousOrgUnitId={this.state.previousOrgUnitId}
{...passOnProps}
/>
<React.Fragment>
<div
ref={this.anchorRef}
className={triggerClassName}
>
<input
ref={this.searchInputRef}
className={classes.searchInput}
value={open ? inputValue : ''}
onChange={this.handleInputChange}
onClick={this.openMenu}
onKeyDown={this.handleKeyDown}
readOnly={!open}
disabled={disabled}
placeholder={open ? i18n.t('Search for an organisation unit') : undefined}
aria-haspopup="tree"
aria-controls={open ? this.popoverId : undefined}
data-test="org-unit-selector-trigger"
/>
<span className={classes.chevron}>
<IconChevronDown16 />
</span>
</div>
Comment on lines +228 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Chevron icon area does not trigger popover open

The onClick={this.openMenu} handler is only on the <input> element (line 237), not on the outer trigger <div> or the chevron <span> (lines 246-248). Since the input has flexGrow: 1 and the chevron has marginLeft: 'auto', clicking the small chevron icon area (~16px) won't open the popover. Most dropdown-like components make the entire trigger area clickable. This is a minor UX gap but not a functional bug.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

{open && !disabled && this.renderPopover()}
</React.Fragment>
);
}

render() {
const { value } = this.props;
return value ? this.renderSelectedOrgUnit(value) : this.renderOrgUnitField();
return value ? this.renderSelectedOrgUnit(value) : this.renderCollapsedOrgUnitField();
}
}
export const SingleOrgUnitSelectField = withStyles(getStyles)(SingleOrgUnitSelectFieldPlain);
Loading