Skip to content

Commit

Permalink
feat: add multi listbox component (#14540)
Browse files Browse the repository at this point in the history
  • Loading branch information
josephkmh committed Nov 7, 2024
1 parent 3d675e6 commit 98034a7
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { FormattedMessage, useIntl } from "react-intl";
import { useDebounce } from "react-use";

import { Box } from "components/ui/Box";
import { FlexContainer } from "components/ui/Flex";
import { FlexContainer, FlexItem } from "components/ui/Flex";
import { ListBox } from "components/ui/ListBox";
import { MultiListBox } from "components/ui/ListBox/MultiListBox";
import { Message } from "components/ui/Message";
import { Switch } from "components/ui/Switch";
import { Text } from "components/ui/Text";
Expand All @@ -21,6 +22,7 @@ import {
useJobInfoWithoutLogs,
} from "core/api";
import { trackError } from "core/utils/datadog";
import { useExperiment } from "hooks/services/Experiment";

import { AttemptStatusIcon } from "./AttemptStatusIcon";
import { DownloadLogsButton } from "./DownloadLogsButton";
Expand Down Expand Up @@ -60,6 +62,7 @@ export const JobLogsModal: React.FC<JobLogsModalProps> = ({ jobId, initialAttemp
const JobLogsModalInner: React.FC<JobLogsModalProps> = ({ jobId, initialAttemptId, eventId, connectionId }) => {
const searchInputRef = useRef<HTMLInputElement>(null);
const job = useJobInfoWithoutLogs(jobId);
const showStructuredLogsUI = useExperiment("logs.structured-logs-ui");

const [inputValue, setInputValue] = useState("");
const [highlightedMatchIndex, setHighlightedMatchIndex] = useState<number | undefined>(undefined);
Expand Down Expand Up @@ -250,17 +253,31 @@ const JobLogsModalInner: React.FC<JobLogsModalProps> = ({ jobId, initialAttemptI
</Box>
<JobLogsModalFailureMessage failureSummary={jobAttempt.attempt.failureSummary} />
<Box px="md">
<LogSearchInput
ref={searchInputRef}
inputValue={inputValue}
onSearchInputKeydown={onSearchInputKeydown}
onSearchTermChange={onSearchTermChange}
highlightedMatchDisplay={highlightedMatchingLineNumber}
highlightedMatchIndex={highlightedMatchIndex}
matches={matchingLines}
scrollToNextMatch={scrollToNextMatch}
scrollToPreviousMatch={scrollToPreviousMatch}
/>
<FlexContainer>
<FlexItem grow>
<LogSearchInput
ref={searchInputRef}
inputValue={inputValue}
onSearchInputKeydown={onSearchInputKeydown}
onSearchTermChange={onSearchTermChange}
highlightedMatchDisplay={highlightedMatchingLineNumber}
highlightedMatchIndex={highlightedMatchIndex}
matches={matchingLines}
scrollToNextMatch={scrollToNextMatch}
scrollToPreviousMatch={scrollToPreviousMatch}
/>
</FlexItem>
{showStructuredLogsUI && (
<FlexItem>
<MultiListBox
selectedValues={selectedLogOrigins ?? origins}
options={logOriginOptions}
onSelectValues={(newOrigins) => setSelectedLogOrigins(newOrigins ?? origins)}
label="Log sources"
/>
</FlexItem>
)}
</FlexContainer>
</Box>

{origins.length > 0 && (
Expand Down
9 changes: 8 additions & 1 deletion airbyte-webapp/src/components/ui/CheckBox/CheckBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export const CheckBox: React.FC<CheckBoxProps> = ({ indeterminate, checkboxSize

const checkMarkSize = checkboxSize === "lg" ? "md" : "sm";

// Without this, two click events will bubble due to how the input is nested. This breaks headless UI's change
// detection, so we stop the duplicate event from bubbling.
const handleClick = (e: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
e.stopPropagation();
};

return (
<label
className={classNames(
Expand All @@ -31,7 +37,8 @@ export const CheckBox: React.FC<CheckBoxProps> = ({ indeterminate, checkboxSize
className
)}
>
<input type="checkbox" aria-checked={indeterminate ? "mixed" : checked} {...inputProps} />
<input type="checkbox" aria-checked={indeterminate ? "mixed" : checked} {...inputProps} onClick={handleClick} />

{indeterminate ? (
<Icon type="minus" size={checkMarkSize} />
) : (
Expand Down
27 changes: 26 additions & 1 deletion airbyte-webapp/src/components/ui/ListBox/ListBox.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@
gap: 0;
overflow: auto;

&:focus-visible {
outline: none;
}

&.nonAdaptive {
max-width: variables.$min-width-listbox-options-list;
}
Expand All @@ -79,9 +83,22 @@
.option {
list-style-type: none;
overflow: visible;
box-sizing: border-box;
user-select: none;

&:first-child > * {
border-top-left-radius: variables.$border-radius-lg;
border-top-right-radius: variables.$border-radius-lg;
}

&:last-child > * {
border-bottom-left-radius: variables.$border-radius-lg;
border-bottom-right-radius: variables.$border-radius-lg;
}
}

.optionValue {
border: variables.$border-thin solid transparent;
padding: (variables.$spacing-xs + variables.$spacing-md) variables.$spacing-lg;
cursor: pointer;
}
Expand All @@ -96,12 +113,20 @@
font-size: variables.$font-size-lg;
}

.active {
.focus {
background-color: colors.$grey-50;

&.focus {
border-color: colors.$blue;
}
}

.selected {
background-color: colors.$blue-50;

&.focus {
border-color: colors.$blue;
}
}

.disabled {
Expand Down
4 changes: 2 additions & 2 deletions airbyte-webapp/src/components/ui/ListBox/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ export const ListBox = <T,>({
"data-testid": `${restOptionProps["data-testid"]}-option`,
})}
>
{({ active, selected }) => (
{({ focus, selected }) => (
<FlexContainer
alignItems="center"
className={classNames(styles.optionValue, selected && selectedOptionClassName, {
[styles.active]: active,
[styles.focus]: focus,
[styles.selected]: selected,
})}
>
Expand Down
52 changes: 52 additions & 0 deletions airbyte-webapp/src/components/ui/ListBox/MultiListBox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { StoryObj } from "@storybook/react";
import { useState } from "react";

import { Option } from "./ListBox";
import { MultiListBox, MultiListBoxProps } from "./MultiListBox";

export default {
title: "ui/MultiListBox",
component: MultiListBox,
} as StoryObj<typeof MultiListBox>;

const exampleOptions: Array<Option<number>> = [
{
label: "one",
value: 1,
},
{
label: "two",
value: 2,
},
{
label: "three",
value: 3,
},
{
label: "four",
value: 4,
},
{
label: "five",
value: 5,
},
];

export const Default: StoryObj<typeof MultiListBox<number>> = {
args: {
label: "Items",
},
render: (args) => <MultiListBoxExample {...args} />,
};

const MultiListBoxExample = (args: MultiListBoxProps<number>) => {
const [selectedValues, setSelectedValues] = useState<number[]>([]);
return (
<MultiListBox
{...args}
selectedValues={selectedValues}
onSelectValues={setSelectedValues}
options={exampleOptions}
/>
);
};
98 changes: 98 additions & 0 deletions airbyte-webapp/src/components/ui/ListBox/MultiListBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
Listbox,
ListboxOption as OriginalListboxOption,
ListboxButton as OriginalListboxButton,
ListboxOptions as OriginalListboxOptions,
} from "@headlessui/react";
import { Float } from "@headlessui-float/react";
import classNames from "classnames";
import isEqual from "lodash/isEqual";
import React from "react";

import { Text } from "components/ui/Text";

import styles from "./ListBox.module.scss";
import { Badge } from "../Badge";
import { CheckBox } from "../CheckBox";
import { FlexContainer } from "../Flex";
import { Icon } from "../Icon";

export interface ListBoxControlButtonProps<T> {
selectedValues: T[];
isDisabled?: boolean;
label: string;
}

const DefaultControlButton = <T,>({ selectedValues, label }: ListBoxControlButtonProps<T>) => {
return (
<>
<FlexContainer>
<Text>{label}</Text>
{selectedValues.length > 0 && <Badge variant="blue">{selectedValues.length}</Badge>}
</FlexContainer>
<Icon type="chevronDown" color="action" />
</>
);
};

export interface Option<T> {
label: React.ReactNode;
value: T;
icon?: React.ReactNode;
disabled?: boolean;
"data-testid"?: string;
}

export interface MultiListBoxProps<T> {
options: Array<Option<T>>;
selectedValues: T[];
onSelectValues: (selectedValues: T[]) => void;
isDisabled?: boolean;
controlButton?: React.ComponentType<ListBoxControlButtonProps<T>>;
label: string;
}

export const MultiListBox = <T,>({
options,
selectedValues,
onSelectValues,
controlButton: ControlButton = DefaultControlButton,
isDisabled,
label,
}: MultiListBoxProps<T>) => {
return (
<Listbox value={selectedValues} onChange={onSelectValues} disabled={isDisabled} by={isEqual} multiple>
<Float
placement="bottom-start"
flip
offset={5} // $spacing-sm
>
<OriginalListboxButton className={classNames(styles.button)}>
<ControlButton selectedValues={selectedValues} isDisabled={isDisabled} label={label} />
</OriginalListboxButton>
<OriginalListboxOptions as="ul" className={classNames(styles.optionsMenu)}>
{options.map(MultiListBoxOption)}
</OriginalListboxOptions>
</Float>
</Listbox>
);
};

interface MutliListboxOptionProps<T> {
label: React.ReactNode;
value: T;
}

const MultiListBoxOption = <T,>({ label, value }: MutliListboxOptionProps<T>) => (
<OriginalListboxOption as="li" value={value} className={styles.option}>
{({ focus, selected }) => (
<FlexContainer
alignItems="center"
className={classNames(styles.optionValue, { [styles.selected]: selected, [styles.focus]: focus })}
>
<CheckBox checked={selected} readOnly />
<Text className={styles.label}>{label}</Text>
</FlexContainer>
)}
</OriginalListboxOption>
);
2 changes: 2 additions & 0 deletions airbyte-webapp/src/hooks/services/Experiment/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface Experiments {
"connector.suggestedDestinationConnectors": string;
"connectorBuilder.aiAssist.enabled": boolean;
"connectorBuilder.contributeEditsToMarketplace": boolean;
"logs.structured-logs-ui": boolean;
"settings.breakingChangeNotifications": boolean;
"settings.downloadDiagnostics": boolean;
"settings.organizationRbacImprovements": boolean;
Expand All @@ -44,6 +45,7 @@ export const defaultExperimentValues: Experiments = {
"connector.suggestedSourceConnectors": "",
"connectorBuilder.aiAssist.enabled": false,
"connectorBuilder.contributeEditsToMarketplace": true,
"logs.structured-logs-ui": false,
"settings.breakingChangeNotifications": false,
"settings.downloadDiagnostics": false,
"settings.organizationRbacImprovements": false,
Expand Down

0 comments on commit 98034a7

Please sign in to comment.