diff --git a/src/common/components/filterTag/publisherFilterTag/PublisherFilterTag.tsx b/src/common/components/filterTag/publisherFilterTag/PublisherFilterTag.tsx new file mode 100644 index 000000000..bb5b7ae21 --- /dev/null +++ b/src/common/components/filterTag/publisherFilterTag/PublisherFilterTag.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import useOrganizationOptions from '../../../../domain/organization/hooks/useOrganizationOptions'; +import getValue from '../../../../utils/getValue'; +import FilterTag, { FilterTagProps } from '../FilterTag'; + +type Props = Omit; + +const PublisherFilterTag: React.FC = ({ value, ...rest }) => { + const { t } = useTranslation(); + const { loading, options } = useOrganizationOptions('id'); + + const name = getValue(options.find((o) => o.value)?.label, ''); + + return ( + + ); +}; + +export default PublisherFilterTag; diff --git a/src/common/components/publisherSelector/PublisherSelector.tsx b/src/common/components/publisherSelector/PublisherSelector.tsx index 655d96dd7..699275f7d 100644 --- a/src/common/components/publisherSelector/PublisherSelector.tsx +++ b/src/common/components/publisherSelector/PublisherSelector.tsx @@ -1,25 +1,19 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { organizationPathBuilder } from '../../../domain/organization/utils'; +import { + getOrganizationOption, + organizationPathBuilder, +} from '../../../domain/organization/utils'; import useUser from '../../../domain/user/hooks/useUser'; import useUserOrganizations from '../../../domain/user/hooks/useUserOrganizations'; -import { - OrganizationFieldsFragment, - useOrganizationQuery, -} from '../../../generated/graphql'; +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'; -const getOption = (organization: OrganizationFieldsFragment): OptionType => { - return { - label: getValue(organization.name, ''), - value: getValue(organization.id, ''), - }; -}; - export type PublisherSelectorProps = { publisher?: string | null; } & Omit, 'toggleButtonAriaLabel'>; @@ -33,6 +27,7 @@ const PublisherSelector: React.FC = ({ ...rest }) => { const { t } = useTranslation(); + const locale = useLocale(); const { user } = useUser(); const { loading, organizations } = useUserOrganizations(user); @@ -48,17 +43,29 @@ const PublisherSelector: React.FC = ({ const selectedOrganization = React.useMemo( () => organizationData?.organization - ? getOption(organizationData.organization) + ? getOrganizationOption({ + idPath: 'id', + locale, + organization: organizationData.organization, + t, + }) : null, - [organizationData] + [locale, organizationData?.organization, t] ); const options = useMemo(() => { if (publisher) { return selectedOrganization ? [selectedOrganization] : []; } - return organizations.map((org) => getOption(org)); - }, [organizations, publisher, selectedOrganization]); + return organizations.map((org) => + getOrganizationOption({ + idPath: 'id', + locale, + organization: org, + t, + }) + ); + }, [locale, organizations, publisher, selectedOrganization, t]); return ( { const [searchState, setSearchState] = useSearchState({ end: null, place: [], + publisher: [], start: null, text: '', type: [], @@ -72,6 +81,12 @@ const SearchPanel: React.FC = () => { }); }; + const handleChangePublishers = (newPublishers: OptionType[]) => { + setSearchState({ + publisher: newPublishers.map((p) => p.value), + }); + }; + const handleChangeText = (text: string) => { setSearchState({ text }); }; @@ -88,10 +103,9 @@ const SearchPanel: React.FC = () => { }; React.useEffect(() => { - const { end, places, start, text, types } = getEventSearchInitialValues( - location.search - ); - setSearchState({ end, place: places, start, text, type: types }); + const { end, places, publisher, start, text, types } = + getEventSearchInitialValues(location.search); + setSearchState({ end, place: places, publisher, start, text, type: types }); }, [location.search, setSearchState]); return ( @@ -174,6 +188,16 @@ const SearchPanel: React.FC = () => { } /> +
+ } + onChange={handleChangePublishers} + toggleButtonLabel={t( + 'eventSearchPage.searchPanel.labelPublisher' + )} + value={searchState.publisher} + /> +
diff --git a/src/domain/eventSearch/searchPanel/publisherSelector/PublisherSelector.tsx b/src/domain/eventSearch/searchPanel/publisherSelector/PublisherSelector.tsx new file mode 100644 index 000000000..85c7a7b3b --- /dev/null +++ b/src/domain/eventSearch/searchPanel/publisherSelector/PublisherSelector.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import MultiSelectDropdown, { + MultiselectDropdownProps, +} from '../../../../common/components/multiSelectDropdown/MultiSelectDropdown'; +import skipFalsyType from '../../../../utils/skipFalsyType'; +import useOrganizationOptions from '../../../organization/hooks/useOrganizationOptions'; + +export type PublisherSelectorProps = { value: string[] } & Omit< + MultiselectDropdownProps, + 'options' | 'value' +>; + +const PublisherSelector: React.FC = ({ + id, + toggleButtonLabel, + value, + ...rest +}) => { + const [searchValue, setSearchValue] = React.useState(''); + + const { loading, options } = useOrganizationOptions('id'); + + console.log(value); + return ( + options.find((o) => o.value === v)) + .filter(skipFalsyType)} + /> + ); +}; + +export default PublisherSelector; diff --git a/src/domain/eventSearch/searchPanel/publisherSelector/__tests__/PublisherSelector.test.tsx b/src/domain/eventSearch/searchPanel/publisherSelector/__tests__/PublisherSelector.test.tsx new file mode 100644 index 000000000..be7c5ab81 --- /dev/null +++ b/src/domain/eventSearch/searchPanel/publisherSelector/__tests__/PublisherSelector.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { + configure, + render, + screen, + userEvent, +} from '../../../../../utils/testUtils'; +import { + mockedOrganizationsResponse, + organizations, + organizationsOverrides, +} from '../../../../organizations/__mocks__/organizationsPage'; +import PublisherSelector, { + PublisherSelectorProps, +} from '../PublisherSelector'; + +configure({ defaultHidden: true }); + +const mocks = [mockedOrganizationsResponse]; + +const toggleButtonLabel = 'Select place'; + +const defaultProps: PublisherSelectorProps = { + onChange: vi.fn(), + toggleButtonLabel, + value: [], +}; + +const renderComponent = (props?: Partial) => + render(, { mocks }); + +const getToggleButton = () => + screen.getByRole('button', { name: toggleButtonLabel }); + +test('should render publisher selector', async () => { + const publisherId = organizations.data[0]?.id as string; + const publisherName = organizations.data[0]?.name as string; + const user = userEvent.setup(); + renderComponent({ value: [publisherId] }); + + await screen.findByText(publisherName); + + const toggleButton = getToggleButton(); + await user.click(toggleButton); + + for (const { name } of organizationsOverrides) { + await screen.findByLabelText(name as string); + } +}); diff --git a/src/domain/eventSearch/searchPanel/searchPanel.module.scss b/src/domain/eventSearch/searchPanel/searchPanel.module.scss index 5299b6529..556a23408 100644 --- a/src/domain/eventSearch/searchPanel/searchPanel.module.scss +++ b/src/domain/eventSearch/searchPanel/searchPanel.module.scss @@ -4,6 +4,7 @@ .searchPanel { --search-panel-background-color: var(--color-coat-of-arms); --search-panel-label-color: var(--color-white); + --search-panel-button-border-color: var(--color-white); --search-panel-koros-height: 70px; --search-panel-button-background-color: var(--color-coat-of-arms); diff --git a/src/domain/events/__tests__/utils.test.ts b/src/domain/events/__tests__/utils.test.ts index abf37acb1..01188604f 100644 --- a/src/domain/events/__tests__/utils.test.ts +++ b/src/domain/events/__tests__/utils.test.ts @@ -172,6 +172,7 @@ describe('getEventsQueryVariables', () => { location: [], page: 1, pageSize: 10, + publisher: [], sort: DEFAULT_EVENT_SORT, start: null, text: '', @@ -184,6 +185,10 @@ describe('getEventsQueryVariables', () => { '?place=place:1&place=place:2', { ...defaultVariables, location: ['place:1', 'place:2'] }, ], + [ + '?publisher=publisher:1&publisher=publisher:2', + { ...defaultVariables, publisher: ['publisher:1', 'publisher:2'] }, + ], ['?sort=name', { ...defaultVariables, sort: 'name' }], ['?start=2021-05-27', { ...defaultVariables, start: '2021-05-27' }], ['?text=search', { ...defaultVariables, text: 'search' }], diff --git a/src/domain/events/constants.ts b/src/domain/events/constants.ts index b78164335..841ba52fc 100644 --- a/src/domain/events/constants.ts +++ b/src/domain/events/constants.ts @@ -19,6 +19,7 @@ export enum EVENT_SEARCH_PARAMS { END = 'end', PAGE = 'page', PLACE = 'place', + PUBLISHER = 'publisher', RETURN_PATH = 'returnPath', SORT = 'sort', START = 'start', diff --git a/src/domain/events/eventList/EventList.tsx b/src/domain/events/eventList/EventList.tsx index 7683043c6..a17591167 100644 --- a/src/domain/events/eventList/EventList.tsx +++ b/src/domain/events/eventList/EventList.tsx @@ -160,7 +160,10 @@ const EventListContainer: React.FC = (props) => { const variables = { ...baseVariables, - ...getEventsQueryVariables(location.search, baseVariables), + ...omit( + getEventsQueryVariables(location.search, baseVariables), + 'publisher' + ), }; const { data: eventsData, loading } = useEventsQuery({ diff --git a/src/domain/events/eventList/__tests__/EventList.test.tsx b/src/domain/events/eventList/__tests__/EventList.test.tsx index a1f6e351c..ceb34ddf1 100644 --- a/src/domain/events/eventList/__tests__/EventList.test.tsx +++ b/src/domain/events/eventList/__tests__/EventList.test.tsx @@ -42,6 +42,7 @@ const variables = { eventType: [], include: EVENT_LIST_INCLUDES, location: [], + publisher: [], start: null, text: '', }; diff --git a/src/domain/events/filterSummary/FilterSummary.tsx b/src/domain/events/filterSummary/FilterSummary.tsx index dfd62b768..1a25ab3e2 100644 --- a/src/domain/events/filterSummary/FilterSummary.tsx +++ b/src/domain/events/filterSummary/FilterSummary.tsx @@ -7,6 +7,7 @@ import DateFilterTag from '../../../common/components/filterTag/dateFilterTag/Da import EventTypeFilterTag from '../../../common/components/filterTag/evenTypeFilterTag/EventTypeFilterTag'; import FilterTag from '../../../common/components/filterTag/FilterTag'; import PlaceFilterTag from '../../../common/components/filterTag/placeFilterTag/PlaceFilterTag'; +import PublisherFilterTag from '../../../common/components/filterTag/publisherFilterTag/PublisherFilterTag'; import { FilterType } from '../../../types'; import { getEventSearchInitialValues, @@ -22,7 +23,7 @@ const FilterSummary: React.FC = ({ className }) => { const { t } = useTranslation(); const navigate = useNavigate(); const { pathname, search } = useLocation(); - const { end, places, start, text, types } = + const { end, places, publisher, start, text, types } = getEventSearchInitialValues(search); const clearFilters = () => { @@ -31,6 +32,7 @@ const FilterSummary: React.FC = ({ className }) => { search: replaceParamsToEventQueryString(search, { end: null, place: [], + publisher: [], start: null, text: '', type: [], @@ -52,6 +54,10 @@ const FilterSummary: React.FC = ({ className }) => { type === 'eventType' ? types.filter((item) => item !== value) : types, place: type === 'place' ? places.filter((item) => item !== value) : places, + publisher: + type === 'publisher' + ? publisher.filter((item) => item !== value) + : publisher, start: type === 'date' ? null : start, text: type === 'text' ? '' : text, }); @@ -82,11 +88,14 @@ const FilterSummary: React.FC = ({ className }) => { )), ...types.map((type) => ( + )), + ...publisher.map((org) => ( + )) ); return filters; - }, [end, navigate, pathname, places, search, start, text, types]); + }, [end, navigate, pathname, places, publisher, search, start, text, types]); if (!filters.length) return null; diff --git a/src/domain/events/filterSummary/__tests__/FilterSummary.test.tsx b/src/domain/events/filterSummary/__tests__/FilterSummary.test.tsx index 028d4930c..4eb7e42de 100644 --- a/src/domain/events/filterSummary/__tests__/FilterSummary.test.tsx +++ b/src/domain/events/filterSummary/__tests__/FilterSummary.test.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { mockedPlaceResponse, placeId, @@ -13,16 +11,22 @@ import { userEvent, } from '../../../../utils/testUtils'; import { EVENT_TYPE } from '../../../event/constants'; +import { + mockedOrganizationsResponse, + organizations, +} from '../../../organizations/__mocks__/organizationsPage'; import FilterSummary from '../FilterSummary'; configure({ defaultHidden: true }); +const publisherId = organizations.data[0]?.id; +const publisherName = organizations.data[0]?.name as string; const text = 'Search word'; const end = '2021-10-13'; const start = '2021-10-05'; const type = EVENT_TYPE.General; -const mocks = [mockedPlaceResponse]; +const mocks = [mockedOrganizationsResponse, mockedPlaceResponse]; const renderComponent = (route = `/fi${ROUTES.SEARCH}`) => render(, { mocks, routes: [route] }); @@ -53,6 +57,21 @@ test('should render and remove place filter', async () => { expect(history.location.search).toBe(''); }); +test('should render and remove publisher filter', async () => { + const user = userEvent.setup(); + const { history } = renderComponent( + `/fi${ROUTES.SEARCH}?publisher=${publisherId}` + ); + + const deleteFilterButton = await screen.findByRole('button', { + name: `Poista suodatusehto: ${publisherName}`, + }); + await user.click(deleteFilterButton); + + expect(history.location.pathname).toBe('/fi/search'); + expect(history.location.search).toBe(''); +}); + test('should render and remove date filter', async () => { const user = userEvent.setup(); const { history } = renderComponent( @@ -84,7 +103,7 @@ test('should render and remove type filter', async () => { test('should remove all filters with clear button', async () => { const user = userEvent.setup(); const { history } = renderComponent( - `/fi${ROUTES.SEARCH}?text=${text}&place=${placeId}&end=${end}&start=${start}&type=${type}` + `/fi${ROUTES.SEARCH}?text=${text}&place=${placeId}&publisher=${publisherId}&end=${end}&start=${start}&type=${type}` ); screen.getByRole('button', { @@ -93,9 +112,13 @@ test('should remove all filters with clear button', async () => { await screen.findByRole('button', { name: `Poista suodatusehto: ${placeName}`, }); + await screen.findByRole('button', { + name: `Poista suodatusehto: ${publisherName}`, + }); screen.getByRole('button', { name: `Poista suodatusehto: 5.10.2021 - 13.10.2021`, }); + screen.getByRole('button', { name: `Poista suodatusehto: Tapahtuma`, }); diff --git a/src/domain/events/types.ts b/src/domain/events/types.ts index ddebd0c83..8dc17efa0 100644 --- a/src/domain/events/types.ts +++ b/src/domain/events/types.ts @@ -42,6 +42,7 @@ export type EventSearchInitialValues = { end: Date | null; page: number; places: string[]; + publisher: string[]; [EVENT_SEARCH_PARAMS.SORT]: EVENT_SORT_OPTIONS; start: Date | null; text: string; @@ -52,6 +53,7 @@ export type EventSearchParams = { [EVENT_SEARCH_PARAMS.END]?: Date | null; [EVENT_SEARCH_PARAMS.PAGE]?: number | null; [EVENT_SEARCH_PARAMS.PLACE]?: string[]; + [EVENT_SEARCH_PARAMS.PUBLISHER]?: string[]; [EVENT_SEARCH_PARAMS.RETURN_PATH]?: string | null; [EVENT_SEARCH_PARAMS.SORT]?: EVENT_SORT_OPTIONS | null; [EVENT_SEARCH_PARAMS.START]?: Date | null; diff --git a/src/domain/events/utils.ts b/src/domain/events/utils.ts index 101afc514..2a91758f2 100644 --- a/src/domain/events/utils.ts +++ b/src/domain/events/utils.ts @@ -190,6 +190,7 @@ export const getEventSearchInitialValues = ( const end = searchParams.get(EVENT_SEARCH_PARAMS.END); const page = searchParams.get(EVENT_SEARCH_PARAMS.PAGE); const places = searchParams.getAll(EVENT_SEARCH_PARAMS.PLACE); + const publisher = searchParams.getAll(EVENT_SEARCH_PARAMS.PUBLISHER); const sort = searchParams.get(EVENT_SEARCH_PARAMS.SORT) as EVENT_SORT_OPTIONS; const start = searchParams.get(EVENT_SEARCH_PARAMS.START); const text = searchParams.get(EVENT_SEARCH_PARAMS.TEXT); @@ -199,6 +200,7 @@ export const getEventSearchInitialValues = ( end: end && isValid(new Date(end)) ? new Date(end) : null, page: Number(page) || 1, places, + publisher, sort: Object.values(EVENT_SORT_OPTIONS).includes(sort) ? sort : DEFAULT_EVENT_SORT, @@ -224,6 +226,7 @@ export const getEventsQueryVariables = ( const end = searchParams.get(EVENT_SEARCH_PARAMS.END); const places = searchParams.getAll(EVENT_SEARCH_PARAMS.PLACE); + const publisher = searchParams.getAll(EVENT_SEARCH_PARAMS.PUBLISHER); const { page, sort, text, types } = getEventSearchInitialValues(search); @@ -235,6 +238,7 @@ export const getEventsQueryVariables = ( location: places, page, pageSize: EVENTS_PAGE_SIZE, + publisher, sort, start, text, @@ -358,6 +362,7 @@ export const getEventParamValue = ({ return formatDate(new Date(value), DATE_FORMAT_API); case EVENT_SEARCH_PARAMS.PAGE: case EVENT_SEARCH_PARAMS.PLACE: + case EVENT_SEARCH_PARAMS.PUBLISHER: case EVENT_SEARCH_PARAMS.SORT: case EVENT_SEARCH_PARAMS.TEXT: case EVENT_SEARCH_PARAMS.TYPE: diff --git a/src/domain/organization/hooks/useOrganizationOptions.ts b/src/domain/organization/hooks/useOrganizationOptions.ts new file mode 100644 index 000000000..2d1abbb93 --- /dev/null +++ b/src/domain/organization/hooks/useOrganizationOptions.ts @@ -0,0 +1,32 @@ +import sortBy from 'lodash/sortBy'; +import { useTranslation } from 'react-i18next'; + +import useLocale from '../../../hooks/useLocale'; +import { OptionType } from '../../../types'; +import { getOrganizationOption } from '../utils'; +import useAllOrganizations from './useAllOrganizations'; + +export type useOrganizationOptionsState = { + loading: boolean; + options: OptionType[]; +}; + +const useOrganizationOptions = ( + idPath: 'atId' | 'id' = 'id' +): useOrganizationOptionsState => { + const locale = useLocale(); + const { loading, organizations } = useAllOrganizations(); + const { t } = useTranslation(); + + return { + loading, + options: sortBy( + organizations.map((o) => + getOrganizationOption({ idPath, locale, organization: o, t }) + ), + 'label' + ), + }; +}; + +export default useOrganizationOptions; diff --git a/src/domain/organization/utils.ts b/src/domain/organization/utils.ts index 8fa62ac34..432a20f3e 100644 --- a/src/domain/organization/utils.ts +++ b/src/domain/organization/utils.ts @@ -19,7 +19,12 @@ import { WebStoreAccountFieldsFragment, WebStoreMerchantFieldsFragment, } from '../../generated/graphql'; -import { Editability, Language, PathBuilderProps } from '../../types'; +import { + Editability, + Language, + OptionType, + PathBuilderProps, +} from '../../types'; import { featureFlagUtils } from '../../utils/featureFlags'; import formatDate from '../../utils/formatDate'; import getDateFromString from '../../utils/getDateFromString'; @@ -633,3 +638,19 @@ export const omitSensitiveDataFromOrganizationPayload = ( 'registrationAdminUsers', 'regularUsers', ]); + +export const getOrganizationOption = ({ + idPath = 'id', + locale, + organization, + t, +}: { + idPath: 'atId' | 'id'; + locale: Language; + organization: OrganizationFieldsFragment; + t: TFunction; +}): OptionType => { + const { fullName: label } = getOrganizationFields(organization, locale, t); + + return { label, value: getValue(organization[idPath], '') }; +}; diff --git a/src/domain/organizations/__mocks__/organizationsPage.ts b/src/domain/organizations/__mocks__/organizationsPage.ts index 818d931d3..d09ba2211 100644 --- a/src/domain/organizations/__mocks__/organizationsPage.ts +++ b/src/domain/organizations/__mocks__/organizationsPage.ts @@ -44,4 +44,4 @@ const mockedOrganizationsResponse = { result: organizationsResponse, }; -export { mockedOrganizationsResponse, organizations }; +export { mockedOrganizationsResponse, organizations, organizationsOverrides }; diff --git a/src/types.ts b/src/types.ts index a009cb02e..53818a8d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,7 +43,7 @@ export type ReturnParams = { remainingQueryString: string; }; -export type FilterType = 'date' | 'eventType' | 'place' | 'text'; +export type FilterType = 'date' | 'eventType' | 'place' | 'publisher' | 'text'; export type FalsyType = false | null | undefined | '' | 0;