"d3-array": "3.2.3",
"d3-scale": "4.0.2",
"d3-shape": "3.2.0",
+ "date-fns": "2.29.3",
"lodash": "4.17.21",
"react": "17.0.2",
+ "react-day-picker": "8.6.0",
"react-dom": "17.0.2",
"react-helmet-async": "1.3.0",
"react-intl": "6.2.5",
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+
+import classNames from 'classnames';
+import {
+ format,
+ getYear,
+ isSameMonth,
+ isSameYear,
+ setMonth,
+ setYear,
+ startOfMonth,
+} from 'date-fns';
+import { range } from 'lodash';
+import * as React from 'react';
+import {
+ ActiveModifiers,
+ CaptionProps,
+ Matcher,
+ DayPicker as OriginalDayPicker,
+ useNavigation as useCalendarNavigation,
+ useDayPicker,
+} from 'react-day-picker';
+import tw from 'twin.macro';
+import { PopupPlacement, PopupZLevel, themeBorder, themeColor, themeContrast } from '../helpers';
+import { InputSizeKeys } from '../types/theme';
+import EscKeydownHandler from './EscKeydownHandler';
+import { FocusOutHandler } from './FocusOutHandler';
+import { InputField } from './InputField';
+import { InputSelect } from './InputSelect';
+import { InteractiveIcon } from './InteractiveIcon';
+import OutsideClickHandler from './OutsideClickHandler';
+import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from './icons';
+import { CloseIcon } from './icons/CloseIcon';
+import { Popup } from './popups';
+
+// When no minDate is given, year dropdown will show year options up to PAST_MAX_YEARS in the past
+const YEARS_TO_DISPLAY = 10;
+const MONTHS_IN_A_YEAR = 12;
+
+interface Props {
+ alignRight?: boolean;
+ ariaNextMonthLabel: string;
+ ariaPreviousMonthLabel: string;
+ className?: string;
+ clearButtonLabel: string;
+ currentMonth?: Date;
+ highlightFrom?: Date;
+ highlightTo?: Date;
+ id?: string;
+ inputClassName?: string;
+ inputRef?: React.Ref<HTMLInputElement>;
+ maxDate?: Date;
+ minDate?: Date;
+ name?: string;
+ onChange: (date: Date | undefined) => void;
+ placeholder: string;
+ showClearButton?: boolean;
+ size?: InputSizeKeys;
+ value?: Date;
+ valueFormatter: (date?: Date) => string;
+}
+
+interface State {
+ currentMonth: Date;
+ lastHovered?: Date;
+ open: boolean;
+}
+
+function formatWeekdayName(date: Date) {
+ return format(date, 'EEE'); // Short weekday name, e.g. Wed, Thu
+}
+
+export class DatePicker extends React.PureComponent<Props, State> {
+ constructor(props: Props) {
+ super(props);
+
+ this.state = { currentMonth: props.value ?? props.currentMonth ?? new Date(), open: false };
+ }
+
+ handleResetClick = () => {
+ this.closeCalendar();
+ this.props.onChange(undefined);
+ };
+
+ openCalendar = () => {
+ this.setState({
+ currentMonth: this.props.value ?? this.props.currentMonth ?? new Date(),
+ lastHovered: undefined,
+ open: true,
+ });
+ };
+
+ closeCalendar = () => {
+ this.setState({ open: false });
+ };
+
+ handleDayClick = (day: Date, modifiers: ActiveModifiers) => {
+ if (!modifiers.disabled) {
+ this.closeCalendar();
+ this.props.onChange(day);
+ }
+ };
+
+ handleDayMouseEnter = (day: Date, modifiers: ActiveModifiers) => {
+ this.setState({ lastHovered: modifiers.disabled ? undefined : day });
+ };
+
+ render() {
+ const {
+ alignRight,
+ ariaNextMonthLabel,
+ ariaPreviousMonthLabel,
+ clearButtonLabel,
+ highlightFrom,
+ highlightTo,
+ inputRef,
+ minDate,
+ maxDate = new Date(),
+ value: selectedDay,
+ name,
+ className,
+ inputClassName,
+ id,
+ placeholder,
+ showClearButton = true,
+ size,
+ } = this.props;
+ const { lastHovered, currentMonth, open } = this.state;
+
+ // Infer start and end dropdown year from min/max dates, if set
+ const fromYear = minDate ? minDate.getFullYear() : new Date().getFullYear() - YEARS_TO_DISPLAY;
+ const toYear = maxDate.getFullYear();
+
+ const selectedDays = selectedDay ? [selectedDay] : [];
+ let highlighted: Matcher = false;
+ const lastHoveredOrValue = lastHovered ?? selectedDay;
+ if (highlightFrom && lastHoveredOrValue) {
+ highlighted = { from: highlightFrom, to: lastHoveredOrValue };
+ selectedDays.push(highlightFrom);
+ }
+ if (highlightTo && lastHoveredOrValue) {
+ highlighted = { from: lastHoveredOrValue, to: highlightTo };
+ selectedDays.push(highlightTo);
+ }
+
+ return (
+ <FocusOutHandler onFocusOut={this.closeCalendar}>
+ <OutsideClickHandler onClickOutside={this.closeCalendar}>
+ <EscKeydownHandler onKeydown={this.closeCalendar}>
+ <Popup
+ allowResizing={true}
+ className="sw-overflow-visible" //Necessary for the month & year selectors
+ overlay={
+ open ? (
+ <div className={classNames('sw-p-2')}>
+ <DayPicker
+ captionLayout="dropdown-buttons"
+ className="sw-body-sm"
+ components={{
+ Caption: getCustomCalendarNavigation({
+ ariaNextMonthLabel,
+ ariaPreviousMonthLabel,
+ }),
+ }}
+ disabled={{ after: maxDate, before: minDate }}
+ formatters={{
+ formatWeekdayName,
+ }}
+ fromYear={fromYear}
+ mode="default"
+ modifiers={{ highlighted }}
+ modifiersClassNames={{ highlighted: 'rdp-highlighted' }}
+ month={currentMonth}
+ onDayClick={this.handleDayClick}
+ onDayMouseEnter={this.handleDayMouseEnter}
+ onMonthChange={(currentMonth) => {
+ this.setState({ currentMonth });
+ }}
+ selected={selectedDays}
+ toYear={toYear}
+ weekStartsOn={1}
+ />
+ </div>
+ ) : null
+ }
+ placement={alignRight ? PopupPlacement.BottomRight : PopupPlacement.BottomLeft}
+ zLevel={PopupZLevel.Global}
+ >
+ <span
+ className={classNames('sw-relative sw-inline-block sw-cursor-pointer', className)}
+ >
+ <StyledInputField
+ aria-label={placeholder}
+ className={classNames(inputClassName, {
+ 'is-filled': selectedDay !== undefined && showClearButton,
+ })}
+ id={id}
+ name={name}
+ onClick={this.openCalendar}
+ onFocus={this.openCalendar}
+ placeholder={placeholder}
+ readOnly={true}
+ ref={inputRef}
+ size={size}
+ title={this.props.valueFormatter(selectedDay)}
+ type="text"
+ value={this.props.valueFormatter(selectedDay)}
+ />
+ <StyledCalendarIcon fill="datePickerIcon" />
+ {selectedDay !== undefined && showClearButton && (
+ <StyledInteractiveIcon
+ Icon={CloseIcon}
+ aria-label={clearButtonLabel}
+ onClick={this.handleResetClick}
+ size="small"
+ />
+ )}
+ </span>
+ </Popup>
+ </EscKeydownHandler>
+ </OutsideClickHandler>
+ </FocusOutHandler>
+ );
+ }
+}
+
+const StyledCalendarIcon = styled(CalendarIcon)`
+ ${tw`sw-absolute`};
+ ${tw`sw-top-[0.625rem] sw-left-2`};
+`;
+
+const StyledInteractiveIcon = styled(InteractiveIcon)`
+ ${tw`sw-absolute`};
+ ${tw`sw-top-[0.375rem] sw-right-[0.375rem]`};
+`;
+
+const StyledInputField = styled(InputField)`
+ input[type='text']& {
+ ${tw`sw-pl-8`};
+ ${tw`sw-cursor-pointer`};
+
+ &.is-filled {
+ ${tw`sw-pr-8`};
+ }
+ }
+`;
+
+const DayPicker = styled(OriginalDayPicker)`
+ --rdp-cell-size: auto;
+ /* Ensures the month/year dropdowns do not move on click, but rdp outline is not shown */
+ --rdp-outline: 2px solid transparent;
+ --rdp-outline-selected: 2px solid transparent;
+
+ margin: 0;
+
+ .rdp-head {
+ color: ${themeContrast('datePicker')};
+ }
+
+ .rdp-day {
+ height: 28px;
+ width: 33px;
+ border-radius: 0;
+ color: ${themeContrast('datePickerDefault')};
+ }
+
+ /* Default modifiers */
+
+ .rdp-day_disabled {
+ cursor: not-allowed;
+ background: ${themeColor('datePickerDisabled')};
+ color: ${themeContrast('datePickerDisabled')};
+ }
+
+ .rdp-day:hover:not(.rdp-day_outside):not(.rdp-day_disabled):not(.rdp-day_selected) {
+ background: ${themeColor('datePickerHover')};
+ color: ${themeContrast('datePickerHover')};
+ }
+
+ .rdp-day:focus-visible {
+ outline: ${themeBorder('focus', 'inputFocus')};
+ background: inherit;
+ z-index: 1;
+ }
+
+ .rdp-day.rdp-highlighted:not(.rdp-day_selected) {
+ background: ${themeColor('datePickerRange')};
+ color: ${themeContrast('datePickerRange')};
+ }
+
+ .rdp-day_selected,
+ .rdp-day_selected:focus-visible {
+ background: ${themeColor('datePickerSelected')};
+ color: ${themeContrast('datePickerSelected')};
+ }
+`;
+
+function getCustomCalendarNavigation({
+ ariaNextMonthLabel,
+ ariaPreviousMonthLabel,
+}: {
+ ariaNextMonthLabel: string;
+ ariaPreviousMonthLabel: string;
+}) {
+ return function CalendarNavigation(props: CaptionProps) {
+ const { displayMonth } = props;
+ const { fromYear, toYear } = useDayPicker();
+ const { goToMonth, nextMonth, previousMonth } = useCalendarNavigation();
+
+ const baseDate = startOfMonth(displayMonth); // reference date
+ const months = range(MONTHS_IN_A_YEAR).map((month) => {
+ const monthValue = setMonth(baseDate, month);
+ return {
+ label: format(monthValue, 'MMM'),
+ value: monthValue,
+ };
+ });
+ const startYear = fromYear ?? getYear(Date.now()) - YEARS_TO_DISPLAY;
+ const years = range(startYear, toYear ? toYear + 1 : undefined).map((year) => {
+ const yearValue = setYear(baseDate, year);
+ return {
+ label: String(year),
+ value: yearValue,
+ };
+ });
+
+ return (
+ <nav className="sw-flex sw-items-center sw-justify-between sw-py-1">
+ <InteractiveIcon
+ Icon={ChevronLeftIcon}
+ aria-label={ariaPreviousMonthLabel}
+ className="sw-mr-2"
+ onClick={() => previousMonth && goToMonth(previousMonth)}
+ size="small"
+ />
+ <InputSelect
+ isClearable={false}
+ onChange={(value) => {
+ if (value) {
+ goToMonth(value.value);
+ }
+ }}
+ options={months}
+ size="full"
+ value={months.find((m) => isSameMonth(m.value, displayMonth))}
+ />
+ <InputSelect
+ className="sw-ml-1"
+ isClearable={false}
+ onChange={(value) => {
+ if (value) {
+ goToMonth(value.value);
+ }
+ }}
+ options={years}
+ size="full"
+ value={years.find((y) => isSameYear(y.value, displayMonth))}
+ />
+ <InteractiveIcon
+ Icon={ChevronRightIcon}
+ aria-label={ariaNextMonthLabel}
+ className="sw-ml-2"
+ onClick={() => nextMonth && goToMonth(nextMonth)}
+ size="small"
+ />
+ </nav>
+ );
+ };
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { max, min } from 'date-fns';
+import * as React from 'react';
+import { DatePicker } from './DatePicker';
+import { LightLabel } from './Text';
+
+interface DateRange {
+ from?: Date;
+ to?: Date;
+}
+
+interface Props {
+ alignEndDateCalandarRight?: boolean;
+ ariaNextMonthLabel: string;
+ ariaPreviousMonthLabel: string;
+ className?: string;
+ clearButtonLabel: string;
+ fromLabel: string;
+ maxDate?: Date;
+ minDate?: Date;
+ onChange: (date: DateRange) => void;
+ separatorText?: string;
+ toLabel: string;
+ value?: DateRange;
+ valueFormatter: (date?: Date) => string;
+}
+
+export class DateRangePicker extends React.PureComponent<Props> {
+ toDateInput?: HTMLInputElement | null;
+
+ get from() {
+ return this.props.value?.from;
+ }
+
+ get to() {
+ return this.props.value?.to;
+ }
+
+ handleFromChange = (from: Date | undefined) => {
+ this.props.onChange({ from, to: this.to });
+
+ // use `setTimeout` to work around the immediate closing of the `toDateInput`
+ setTimeout(() => {
+ if (from && !this.to && this.toDateInput) {
+ this.toDateInput.focus();
+ }
+ }, 0);
+ };
+
+ handleToChange = (to: Date | undefined) => {
+ this.props.onChange({ from: this.from, to });
+ };
+
+ render() {
+ const {
+ alignEndDateCalandarRight,
+ ariaNextMonthLabel,
+ ariaPreviousMonthLabel,
+ clearButtonLabel,
+ fromLabel,
+ minDate,
+ maxDate,
+ separatorText,
+ toLabel,
+ valueFormatter,
+ } = this.props;
+
+ return (
+ <div className={classNames('sw-flex sw-items-center', this.props.className)}>
+ <DatePicker
+ ariaNextMonthLabel={ariaNextMonthLabel}
+ ariaPreviousMonthLabel={ariaPreviousMonthLabel}
+ clearButtonLabel={clearButtonLabel}
+ currentMonth={this.to}
+ data-test="from"
+ highlightTo={this.to}
+ id="date-from"
+ maxDate={maxDate && this.to ? min([maxDate, this.to]) : maxDate ?? this.to}
+ minDate={minDate}
+ onChange={this.handleFromChange}
+ placeholder={fromLabel}
+ size="full"
+ value={this.from}
+ valueFormatter={valueFormatter}
+ />
+ <LightLabel className="sw-mx-2">{separatorText ?? '–'}</LightLabel>
+ <DatePicker
+ alignRight={alignEndDateCalandarRight}
+ ariaNextMonthLabel={ariaNextMonthLabel}
+ ariaPreviousMonthLabel={ariaPreviousMonthLabel}
+ clearButtonLabel={clearButtonLabel}
+ currentMonth={this.from}
+ data-test="to"
+ highlightFrom={this.from}
+ id="date-to"
+ inputRef={(element: HTMLInputElement | null) => {
+ this.toDateInput = element;
+ }}
+ maxDate={maxDate}
+ minDate={minDate && this.from ? max([minDate, this.from]) : minDate ?? this.from}
+ onChange={this.handleToChange}
+ placeholder={toLabel}
+ size="full"
+ value={this.to}
+ valueFormatter={valueFormatter}
+ />
+ </div>
+ );
+ }
+}
import { InputSizeKeys } from '../types/theme';
import { InputSelect, LabelValueSelectOption } from './InputSelect';
-interface Props {
+interface Props<V> {
className?: string;
customValue?: JSX.Element;
- options: LabelValueSelectOption[];
- setValue: ({ value }: LabelValueSelectOption) => void;
+ options: LabelValueSelectOption<V>[];
+ setValue: ({ value }: LabelValueSelectOption<V>) => void;
size?: InputSizeKeys;
- value: string;
+ value: V;
}
-export function DiscreetSelect({
+export function DiscreetSelect<V>({
className,
customValue,
options,
setValue,
value,
...props
-}: Props) {
+}: Props<V>) {
return (
<StyledSelect
className={className}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import EscKeydownHandler from './EscKeydownHandler';
-import FocusOutHandler from './FocusOutHandler';
+import { FocusOutHandler } from './FocusOutHandler';
import OutsideClickHandler from './OutsideClickHandler';
import { Popup } from './popups';
onFocusOut: () => void;
}
-export default class FocusOutHandler extends React.PureComponent<React.PropsWithChildren<Props>> {
+export class FocusOutHandler extends React.PureComponent<React.PropsWithChildren<Props>> {
ref?: HTMLDivElement;
componentDidMount() {
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import { forwardRef } from 'react';
+import tw from 'twin.macro';
+import { INPUT_SIZES } from '../helpers/constants';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { InputSizeKeys, ThemedProps } from '../types/theme';
+
+interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
+ as?: React.ElementType;
+ className?: string;
+ isInvalid?: boolean;
+ isValid?: boolean;
+ size?: InputSizeKeys;
+}
+
+interface InputTextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
+ className?: string;
+ isInvalid?: boolean;
+ isValid?: boolean;
+ size?: InputSizeKeys;
+}
+
+export const InputField = forwardRef<HTMLInputElement, InputProps>(
+ ({ size = 'medium', style, ...props }, ref) => {
+ return (
+ <StyledInput ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
+ );
+ }
+);
+InputField.displayName = 'InputField';
+
+export const InputTextArea = forwardRef<HTMLTextAreaElement, InputTextAreaProps>(
+ ({ size = 'medium', style, ...props }, ref) => {
+ return (
+ <StyledTextArea ref={ref} style={{ ...style, '--inputSize': INPUT_SIZES[size] }} {...props} />
+ );
+ }
+);
+InputTextArea.displayName = 'InputTextArea';
+
+const defaultStyle = (props: ThemedProps) => css`
+ --border: ${themeBorder('default', 'inputBorder')(props)};
+ --focusBorder: ${themeBorder('default', 'inputFocus')(props)};
+ --focusOutline: ${themeBorder('focus', 'inputFocus')(props)};
+`;
+
+const dangerStyle = (props: ThemedProps) => css`
+ --border: ${themeBorder('default', 'inputDanger')(props)};
+ --focusBorder: ${themeBorder('default', 'inputDangerFocus')(props)};
+ --focusOutline: ${themeBorder('focus', 'inputDangerFocus')(props)};
+`;
+
+const successStyle = (props: ThemedProps) => css`
+ --border: ${themeBorder('default', 'inputSuccess')(props)};
+ --focusBorder: ${themeBorder('default', 'inputSuccessFocus')(props)};
+ --focusOutline: ${themeBorder('focus', 'inputSuccessFocus')(props)};
+`;
+
+const getInputVariant = (props: ThemedProps & { isInvalid?: boolean; isValid?: boolean }) => {
+ const { isValid, isInvalid } = props;
+ if (isInvalid) {
+ return dangerStyle;
+ } else if (isValid) {
+ return successStyle;
+ }
+ return defaultStyle;
+};
+
+const baseStyle = (props: ThemedProps) => css`
+ color: ${themeContrast('inputBackground')(props)};
+ background: ${themeColor('inputBackground')(props)};
+ border: var(--border);
+ width: var(--inputSize);
+ transition: border-color 0.2s ease;
+
+ ${tw`sw-body-sm`}
+ ${tw`sw-box-border`}
+ ${tw`sw-rounded-2`}
+ ${tw`sw-px-3 sw-py-2`}
+
+ &::placeholder {
+ color: ${themeColor('inputPlaceholder')(props)};
+ }
+
+ &:hover {
+ border: var(--focusBorder);
+ }
+
+ &:active,
+ &:focus,
+ &:focus-within,
+ &:focus-visible {
+ border: var(--focusBorder);
+ outline: var(--focusOutline);
+ }
+
+ &:disabled,
+ &:disabled:hover {
+ color: ${themeContrast('inputDisabled')(props)};
+ background-color: ${themeColor('inputDisabled')(props)};
+ border: ${themeBorder('default', 'inputDisabledBorder')(props)};
+ outline: none;
+
+ ${tw`sw-cursor-not-allowed`};
+ &::placeholder {
+ color: ${themeContrast('inputDisabled')(props)};
+ }
+ }
+`;
+
+const StyledInput = styled.input`
+ input[type='text']& {
+ ${getInputVariant}
+ ${baseStyle}
+ ${tw`sw-h-control`}
+ }
+`;
+
+const StyledTextArea = styled.textarea`
+ ${getInputVariant};
+ ${baseStyle};
+`;
import { InputSizeKeys } from '../types/theme';
import { ChevronDownIcon } from './icons';
-export interface LabelValueSelectOption<V = string> {
+export interface LabelValueSelectOption<V> {
Icon?: ReactNode;
label: string;
value: V;
}
type SelectProps<
- Option = LabelValueSelectOption,
- IsMulti extends boolean = boolean,
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
> = NamedProps<Option, IsMulti, Group> & StyleExtensionProps;
function IconOption<
- Option extends LabelValueSelectOption,
- IsMulti extends boolean = boolean,
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>(props: OptionProps<Option, IsMulti, Group>) {
const {
}
function SingleValue<
- Option extends LabelValueSelectOption,
- IsMulti extends boolean = boolean,
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>(props: OptionProps<Option, IsMulti, Group>) {
const {
}
function IndicatorsContainer<
- Option extends LabelValueSelectOption,
- IsMulti extends boolean = boolean,
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>(props: OptionProps<Option, IsMulti, Group>) {
return (
}
export function InputSelect<
- Option extends LabelValueSelectOption = LabelValueSelectOption,
- IsMulti extends boolean = boolean,
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
->({ size = 'medium', ...props }: SelectProps<Option, IsMulti, Group>) {
+>({ size = 'medium', ...props }: SelectProps<V, Option, IsMulti, Group>) {
return (
<ReactSelect<Option, IsMulti, Group>
{...omit(props, 'className', 'large')}
classNames={{
container: () => 'sw-relative sw-inline-block sw-align-middle',
placeholder: () => 'sw-truncate sw-leading-4',
- menu: () => 'sw-overflow-y-auto sw-py-2 sw-max-h-[12.25rem]',
+ menu: () => 'sw-w-auto',
+ menuList: () => 'sw-overflow-y-auto sw-py-2 sw-max-h-[12.25rem]',
control: ({ isDisabled }) =>
classNames(
- 'sw-absolut sw-box-border sw-rounded-2 sw-mt-1 sw-overflow-hidden sw-z-dropdown-menu',
+ 'sw-absolut sw-box-border sw-rounded-2 sw-overflow-hidden sw-z-dropdown-menu',
isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
),
option: ({ isDisabled }) =>
IndicatorSeparator: null,
}}
isSearchable={props.isSearchable ?? false}
- styles={selectStyle<Option, IsMulti, Group>({ size })}
+ styles={selectStyle({ size })}
/>
);
}
export function selectStyle<
- Option = LabelValueSelectOption,
- IsMulti extends boolean = boolean,
+ V,
+ Option extends LabelValueSelectOption<V>,
+ IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>({ size }: { size: InputSizeKeys }): StylesConfig<Option, IsMulti, Group> {
const theme = themeInfo();
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { getMonth, getYear, parseISO } from 'date-fns';
+import { render } from '../../helpers/testUtils';
+import { DatePicker } from '../DatePicker';
+
+it('behaves correctly', async () => {
+ const user = userEvent.setup();
+
+ const onChange = jest.fn((_: Date) => undefined);
+ const currentMonth = parseISO('2022-06-13');
+ renderDatePicker({ currentMonth, onChange });
+
+ /*
+ * Open the DatePicker, navigate to the previous month and choose an arbitrary day (7)
+ * Then check that onChange was correctly called with a date in the previous month
+ */
+ await user.click(screen.getByRole('textbox'));
+
+ const nav = screen.getByRole('navigation');
+ expect(nav).toBeInTheDocument();
+
+ await user.click(within(nav).getByRole('button', { name: 'previous' }));
+ await user.click(screen.getByText('7'));
+
+ expect(onChange).toHaveBeenCalled();
+ const newDate = onChange.mock.calls[0][0]; // first argument of the first and only call
+ expect(getMonth(newDate)).toBe(getMonth(currentMonth) - 1);
+
+ onChange.mockClear();
+
+ /*
+ * Open the DatePicker, navigate to the next month twice and choose an arbitrary day (12)
+ * Then check that onChange was correctly called with a date in the following month
+ */
+ await user.click(screen.getByRole('textbox'));
+ const nextButton = screen.getByRole('button', { name: 'next' });
+ await user.click(nextButton);
+ await user.click(nextButton);
+ await user.click(screen.getByText('12'));
+
+ expect(onChange).toHaveBeenCalled();
+ const newDate2 = onChange.mock.calls[0][0]; // first argument
+ expect(getMonth(newDate2)).toBe(getMonth(currentMonth) + 1);
+
+ onChange.mockClear();
+
+ /*
+ * Open the DatePicker, select the month, select the year and choose an arbitrary day (10)
+ * Then check that onChange was correctly called with a date in the selected month & year
+ */
+ await user.click(screen.getByRole('textbox'));
+ // Select month
+ await user.click(screen.getByText('Jun'));
+ await user.click(screen.getByText('Feb'));
+
+ // Select year
+ await user.click(screen.getByText('2022'));
+ await user.click(screen.getByText('2019'));
+
+ await user.click(screen.getByText('10'));
+
+ const newDate3 = onChange.mock.calls[0][0]; // first argument
+
+ expect(getMonth(newDate3)).toBe(1);
+ expect(getYear(newDate3)).toBe(2019);
+});
+
+it('highlights the appropriate days', async () => {
+ const user = userEvent.setup();
+
+ const value = parseISO('2022-06-14');
+ renderDatePicker({ highlightFrom: parseISO('2022-06-12'), showClearButton: true, value });
+
+ await user.click(screen.getByRole('textbox'));
+
+ expect(screen.getByText('11')).not.toHaveClass('rdp-highlighted');
+ expect(screen.getByText('12')).toHaveClass('rdp-highlighted');
+ expect(screen.getByText('13')).toHaveClass('rdp-highlighted');
+ expect(screen.getByText('14')).toHaveClass('rdp-highlighted');
+ expect(screen.getByText('15')).not.toHaveClass('rdp-highlighted');
+});
+
+function renderDatePicker(overrides: Partial<DatePicker['props']> = {}) {
+ const defaultFormatter = (date?: Date) => (date ? date.toISOString() : '');
+
+ render(
+ <DatePicker
+ ariaNextMonthLabel="next"
+ ariaPreviousMonthLabel="previous"
+ clearButtonLabel="clear"
+ onChange={jest.fn()}
+ placeholder="placeholder"
+ valueFormatter={defaultFormatter}
+ {...overrides}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { formatISO, parseISO } from 'date-fns';
+import { render } from '../../helpers/testUtils';
+import { DateRangePicker } from '../DateRangePicker';
+
+beforeAll(() => {
+ jest.useFakeTimers().setSystemTime(parseISO('2022-06-12'));
+});
+
+afterAll(() => {
+ jest.useRealTimers();
+});
+
+it('behaves correctly', async () => {
+ // Remove delay to play nice with fake timers
+ const user = userEvent.setup({ delay: null });
+
+ const onChange = jest.fn((_: { from?: Date; to?: Date }) => undefined);
+ renderDateRangePicker({ onChange });
+
+ await user.click(screen.getByRole('textbox', { name: 'from' }));
+
+ const fromDateNav = screen.getByRole('navigation');
+ expect(fromDateNav).toBeInTheDocument();
+
+ await user.click(within(fromDateNav).getByRole('button', { name: 'previous' }));
+ await user.click(screen.getByText('7'));
+
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+
+ expect(onChange).toHaveBeenCalled();
+ const { from } = onChange.mock.calls[0][0]; // first argument
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ expect(formatISO(from!, { representation: 'date' })).toBe('2022-05-07');
+
+ onChange.mockClear();
+
+ jest.runAllTimers();
+
+ const toDateNav = await screen.findByRole('navigation');
+ const previousButton = within(toDateNav).getByRole('button', { name: 'previous' });
+ const nextButton = within(toDateNav).getByRole('button', { name: 'next' });
+ await user.click(previousButton);
+ await user.click(nextButton);
+ await user.click(previousButton);
+ await user.click(screen.getByText('12'));
+
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+
+ expect(onChange).toHaveBeenCalled();
+ const { to } = onChange.mock.calls[0][0]; // first argument
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ expect(formatISO(to!, { representation: 'date' })).toBe('2022-05-12');
+});
+
+function renderDateRangePicker(overrides: Partial<DateRangePicker['props']> = {}) {
+ const defaultFormatter = (date?: Date) =>
+ date ? formatISO(date, { representation: 'date' }) : '';
+
+ render(
+ <DateRangePicker
+ ariaNextMonthLabel="next"
+ ariaPreviousMonthLabel="previous"
+ clearButtonLabel="clear"
+ fromLabel="from"
+ onChange={jest.fn()}
+ toLabel="to"
+ valueFormatter={defaultFormatter}
+ {...overrides}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { render, screen } from '@testing-library/react';
+import { InputField } from '../InputField';
+
+describe('Input Field', () => {
+ it.each([
+ ['default', false, false, 'defaultStyle'],
+ ['invalid', true, false, 'dangerStyle'],
+ ['valid', false, true, 'successStyle'],
+ ])('should handle status %s', (_, isInvalid, isValid, expectedStyle) => {
+ render(<InputField isInvalid={isInvalid} isValid={isValid} />);
+
+ // Emotion classes contain pseudo-random parts, we're interesting in the fixed part
+ // so we can't just check a specific class
+ // eslint-disable-next-line jest-dom/prefer-to-have-class
+ expect(screen.getByRole('textbox').className).toContain(expectedStyle);
+ });
+});
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { CalendarIcon as Octicon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export const CalendarIcon = OcticonHoc(Octicon);
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export function ChevronLeftIcon({ fill = 'currentColor', ...iconProps }: IconProps) {
+ const theme = useTheme();
+ return (
+ <CustomIcon {...iconProps}>
+ <path
+ clipRule="evenodd"
+ d="M10.16801 12.7236c-.19527.1953-.51185.1953-.70711 0l-4.185-4.18499c-.1953-.19526-.1953-.51184 0-.7071l4.185-4.18503c.19526-.19527.51184-.19527.70711 0 .19526.19526.19526.51184 0 .7071L6.33653 8.18506l3.83148 3.83144c.19526.1953.19526.5119 0 .7071Z"
+ fill={themeColor(fill)({ theme })}
+ fillRule="evenodd"
+ />
+ </CustomIcon>
+ );
+}
export { BranchIcon } from './BranchIcon';
export { BugIcon } from './BugIcon';
+export { CalendarIcon } from './CalendarIcon';
export { ChevronDownIcon } from './ChevronDownIcon';
+export { ChevronLeftIcon } from './ChevronLeftIcon';
export { ChevronRightIcon } from './ChevronRightIcon';
export { ClockIcon } from './ClockIcon';
export { CodeSmellIcon } from './CodeSmellIcon';
export { BarChart } from './BarChart';
export * from './Card';
export * from './CoverageIndicator';
+export * from './DatePicker';
+export * from './DateRangePicker';
export { DeferredSpinner } from './DeferredSpinner';
export * from './DiscreetSelect';
export { Dropdown } from './Dropdown';
export * from './GenericAvatar';
export * from './HighlightedSection';
export { HotspotRating } from './HotspotRating';
+export * from './InputField';
export { InputSearch } from './InputSearch';
export * from './InputSelect';
export * from './InteractiveIcon';
}
.rdp-day_selected {
- background-color: var(--blue) !important;
+ background-color: var(--blue);
+}
+
+.rdp-day_selected:hover {
+ background-color: var(--blue);
}
.date-input-control {
d3-array: 3.2.3
d3-scale: 4.0.2
d3-shape: 3.2.0
+ date-fns: 2.29.3
lodash: 4.17.21
react: 17.0.2
+ react-day-picker: 8.6.0
react-dom: 17.0.2
react-helmet-async: 1.3.0
react-intl: 6.2.5