= ({
onClick={() => onClick(image)}
onDoubleClick={() => onDoubleClick(image)}
>
- {showPlaceholder &&
}
+ {showPlaceholder &&
}
)}
@@ -212,9 +212,9 @@ const ImageSelector: React.FC
= ({
}
+ iconStart={}
onClick={fetchMore}
- variant="supplementary"
+ variant={ButtonVariant.Supplementary}
>
{t('common.showMoreWithCount', { count: imagesLeft })}
diff --git a/src/common/components/imageUploader/ImageUploader.tsx b/src/common/components/imageUploader/ImageUploader.tsx
index 3a6637df4..69b9581cc 100644
--- a/src/common/components/imageUploader/ImageUploader.tsx
+++ b/src/common/components/imageUploader/ImageUploader.tsx
@@ -1,4 +1,4 @@
-import { IconMinusCircle } from 'hds-react';
+import { ButtonVariant, IconMinusCircle } from 'hds-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PixelCrop } from 'react-image-crop';
@@ -66,10 +66,10 @@ const ImageUploader: React.FC = ({
}
+ iconStart={}
onClick={removeImageFile}
type="button"
- variant="danger"
+ variant={ButtonVariant.Danger}
>
{t('common.imageUploader.buttonRemoveImage')}
diff --git a/src/common/components/keywordSelector/KeywordSelector.tsx b/src/common/components/keywordSelector/KeywordSelector.tsx
index 2863125eb..556a5d0fb 100644
--- a/src/common/components/keywordSelector/KeywordSelector.tsx
+++ b/src/common/components/keywordSelector/KeywordSelector.tsx
@@ -1,14 +1,14 @@
/* eslint-disable no-undef */
import {
ApolloClient,
+ ApolloError,
NormalizedCacheObject,
useApolloClient,
} from '@apollo/client';
-import React from 'react';
+import { Option, SearchFunction, SearchResult, SelectData } from 'hds-react';
+import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
-import { useDebounce } from 'use-debounce';
-import { COMBOBOX_DEBOUNCE_TIME_MS } from '../../../constants';
import {
getKeywordFields,
getKeywordQueryResult,
@@ -20,13 +20,12 @@ import {
useKeywordsQuery,
} from '../../../generated/graphql';
import useLocale from '../../../hooks/useLocale';
-import useMountedState from '../../../hooks/useMountedState';
import { Language, OptionType } from '../../../types';
import getPathBuilder from '../../../utils/getPathBuilder';
import getValue from '../../../utils/getValue';
import parseIdFromAtId from '../../../utils/parseIdFromAtId';
import skipFalsyType from '../../../utils/skipFalsyType';
-import Combobox, { MultiComboboxProps } from '../combobox/Combobox';
+import Select, { MultiSelectPropsWithValue } from '../select/Select';
const getOption = ({
keyword,
@@ -40,51 +39,43 @@ const getOption = ({
return { label, value };
};
-export type KeywordSelectorProps = Omit<
- MultiComboboxProps,
- 'toggleButtonAriaLabel'
->;
+export type KeywordSelectorProps = MultiSelectPropsWithValue & {
+ handleClose: (selectedOptions: OptionType[]) => void;
+};
const KeywordSelector: React.FC = ({
- label,
+ texts,
name,
value,
+ handleClose,
...rest
}) => {
- const timer = React.useRef();
const apolloClient = useApolloClient() as ApolloClient;
const { t } = useTranslation();
const locale = useLocale();
- const [search, setSearch] = useMountedState('');
- const [debouncedSearch] = useDebounce(search, COMBOBOX_DEBOUNCE_TIME_MS);
const [selectedKeywords, setSelectedKeywords] = React.useState(
[]
);
+ const [options, setOptions] = React.useState([]);
+
+ const initialOptions = React.useRef([]);
const {
data: keywordsData,
loading,
previousData: previousKeywordsData,
+ refetch,
} = useKeywordsQuery({
variables: {
createPath: getPathBuilder(keywordsPathBuilder),
dataSource: ['yso', 'helsinki'],
showAllKeywords: true,
- freeText: debouncedSearch,
+ freeText: '',
},
});
- const handleFilter = (items: OptionType[], inputValue: string) => {
- clearTimeout(timer.current);
- timer.current = setTimeout(() => {
- setSearch(inputValue);
- });
-
- return items;
- };
-
- const options: OptionType[] = React.useMemo(
+ const keywordsOptions = React.useMemo(
() =>
getValue(
(keywordsData || previousKeywordsData)?.keywords.data.map((keyword) =>
@@ -95,42 +86,87 @@ const KeywordSelector: React.FC = ({
[keywordsData, locale, previousKeywordsData]
);
+ useEffect(() => {
+ setOptions(keywordsOptions);
+
+ if (keywordsData && !initialOptions?.current.length) {
+ initialOptions.current = keywordsOptions;
+ }
+ }, [keywordsOptions, keywordsData]);
+
+ const handleSearch: SearchFunction = React.useCallback(
+ async (searchValue: string): Promise => {
+ try {
+ const { error, data: newKeywordsData } = await refetch({
+ freeText: searchValue,
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return Promise.resolve({
+ options: getValue(
+ newKeywordsData?.keywords.data.map((keyword) =>
+ getOption({ keyword: keyword as KeywordFieldsFragment, locale })
+ ),
+ []
+ ),
+ });
+ } catch (error) {
+ return Promise.reject(error as ApolloError);
+ }
+ },
+ [refetch, locale]
+ );
+
+ const onClose = React.useCallback(
+ (
+ selectedOptions: Option[],
+ _clickedOption: undefined,
+ _data: SelectData
+ ) => {
+ setOptions(initialOptions.current);
+
+ if (handleClose) {
+ handleClose(selectedOptions);
+ }
+ },
+ [handleClose]
+ );
+
React.useEffect(() => {
- const getSelectedKeywordsFromCache = async () =>
- setSelectedKeywords(
- (
- await Promise.all(
- value.map(async (atId) => {
- const keyword = await getKeywordQueryResult(
- getValue(parseIdFromAtId(atId), ''),
- apolloClient
- );
- /* istanbul ignore next */
- return keyword ? getOption({ keyword: keyword, locale }) : null;
- })
- )
- ).filter(skipFalsyType)
+ const getSelectedKeywordsFromCache = async () => {
+ const selectedOptions = await Promise.all(
+ value.map(async (atId) => {
+ const keyword = await getKeywordQueryResult(
+ getValue(parseIdFromAtId(atId), ''),
+ apolloClient
+ );
+ /* istanbul ignore next */
+ return keyword ? getOption({ keyword: keyword, locale }) : null;
+ })
);
+ setSelectedKeywords(selectedOptions.filter(skipFalsyType));
+ };
+
getSelectedKeywordsFromCache();
}, [apolloClient, locale, value]);
- React.useEffect(() => {
- return () => clearTimeout(timer.current);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
return (
-
);
diff --git a/src/common/components/keywordSelector/__mocks__/keywordSelector.ts b/src/common/components/keywordSelector/__mocks__/keywordSelector.ts
index 4593df6b4..058c095f6 100644
--- a/src/common/components/keywordSelector/__mocks__/keywordSelector.ts
+++ b/src/common/components/keywordSelector/__mocks__/keywordSelector.ts
@@ -40,11 +40,24 @@ const mockedKeywordsResponse = {
result: keywordsResponse,
};
+const filteredKeywordsVariables = {
+ ...keywordsVariables,
+ freeText: keywordName,
+};
+const filteredKeywords = keywords;
+const filteredEventsResponse = keywordsResponse;
+const mockedFilteredKeywordsResponse = {
+ request: { query: KeywordDocument, variables: filteredKeywordsVariables },
+ result: filteredEventsResponse,
+};
+
export {
+ filteredKeywords,
keyword,
keywordAtId,
keywordName,
keywordNames,
+ mockedFilteredKeywordsResponse,
mockedKeywordResponse,
mockedKeywordsResponse,
};
diff --git a/src/common/components/keywordSelector/__tests__/KeywordSelector.test.tsx b/src/common/components/keywordSelector/__tests__/KeywordSelector.test.tsx
index 570eee5cd..b84fc6fe7 100644
--- a/src/common/components/keywordSelector/__tests__/KeywordSelector.test.tsx
+++ b/src/common/components/keywordSelector/__tests__/KeywordSelector.test.tsx
@@ -1,13 +1,17 @@
+import getValue from '../../../../utils/getValue';
import {
configure,
render,
screen,
shouldOpenMenuAndSelectOption,
+ userEvent,
} from '../../../../utils/testUtils';
import {
+ filteredKeywords,
keywordAtId,
keywordName,
keywordNames,
+ mockedFilteredKeywordsResponse,
mockedKeywordResponse,
mockedKeywordsResponse,
} from '../__mocks__/keywordSelector';
@@ -19,29 +23,40 @@ const helper = 'Helper text';
const label = 'Select keyword';
const name = 'keyword';
-const mocks = [mockedKeywordResponse, mockedKeywordsResponse];
+const mocks = [
+ mockedKeywordResponse,
+ mockedKeywordsResponse,
+ mockedFilteredKeywordsResponse,
+];
const clearButtonAriaLabel = 'Poista kaikki';
const selectedItemRemoveButtonAriaLabel = 'Poista valinta';
const defaultProps: KeywordSelectorProps = {
- clearButtonAriaLabel,
- helper,
- label,
- multiselect: true,
+ texts: {
+ clearButtonAriaLabel_multiple: clearButtonAriaLabel,
+ assistive: helper,
+ label,
+ tagRemoveSelectionAriaLabel: selectedItemRemoveButtonAriaLabel,
+ },
+ multiSelect: true,
name,
- selectedItemRemoveButtonAriaLabel,
value: [keywordAtId],
+ onChange: vi.fn(),
+ handleClose: vi.fn(),
};
const renderComponent = (props?: Partial) =>
render(, { mocks });
-test('should combobox input value to be selected keyword option label', async () => {
- renderComponent();
-
- await screen.findByText(keywordName, undefined, { timeout: 2000 });
-});
+const getElement = (key: 'inputField' | 'toggleButton') => {
+ switch (key) {
+ case 'inputField':
+ return screen.getByRole('combobox', { name: new RegExp(label) });
+ case 'toggleButton':
+ return screen.getByRole('button', { name: new RegExp(label) });
+ }
+};
test('should open menu by clickin toggle button and list of options should be visible', async () => {
renderComponent();
@@ -51,3 +66,23 @@ test('should open menu by clickin toggle button and list of options should be vi
toggleButtonLabel: new RegExp(label),
});
});
+
+test('should search for keywords', async () => {
+ const user = userEvent.setup();
+ renderComponent();
+
+ const toggleButton = getElement('toggleButton');
+
+ await user.click(toggleButton);
+
+ const input = getElement('inputField');
+
+ await user.type(input, keywordName);
+
+ for (const option of filteredKeywords.data) {
+ await screen.findByRole('option', {
+ hidden: true,
+ name: getValue(option?.name?.fi, ''),
+ });
+ }
+});
diff --git a/src/common/components/loadingButton/LoadingButton.tsx b/src/common/components/loadingButton/LoadingButton.tsx
index 2e16e91bf..0e17fb2c8 100644
--- a/src/common/components/loadingButton/LoadingButton.tsx
+++ b/src/common/components/loadingButton/LoadingButton.tsx
@@ -14,7 +14,7 @@ const LoadingButton = React.forwardRef(
{...rest}
ref={ref}
disabled={loading || disabled}
- iconLeft={
+ iconStart={
loading ? (
= ({
{...commonProps}
ref={toggleButton}
fullWidth={true}
- iconRight={
+ iconEnd={
menuOpen ? :
}
- variant="secondary"
+ variant={ButtonVariant.Secondary}
>
- {buttonLabel}
+ {buttonLabel}
);
};
diff --git a/src/common/components/multiSelectDropdown/MultiSelectDropdown.tsx b/src/common/components/multiSelectDropdown/MultiSelectDropdown.tsx
index 3641c3a32..8358855b7 100644
--- a/src/common/components/multiSelectDropdown/MultiSelectDropdown.tsx
+++ b/src/common/components/multiSelectDropdown/MultiSelectDropdown.tsx
@@ -19,7 +19,7 @@ export interface MultiselectDropdownProps {
loadingSpinnerText?: string;
onChange: (values: OptionType[]) => void;
options: OptionType[];
- renderOptionText?: (option: OptionType) => React.ReactChild;
+ renderOptionText?: (option: OptionType) => string | undefined;
searchPlaceholder?: string;
searchValue?: string;
setSearchValue?: (newVal: string) => void;
@@ -59,7 +59,7 @@ const MultiSelectDropdown: React.FC = ({
const filteredOptions = React.useMemo(() => {
return [
...options.filter((option) =>
- option.label.toLowerCase().includes(searchValue.toLowerCase())
+ option.label?.toLowerCase().includes(searchValue.toLowerCase())
),
].filter(skipFalsyType);
}, [options, searchValue]);
diff --git a/src/common/components/pagination/__tests__/Pagination.test.tsx b/src/common/components/pagination/__tests__/Pagination.test.tsx
index 2d64fd1c4..b387342d9 100644
--- a/src/common/components/pagination/__tests__/Pagination.test.tsx
+++ b/src/common/components/pagination/__tests__/Pagination.test.tsx
@@ -67,7 +67,9 @@ test('should call onChange when clicking page link', async () => {
const user = userEvent.setup();
renderComponent({ pageIndex: 0, onChange });
- const page2Link = screen.getByTestId('hds-pagination-page-2');
+ const links = screen.getAllByRole('listitem');
+ const page2Link = links[1].querySelector('a') as HTMLAnchorElement;
+
await user.click(page2Link);
expect(onChange).toBeCalledWith(expect.objectContaining({}), 1);
diff --git a/src/common/components/placeSelector/PlaceSelector.tsx b/src/common/components/placeSelector/PlaceSelector.tsx
index a18753ae7..39b5dc8a4 100644
--- a/src/common/components/placeSelector/PlaceSelector.tsx
+++ b/src/common/components/placeSelector/PlaceSelector.tsx
@@ -1,10 +1,10 @@
/* eslint-disable no-undef */
+import { ApolloError } from '@apollo/client';
+import { Option, SearchFunction, SearchResult, SelectData } from 'hds-react';
import { TFunction } from 'i18next';
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
-import { useDebounce } from 'use-debounce';
-import { COMBOBOX_DEBOUNCE_TIME_MS } from '../../../constants';
import {
getPlaceFields,
placePathBuilder,
@@ -16,13 +16,12 @@ import {
usePlacesQuery,
} from '../../../generated/graphql';
import useLocale from '../../../hooks/useLocale';
-import useMountedState from '../../../hooks/useMountedState';
import { Language, OptionType } from '../../../types';
import getPathBuilder from '../../../utils/getPathBuilder';
import getValue from '../../../utils/getValue';
import parseIdFromAtId from '../../../utils/parseIdFromAtId';
import skipFalsyType from '../../../utils/skipFalsyType';
-import Combobox, { SingleComboboxProps } from '../combobox/Combobox';
+import Select, { SelectPropsWithValue } from '../select/Select';
import styles from './placeSelector.module.scss';
export type GetOptionArgs = {
@@ -58,32 +57,31 @@ export const getOption = ({
};
};
-export type PlaceSelectorProps = Omit<
- SingleComboboxProps,
- 'toggleButtonAriaLabel'
->;
+export type PlaceSelectorProps = SelectPropsWithValue;
const PlaceSelector: React.FC = ({
- label,
- name,
+ texts,
value,
+ name,
+ onChange,
...rest
}) => {
- const timer = React.useRef();
const { t } = useTranslation();
const locale = useLocale();
- const [search, setSearch] = useMountedState('');
- const [debouncedSearch] = useDebounce(search, COMBOBOX_DEBOUNCE_TIME_MS);
+
+ const [options, setOptions] = React.useState([]);
+ const initialOptions = React.useRef([]);
const {
data: placesData,
loading,
previousData: previousPlacesData,
+ refetch,
} = usePlacesQuery({
variables: {
createPath: getPathBuilder(placesPathBuilder),
showAllPlaces: true,
- text: debouncedSearch,
+ text: '',
},
});
@@ -95,16 +93,7 @@ const PlaceSelector: React.FC = ({
},
});
- const handleFilter = (items: OptionType[], inputValue: string) => {
- clearTimeout(timer.current);
- timer.current = setTimeout(() => {
- setSearch(inputValue);
- });
-
- return items;
- };
-
- const options = React.useMemo(
+ const getPlacesData = useCallback(
() =>
getValue(
(placesData || previousPlacesData)?.places.data.map((place) =>
@@ -115,39 +104,84 @@ const PlaceSelector: React.FC = ({
[locale, placesData, previousPlacesData, t]
);
- React.useEffect(() => {
- return () => clearTimeout(timer.current);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ const placesOptions = React.useMemo(() => getPlacesData(), [getPlacesData]);
+
+ useEffect(() => {
+ setOptions(placesOptions);
+
+ if (placesData && !initialOptions?.current.length) {
+ initialOptions.current = placesOptions;
+ }
+ }, [placesOptions, placesData]);
+
+ const handleSearch: SearchFunction = React.useCallback(
+ async (searchValue: string): Promise => {
+ try {
+ const { error, data: newPlacesData } = await refetch({
+ text: searchValue,
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return {
+ options: getValue(
+ newPlacesData?.places.data.map((place) =>
+ getOption({ place: place as PlaceFieldsFragment, locale, t })
+ ),
+ []
+ ),
+ };
+ } catch (error) {
+ return Promise.reject(error as ApolloError);
+ }
+ },
+ [refetch, locale, t]
+ );
+
+ const handleChange = React.useCallback(
+ (selectedOptions: Option[], clickedOption: Option, data: SelectData) => {
+ setOptions(initialOptions.current);
+
+ if (onChange) {
+ onChange(selectedOptions, clickedOption, data);
+ }
+ },
+ [onChange]
+ );
const selectedPlace = React.useMemo(
() =>
placeData?.place
- ? getOption({
- locale,
- place: placeData.place,
- showEventAmount: false,
- t,
- })
- : null,
+ ? [
+ getOption({
+ locale,
+ place: placeData.place,
+ showEventAmount: false,
+ t,
+ }),
+ ]
+ : [],
[locale, placeData, t]
);
return (
-
);
};
diff --git a/src/common/components/placeSelector/__mocks__/placeSelector.ts b/src/common/components/placeSelector/__mocks__/placeSelector.ts
index ebd163901..fef36a8c6 100644
--- a/src/common/components/placeSelector/__mocks__/placeSelector.ts
+++ b/src/common/components/placeSelector/__mocks__/placeSelector.ts
@@ -23,7 +23,7 @@ const mockedPlaceResponse = {
result: placeResponse,
};
-const places = fakePlaces(0);
+const places = fakePlaces(1, [place]);
const placesVariables = {
createPath: undefined,
showAllPlaces: true,
@@ -35,7 +35,7 @@ const mockedPlacesResponse = {
result: placesResponse,
};
-const filteredPlaces = fakePlaces(1, [place]);
+const filteredPlaces = places;
const filteredPlacesVariables = {
createPath: undefined,
showAllPlaces: true,
diff --git a/src/common/components/placeSelector/__tests__/PlaceSelector.test.tsx b/src/common/components/placeSelector/__tests__/PlaceSelector.test.tsx
index 19995adec..125d37f02 100644
--- a/src/common/components/placeSelector/__tests__/PlaceSelector.test.tsx
+++ b/src/common/components/placeSelector/__tests__/PlaceSelector.test.tsx
@@ -10,7 +10,6 @@ import {
render,
screen,
userEvent,
- waitFor,
} from '../../../../utils/testUtils';
import {
filteredPlaces,
@@ -39,10 +38,10 @@ const label = 'Select place';
const name = 'place';
const defaultProps: PlaceSelectorProps = {
- helper,
- label,
+ texts: { assistive: helper, label },
name,
value: place.atId,
+ onChange: vi.fn(),
};
const renderComponent = (props?: Partial) =>
@@ -51,31 +50,42 @@ const renderComponent = (props?: Partial) =>
const getElement = (key: 'inputField' | 'toggleButton') => {
switch (key) {
case 'inputField':
- return screen.getByRole('combobox', { name: new RegExp(helper) });
+ return screen.getByRole('combobox', { name: new RegExp(label) });
case 'toggleButton':
return screen.getByRole('button', { name: new RegExp(label) });
}
};
-test('should combobox input value to be selected place option label', async () => {
+test('should open menu by clickin toggle button and list of options should be visible', async () => {
+ const user = userEvent.setup();
renderComponent();
- const inputField = getElement('inputField');
+ const toggleButton = getElement('toggleButton');
+
+ await user.click(toggleButton);
- await waitFor(() => expect(inputField).toHaveValue(selectedPlaceText));
+ expect(toggleButton.getAttribute('aria-expanded')).toBe('true');
+
+ for (const option of filteredPlaces.data) {
+ await screen.findByRole('option', {
+ hidden: true,
+ name: new RegExp(getValue(option?.name?.fi, '')),
+ });
+ }
});
-test('should open menu by clickin toggle button and list of options should be visible', async () => {
+test('should search for places', async () => {
const user = userEvent.setup();
renderComponent();
- const inputField = getElement('inputField');
- expect(inputField.getAttribute('aria-expanded')).toBe('false');
-
const toggleButton = getElement('toggleButton');
+
await user.click(toggleButton);
- expect(inputField.getAttribute('aria-expanded')).toBe('true');
+ const input = getElement('inputField');
+
+ await user.type(input, selectedPlaceText);
+
for (const option of filteredPlaces.data) {
await screen.findByRole('option', {
hidden: true,
diff --git a/src/common/components/publisherSelector/PublisherSelector.tsx b/src/common/components/publisherSelector/PublisherSelector.tsx
index 699275f7d..11bc4f91b 100644
--- a/src/common/components/publisherSelector/PublisherSelector.tsx
+++ b/src/common/components/publisherSelector/PublisherSelector.tsx
@@ -9,18 +9,17 @@ import useUser from '../../../domain/user/hooks/useUser';
import useUserOrganizations from '../../../domain/user/hooks/useUserOrganizations';
import { useOrganizationQuery } from '../../../generated/graphql';
import useLocale from '../../../hooks/useLocale';
-import { OptionType } from '../../../types';
import getPathBuilder from '../../../utils/getPathBuilder';
import getValue from '../../../utils/getValue';
-import Combobox, { SingleComboboxProps } from '../combobox/Combobox';
+import Select, { SelectPropsWithValue } from '../select/Select';
export type PublisherSelectorProps = {
publisher?: string | null;
-} & Omit, 'toggleButtonAriaLabel'>;
+} & SelectPropsWithValue;
const PublisherSelector: React.FC = ({
clearable = false,
- label,
+ texts,
name,
publisher,
value,
@@ -43,19 +42,21 @@ const PublisherSelector: React.FC = ({
const selectedOrganization = React.useMemo(
() =>
organizationData?.organization
- ? getOrganizationOption({
- idPath: 'id',
- locale,
- organization: organizationData.organization,
- t,
- })
- : null,
+ ? [
+ getOrganizationOption({
+ idPath: 'id',
+ locale,
+ organization: organizationData.organization,
+ t,
+ }),
+ ]
+ : [],
[locale, organizationData?.organization, t]
);
const options = useMemo(() => {
if (publisher) {
- return selectedOrganization ? [selectedOrganization] : [];
+ return selectedOrganization.length ? selectedOrganization : [];
}
return organizations.map((org) =>
getOrganizationOption({
@@ -68,19 +69,18 @@ const PublisherSelector: React.FC = ({
}, [locale, organizations, publisher, selectedOrganization, t]);
return (
-
);
};
diff --git a/src/common/components/publisherSelector/__tests__/PublisherSelector.test.tsx b/src/common/components/publisherSelector/__tests__/PublisherSelector.test.tsx
index 957e87657..1d3904008 100644
--- a/src/common/components/publisherSelector/__tests__/PublisherSelector.test.tsx
+++ b/src/common/components/publisherSelector/__tests__/PublisherSelector.test.tsx
@@ -11,7 +11,6 @@ import {
render,
screen,
userEvent,
- waitFor,
} from '../../../../utils/testUtils';
import PublisherSelector, {
PublisherSelectorProps,
@@ -94,9 +93,10 @@ const mocks = [
];
const defaultProps: PublisherSelectorProps = {
- label,
+ texts: { label },
name: 'publisher-selector',
value: publisherId,
+ onChange: vi.fn(),
};
const renderComponent = (props?: Partial) =>
@@ -104,36 +104,29 @@ const renderComponent = (props?: Partial) =>
mocks,
});
-const getElement = (key: 'searchInput' | 'toggleButton') => {
- switch (key) {
- case 'searchInput':
- return screen.getByRole('combobox', { name: label });
- case 'toggleButton':
- return screen.getByRole('button', { name: `${label}: Valikko` });
- }
-};
+const getToggleButton = () =>
+ screen.getByRole('combobox', { name: new RegExp(label) });
test('should show users organizations as menu options', async () => {
const user = userEvent.setup();
- renderComponent({ publisher: null, value: null });
+ renderComponent({ publisher: null, value: undefined });
- getElement('searchInput');
+ const toggleButton = getToggleButton();
- const toggleButton = getElement('toggleButton');
await user.click(toggleButton);
await screen.findByRole('option', { name: organizationName });
+
screen.getByRole('option', { name: adminOrganizationName });
});
test('should show publisher as menu option', async () => {
const user = userEvent.setup();
+
renderComponent({ publisher: publisherId });
- const searchinput = getElement('searchInput');
- await waitFor(() => expect(searchinput).toHaveValue(publisherName));
+ const toggleButton = getToggleButton();
- const toggleButton = getElement('toggleButton');
await user.click(toggleButton);
await screen.findByRole('option', { name: publisherName });
diff --git a/src/common/components/searchPanel/SearchPanel.tsx b/src/common/components/searchPanel/SearchPanel.tsx
index b3e4f9f43..094d57516 100644
--- a/src/common/components/searchPanel/SearchPanel.tsx
+++ b/src/common/components/searchPanel/SearchPanel.tsx
@@ -1,4 +1,5 @@
import classNames from 'classnames';
+import { ButtonVariant } from 'hds-react';
import { FC, PropsWithChildren, ReactElement } from 'react';
import Button from '../button/Button';
@@ -65,7 +66,11 @@ const SearchRow: FC = ({
)}
-
diff --git a/src/common/components/select/Select.tsx b/src/common/components/select/Select.tsx
new file mode 100644
index 000000000..2ced50000
--- /dev/null
+++ b/src/common/components/select/Select.tsx
@@ -0,0 +1,44 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import classNames from 'classnames';
+import { Select as HDSSelect } from 'hds-react';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useTheme } from '../../../domain/app/theme/Theme';
+import SelectLoadingSpinner from '../selectLoadingSpinner/SelectLoadingSpinner';
+import { SingleSelectProps } from '../singleSelect/SingleSelect';
+import styles from './select.module.scss';
+
+export type SelectPropsWithValue = SingleSelectProps & {
+ name: string;
+ value: ValueType;
+};
+
+export type MultiSelectPropsWithValue = SingleSelectProps & {
+ name: string;
+ value: ValueType[];
+};
+
+const Select: React.FC = ({
+ alignedLabel,
+ isLoading,
+ className,
+ texts,
+ ...rest
+}) => {
+ const { theme } = useTheme();
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ );
+};
+
+export default Select;
diff --git a/src/common/components/select/select.module.scss b/src/common/components/select/select.module.scss
index d41f74b94..f7125c54b 100644
--- a/src/common/components/select/select.module.scss
+++ b/src/common/components/select/select.module.scss
@@ -1,8 +1,12 @@
-.label {
- .text {
- color: var(--color-black-90);
- }
- .amount {
- color: var(--color-black-60);
+.select {
+ max-width: 100% !important;
+
+ > div:focus-within {
+ box-shadow: none;
+
+ input:global(.focus-visible) {
+ outline: var(--focus-outline-width) solid var(--focus-outline-color);
+ outline-offset: 1px;
+ }
}
}
diff --git a/src/common/components/select/SelectLabel.tsx b/src/common/components/selectLabel/SelectLabel.tsx
similarity index 92%
rename from src/common/components/select/SelectLabel.tsx
rename to src/common/components/selectLabel/SelectLabel.tsx
index 80cd62bcd..6c91bd542 100644
--- a/src/common/components/select/SelectLabel.tsx
+++ b/src/common/components/selectLabel/SelectLabel.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
-import styles from './select.module.scss';
+import styles from './selectLabel.module.scss';
interface SelectLabelProps {
nEvents: number;
diff --git a/src/common/components/select/__tests__/SelectLabel.test.tsx b/src/common/components/selectLabel/__tests__/SelectLabel.test.tsx
similarity index 100%
rename from src/common/components/select/__tests__/SelectLabel.test.tsx
rename to src/common/components/selectLabel/__tests__/SelectLabel.test.tsx
diff --git a/src/common/components/select/__tests__/__snapshots__/SelectLabel.test.tsx.snap b/src/common/components/selectLabel/__tests__/__snapshots__/SelectLabel.test.tsx.snap
similarity index 100%
rename from src/common/components/select/__tests__/__snapshots__/SelectLabel.test.tsx.snap
rename to src/common/components/selectLabel/__tests__/__snapshots__/SelectLabel.test.tsx.snap
diff --git a/src/common/components/selectLabel/selectLabel.module.scss b/src/common/components/selectLabel/selectLabel.module.scss
new file mode 100644
index 000000000..d41f74b94
--- /dev/null
+++ b/src/common/components/selectLabel/selectLabel.module.scss
@@ -0,0 +1,8 @@
+.label {
+ .text {
+ color: var(--color-black-90);
+ }
+ .amount {
+ color: var(--color-black-60);
+ }
+}
diff --git a/src/common/components/comboboxLoadingSpinner/ComboboxLoadingSpinner.tsx b/src/common/components/selectLoadingSpinner/SelectLoadingSpinner.tsx
similarity index 69%
rename from src/common/components/comboboxLoadingSpinner/ComboboxLoadingSpinner.tsx
rename to src/common/components/selectLoadingSpinner/SelectLoadingSpinner.tsx
index 39d1261ba..1022f75cc 100644
--- a/src/common/components/comboboxLoadingSpinner/ComboboxLoadingSpinner.tsx
+++ b/src/common/components/selectLoadingSpinner/SelectLoadingSpinner.tsx
@@ -2,18 +2,18 @@ import classNames from 'classnames';
import { FC, PropsWithChildren } from 'react';
import LoadingSpinner from '../loadingSpinner/LoadingSpinner';
-import styles from './comboboxLoadingSpinner.module.scss';
+import styles from './selectLoadingSpinner.module.scss';
-export type ComboboxLoadingSpinnerProps = {
+export type SelectLoadingSpinnerProps = {
alignedLabel?: boolean;
isLoading?: boolean;
};
-const ComboboxLoadingSpinner: FC<
- PropsWithChildren
+const SelectLoadingSpinner: FC<
+ PropsWithChildren
> = ({ alignedLabel, children, isLoading }) => {
return (
-
+
{children}
{isLoading && (
= ({ errors, id: _id }) => {
{errors.map(({ label, message }) => (
diff --git a/src/common/components/singleKeywordSelector/SingleKeywordSelector.tsx b/src/common/components/singleKeywordSelector/SingleKeywordSelector.tsx
index c655e488c..8f00d48bb 100644
--- a/src/common/components/singleKeywordSelector/SingleKeywordSelector.tsx
+++ b/src/common/components/singleKeywordSelector/SingleKeywordSelector.tsx
@@ -1,9 +1,9 @@
/* eslint-disable no-undef */
-import React from 'react';
+import { ApolloError } from '@apollo/client';
+import { Option, SearchFunction, SearchResult, SelectData } from 'hds-react';
+import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
-import { useDebounce } from 'use-debounce';
-import { COMBOBOX_DEBOUNCE_TIME_MS } from '../../../constants';
import {
getKeywordFields,
keywordPathBuilder,
@@ -16,11 +16,10 @@ import {
useKeywordsQuery,
} from '../../../generated/graphql';
import useLocale from '../../../hooks/useLocale';
-import useMountedState from '../../../hooks/useMountedState';
import { Language, OptionType } from '../../../types';
import getPathBuilder from '../../../utils/getPathBuilder';
import getValue from '../../../utils/getValue';
-import Combobox, { SingleComboboxProps } from '../combobox/Combobox';
+import Select, { SelectPropsWithValue } from '../select/Select';
const getOption = ({
keyword,
@@ -34,32 +33,31 @@ const getOption = ({
return { label, value };
};
-export type SingleKeywordSelectorProps = Omit<
- SingleComboboxProps,
- 'toggleButtonAriaLabel'
->;
+export type SingleKeywordSelectorProps = SelectPropsWithValue;
const SingleKeywordSelector: React.FC = ({
- label,
+ texts,
name,
value,
+ onChange,
...rest
}) => {
- const timer = React.useRef();
const { t } = useTranslation();
const locale = useLocale();
- const [search, setSearch] = useMountedState('');
- const [debouncedSearch] = useDebounce(search, COMBOBOX_DEBOUNCE_TIME_MS);
+
+ const [options, setOptions] = React.useState([]);
+ const initialOptions = React.useRef([]);
const {
data: keywordsData,
loading,
previousData: previousKeywordsData,
+ refetch,
} = useKeywordsQuery({
variables: {
createPath: getPathBuilder(keywordsPathBuilder),
showAllKeywords: true,
- text: debouncedSearch,
+ text: '',
},
});
@@ -71,14 +69,7 @@ const SingleKeywordSelector: React.FC = ({
},
});
- const handleFilter = (items: OptionType[], inputValue: string) => {
- clearTimeout(timer.current);
- timer.current = setTimeout(() => setSearch(inputValue));
-
- return items;
- };
-
- const options: OptionType[] = React.useMemo(
+ const getKeywordsData = useCallback(
() =>
getValue(
(keywordsData || previousKeywordsData)?.keywords.data.map((keyword) =>
@@ -89,33 +80,78 @@ const SingleKeywordSelector: React.FC = ({
[keywordsData, locale, previousKeywordsData]
);
+ const keywordsOptions = React.useMemo(
+ () => getKeywordsData(),
+ [getKeywordsData]
+ );
+
+ React.useEffect(() => {
+ setOptions(keywordsOptions);
+
+ if (keywordsData && !initialOptions?.current.length) {
+ initialOptions.current = keywordsOptions;
+ }
+ }, [keywordsOptions, keywordsData]);
+
+ const handleSearch: SearchFunction = React.useCallback(
+ async (searchValue: string): Promise => {
+ try {
+ const { error, data: newkeywordsData } = await refetch({
+ text: searchValue,
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return {
+ options: getValue(
+ newkeywordsData?.keywords.data.map((keyword) =>
+ getOption({ keyword: keyword as KeywordFieldsFragment, locale })
+ ),
+ []
+ ),
+ };
+ } catch (error) {
+ return Promise.reject(error as ApolloError);
+ }
+ },
+ [refetch, locale]
+ );
+
+ const handleChange = React.useCallback(
+ (selectedOptions: Option[], clickedOption: Option, data: SelectData) => {
+ setOptions(initialOptions.current);
+
+ if (onChange) {
+ onChange(selectedOptions, clickedOption, data);
+ }
+ },
+ [onChange]
+ );
+
const selectedKeyword = React.useMemo(
() =>
keywordData?.keyword
- ? getOption({ keyword: keywordData.keyword, locale })
- : null,
+ ? [getOption({ keyword: keywordData.keyword, locale })]
+ : [],
[keywordData, locale]
);
- React.useEffect(() => {
- return () => clearTimeout(timer.current);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
return (
-
);
};
diff --git a/src/common/components/singleKeywordSelector/__tests__/SingleKeywordSelector.test.tsx b/src/common/components/singleKeywordSelector/__tests__/SingleKeywordSelector.test.tsx
index e40c41449..833a739fa 100644
--- a/src/common/components/singleKeywordSelector/__tests__/SingleKeywordSelector.test.tsx
+++ b/src/common/components/singleKeywordSelector/__tests__/SingleKeywordSelector.test.tsx
@@ -1,12 +1,14 @@
import { TEST_KEYWORD_ID } from '../../../../domain/keyword/constants';
+import getValue from '../../../../utils/getValue';
import {
configure,
render,
screen,
shouldOpenMenuAndSelectOption,
- waitFor,
+ userEvent,
} from '../../../../utils/testUtils';
import {
+ filteredKeywords,
keywordName,
keywordNames,
mockedKeywordResponse,
@@ -31,21 +33,24 @@ const mocks = [
];
const defaultProps: SingleKeywordSelectorProps = {
- label,
+ texts: { label },
name,
value: TEST_KEYWORD_ID,
+ onChange: vi.fn(),
+};
+
+const getElement = (key: 'inputField' | 'toggleButton') => {
+ switch (key) {
+ case 'inputField':
+ return screen.getByRole('combobox', { name: new RegExp(label) });
+ case 'toggleButton':
+ return screen.getByRole('button', { name: new RegExp(label) });
+ }
};
const renderComponent = (props?: Partial) =>
render(, { mocks });
-test('should set input value to equal label of selected keyword', async () => {
- renderComponent();
-
- const combobox = screen.getByRole('combobox', { name: new RegExp(label) });
- await waitFor(() => expect(combobox).toHaveValue(keywordName));
-});
-
test('should open menu by clickin toggle button and list of options should be visible', async () => {
renderComponent();
@@ -54,3 +59,23 @@ test('should open menu by clickin toggle button and list of options should be vi
toggleButtonLabel: new RegExp(label),
});
});
+
+test('should search for keywords', async () => {
+ const user = userEvent.setup();
+ renderComponent();
+
+ const toggleButton = getElement('toggleButton');
+
+ await user.click(toggleButton);
+
+ const input = getElement('inputField');
+
+ await user.type(input, keywordName);
+
+ for (const option of filteredKeywords.data) {
+ await screen.findByRole('option', {
+ hidden: true,
+ name: getValue(option?.name?.fi, ''),
+ });
+ }
+});
diff --git a/src/common/components/singleOrganizationClassSelector/SingleOrganizationClassSelector.tsx b/src/common/components/singleOrganizationClassSelector/SingleOrganizationClassSelector.tsx
index 95d73e623..a2120e6f7 100644
--- a/src/common/components/singleOrganizationClassSelector/SingleOrganizationClassSelector.tsx
+++ b/src/common/components/singleOrganizationClassSelector/SingleOrganizationClassSelector.tsx
@@ -13,7 +13,7 @@ import {
import { OptionType } from '../../../types';
import getPathBuilder from '../../../utils/getPathBuilder';
import getValue from '../../../utils/getValue';
-import Combobox, { SingleComboboxProps } from '../combobox/Combobox';
+import Select, { SelectPropsWithValue } from '../select/Select';
const getOption = ({
organizationClass,
@@ -26,18 +26,24 @@ const getOption = ({
return { label, value };
};
-export type SingleOrganizationClassSelectorProps = SingleComboboxProps<
- string | null
->;
+type SingleOrganizationClassSelectorProps = SelectPropsWithValue;
const SingleOrganizationClassSelector: React.FC<
SingleOrganizationClassSelectorProps
-> = ({ label, name, value, ...rest }) => {
+> = ({ texts, name, value, ...rest }) => {
const { t } = useTranslation();
const { loading, organizationClasses } = useAllOrganizationClasses();
- const options: OptionType[] = React.useMemo(
+ const { data: organizationClassData } = useOrganizationClassQuery({
+ skip: !value,
+ variables: {
+ id: getValue(value, ''),
+ createPath: getPathBuilder(organizationClassPathBuilder),
+ },
+ });
+
+ const options = React.useMemo(
() =>
getValue(
organizationClasses.map((organizationClass) =>
@@ -48,32 +54,23 @@ const SingleOrganizationClassSelector: React.FC<
[organizationClasses]
);
- const { data: organizationClassData } = useOrganizationClassQuery({
- skip: !value,
- variables: {
- id: getValue(value, ''),
- createPath: getPathBuilder(organizationClassPathBuilder),
- },
- });
-
const selectedOrganizationClass = React.useMemo(() => {
const organizationClass = organizationClassData?.organizationClass;
- return organizationClass ? getOption({ organizationClass }) : null;
+ return organizationClass ? [getOption({ organizationClass })] : [];
}, [organizationClassData]);
return (
-
);
};
diff --git a/src/common/components/singleOrganizationSelector/SingleOrganizationSelector.tsx b/src/common/components/singleOrganizationSelector/SingleOrganizationSelector.tsx
index 049e61f79..24ebcf1e0 100644
--- a/src/common/components/singleOrganizationSelector/SingleOrganizationSelector.tsx
+++ b/src/common/components/singleOrganizationSelector/SingleOrganizationSelector.tsx
@@ -9,7 +9,7 @@ import { OrganizationFieldsFragment } from '../../../generated/graphql';
import useLocale from '../../../hooks/useLocale';
import { Language, OptionType } from '../../../types';
import getValue from '../../../utils/getValue';
-import Combobox, { SingleComboboxProps } from '../combobox/Combobox';
+import Select, { SelectPropsWithValue } from '../select/Select';
const getOption = ({
locale,
@@ -29,12 +29,10 @@ const getOption = ({
return { label, value };
};
-export type SingleOrganizationSelectorProps = SingleComboboxProps<
- string | null
->;
+type SingleOrganizationSelectorProps = SelectPropsWithValue;
const SingleOrganizationSelector: React.FC = ({
- label,
+ texts,
name,
value,
...rest
@@ -44,7 +42,7 @@ const SingleOrganizationSelector: React.FC = ({
const { loading, organizations } = useAllOrganizations();
- const options: OptionType[] = React.useMemo(
+ const options = React.useMemo(
() =>
sortBy(
getValue(
@@ -58,28 +56,27 @@ const SingleOrganizationSelector: React.FC = ({
[locale, organizations, t]
);
- const selectedOrganization = React.useMemo(
- () =>
- getValue(
- options.find((o) => o.value === value),
- null
- ),
- [options, value]
- );
+ const selectedOrganization = React.useMemo(() => {
+ const option = getValue(
+ options.find((o) => o.value === value),
+ null
+ );
+
+ return option ? [option] : [];
+ }, [options, value]);
return (
-
);
};
diff --git a/src/common/components/singleSelect/SingleSelect.tsx b/src/common/components/singleSelect/SingleSelect.tsx
index 413c3fa1f..f62e8360f 100644
--- a/src/common/components/singleSelect/SingleSelect.tsx
+++ b/src/common/components/singleSelect/SingleSelect.tsx
@@ -1,21 +1,16 @@
import classNames from 'classnames';
-import { Select, SingleSelectProps as HdsSingleSelectProps } from 'hds-react';
+import { Select as HDSSelect, SelectProps } from 'hds-react';
import React from 'react';
-import { useTranslation } from 'react-i18next';
import { useTheme } from '../../../domain/app/theme/Theme';
-import { OptionType } from '../../../types';
-import {
- getA11ySelectionMessage,
- getA11yStatusMessage,
-} from '../../../utils/accessibilityUtils';
-import ComboboxLoadingSpinner, {
- ComboboxLoadingSpinnerProps,
-} from '../comboboxLoadingSpinner/ComboboxLoadingSpinner';
+import SelectLoadingSpinner, {
+ SelectLoadingSpinnerProps,
+} from '../selectLoadingSpinner/SelectLoadingSpinner';
import styles from './singleSelect.module.scss';
-export type SingleSelectProps = ComboboxLoadingSpinnerProps &
- HdsSingleSelectProps;
+export type SingleSelectProps = SelectLoadingSpinnerProps & {
+ className?: string;
+} & SelectProps;
const SingleSelect: React.FC = ({
alignedLabel,
@@ -24,21 +19,16 @@ const SingleSelect: React.FC = ({
...rest
}) => {
const { theme } = useTheme();
- const { t } = useTranslation();
return (
-
-
+
);
};
diff --git a/src/common/components/singleSelect/__tests__/SingleSelect.test.tsx b/src/common/components/singleSelect/__tests__/SingleSelect.test.tsx
index d2c44a7a7..164f97647 100644
--- a/src/common/components/singleSelect/__tests__/SingleSelect.test.tsx
+++ b/src/common/components/singleSelect/__tests__/SingleSelect.test.tsx
@@ -9,7 +9,13 @@ const options = [
];
const renderComponent = () =>
- render();
+ render(
+
+ );
test('renders the component', async () => {
const { container } = renderComponent();
diff --git a/src/common/components/singleSelect/__tests__/__snapshots__/SingleSelect.test.tsx.snap b/src/common/components/singleSelect/__tests__/__snapshots__/SingleSelect.test.tsx.snap
index 8942ea00d..b6f381150 100644
--- a/src/common/components/singleSelect/__tests__/__snapshots__/SingleSelect.test.tsx.snap
+++ b/src/common/components/singleSelect/__tests__/__snapshots__/SingleSelect.test.tsx.snap
@@ -3,55 +3,94 @@
exports[`renders the component 1`] = `
-
-
+
+
+
+
+
diff --git a/src/common/components/singleSelect/singleSelect.module.scss b/src/common/components/singleSelect/singleSelect.module.scss
index 7c2be36ee..594c33ee1 100644
--- a/src/common/components/singleSelect/singleSelect.module.scss
+++ b/src/common/components/singleSelect/singleSelect.module.scss
@@ -1,4 +1,6 @@
.select {
+ max-width: 100% !important;
+
> div:focus-within {
box-shadow: none;
diff --git a/src/common/components/table/CustomTable.tsx b/src/common/components/table/CustomTable.tsx
index 6b823aadc..73ce511d0 100644
--- a/src/common/components/table/CustomTable.tsx
+++ b/src/common/components/table/CustomTable.tsx
@@ -19,8 +19,8 @@ type TableProps = React.ComponentPropsWithoutRef<'table'> & {
} & TableWrapperProps &
Pick<
HdsTableProps,
- 'caption' | 'dataTestId' | 'dense' | 'variant' | 'verticalLines' | 'zebra'
- >;
+ 'caption' | 'dense' | 'variant' | 'verticalLines' | 'zebra'
+ > & { dataTestId?: string };
const CustomTable: FC
> = ({
caption,
diff --git a/src/common/components/table/__tests__/__snapshots__/Table.test.tsx.snap b/src/common/components/table/__tests__/__snapshots__/Table.test.tsx.snap
index 096f07025..d149d2e7a 100644
--- a/src/common/components/table/__tests__/__snapshots__/Table.test.tsx.snap
+++ b/src/common/components/table/__tests__/__snapshots__/Table.test.tsx.snap
@@ -11,8 +11,6 @@ exports[` spec > renders the component 1`] = `
>
= ({
onClick(event);
};
- const iconProps: IconProps = { 'aria-hidden': true, size: 's' };
+ const iconProps: IconProps = { 'aria-hidden': true, size: IconSize.Small };
return (
diff --git a/src/common/components/tabs/__tests__/__snapshots__/Tabs.test.tsx.snap b/src/common/components/tabs/__tests__/__snapshots__/Tabs.test.tsx.snap
index 542611d1b..2fa865825 100644
--- a/src/common/components/tabs/__tests__/__snapshots__/Tabs.test.tsx.snap
+++ b/src/common/components/tabs/__tests__/__snapshots__/Tabs.test.tsx.snap
@@ -19,7 +19,7 @@ exports[`renders the component 1`] = `