diff --git a/cypress/datasets/stopRegistry.ts b/cypress/datasets/stopRegistry.ts index f7e55b111..68f50d573 100644 --- a/cypress/datasets/stopRegistry.ts +++ b/cypress/datasets/stopRegistry.ts @@ -5,6 +5,7 @@ import { StopRegistryNameType, } from '@hsl/jore4-test-db-manager'; import cloneDeep from 'lodash/cloneDeep'; +import { DateTime } from 'luxon'; import { stopCoordinatesByLabel } from './base'; const coordinatesToStopRegistryGeoJSON = ( @@ -134,9 +135,41 @@ const stopPlaceData: Array = [ }, ]; +export const stopAreaX0003 = { + memberLabels: ['E2E001', 'E2E009'], + stopArea: { + name: { lang: 'fin', value: 'X0003' }, + description: { lang: 'fin', value: 'Annankatu 15' }, + validBetween: { + fromDate: DateTime.fromISO('2020-01-01T00:00:00.001'), + toDate: DateTime.fromISO('2050-01-01T00:00:00.001'), + }, + geometry: { + coordinates: [24.938927, 60.165433], + type: StopRegistryGeoJsonType.Point, + }, + }, +}; + +export const stopAreaX0004 = { + memberLabels: ['E2E003', 'E2E006'], + stopArea: { + name: { lang: 'fin', value: 'X0004' }, + description: { lang: 'fin', value: 'Kalevankatu 32' }, + validBetween: { + fromDate: DateTime.fromISO('2020-01-01T00:00:00.001'), + toDate: DateTime.fromISO('2050-01-01T00:00:00.001'), + }, + geometry: { + coordinates: [24.932914978884, 60.165538996581], + type: StopRegistryGeoJsonType.Point, + }, + }, +}; + const baseStopRegistryData = { organisations: [], - stopAreas: [], + stopAreas: [stopAreaX0003, stopAreaX0004], stopPlaces: stopPlaceData, }; diff --git a/cypress/e2e/stop-registry/stopAreaDetails.cy.ts b/cypress/e2e/stop-registry/stopAreaDetails.cy.ts index 8eb870aa4..eb958e911 100644 --- a/cypress/e2e/stop-registry/stopAreaDetails.cy.ts +++ b/cypress/e2e/stop-registry/stopAreaDetails.cy.ts @@ -1,7 +1,3 @@ -import { - StopAreaInput, - StopRegistryGeoJsonType, -} from '@hsl/jore4-test-db-manager'; import { DateTime } from 'luxon'; import { buildInfraLinksAlongRoute, @@ -9,7 +5,10 @@ import { getClonedBaseDbResources, testInfraLinkExternalIds, } from '../../datasets/base'; -import { getClonedBaseStopRegistryData } from '../../datasets/stopRegistry'; +import { + getClonedBaseStopRegistryData, + stopAreaX0003, +} from '../../datasets/stopRegistry'; import { SelectMemberStopsDropdown, StopAreaDetailsPage, @@ -49,23 +48,7 @@ describe('Stop area details', () => { const baseDbResources = getClonedBaseDbResources(); const baseStopRegistryData = getClonedBaseStopRegistryData(); - const testStopArea = { - memberLabels: ['E2E001', 'E2E009'], - stopArea: { - name: { lang: 'fin', value: 'X0003' }, - description: { lang: 'fin', value: 'Annankatu 15' }, - validBetween: { - fromDate: DateTime.fromISO('2020-01-01T00:00:00.001'), - toDate: DateTime.fromISO('2050-01-01T00:00:00.001'), - }, - geometry: { - coordinates: [24.938927, 60.165433], - type: StopRegistryGeoJsonType.Point, - }, - }, - }; - - const stopAreaData: Array = [testStopArea]; + const testStopArea = { ...stopAreaX0003 }; const testAreaExpectedBasicDetails: ExpectedBasicDetails = { name: testStopArea.stopArea.name.value, @@ -100,10 +83,10 @@ describe('Stop area details', () => { insertToDbHelper(dbResources); - cy.task('insertStopRegistryData', { - ...baseStopRegistryData, - stopAreas: stopAreaData, - }).then((data) => { + cy.task( + 'insertStopRegistryData', + baseStopRegistryData, + ).then((data) => { const id = data.stopAreaIdsByName.X0003; cy.setupTests(); diff --git a/cypress/e2e/stop-registry/stopSearch.cy.ts b/cypress/e2e/stop-registry/stopSearch.cy.ts index 1f9611846..c99ca2d66 100644 --- a/cypress/e2e/stop-registry/stopSearch.cy.ts +++ b/cypress/e2e/stop-registry/stopSearch.cy.ts @@ -11,6 +11,7 @@ import { import { getClonedBaseStopRegistryData } from '../../datasets/stopRegistry'; import { Tag } from '../../enums'; import { + StopGroupSelector, StopSearchBar, StopSearchByLine, StopSearchResultsPage, @@ -26,6 +27,8 @@ describe('Stop search', () => { const stopSearchBar = new StopSearchBar(); const stopSearchResultsPage = new StopSearchResultsPage(); const stopSearchByLine = new StopSearchByLine(); + const stopGroupSelector = new StopGroupSelector(); + let dbResources: SupportedResources; before(() => { @@ -442,25 +445,16 @@ describe('Stop search', () => { }); } - function shouldHaveLines(lines: ReadonlyArray) { - const shouldHaveLength = stopSearchByLine - .getLineSelectors() - .should('have.length', lines.length); - - lines.reduce( - (should, line) => should.and('contain', line), - shouldHaveLength, - ); - } - function assertShowsAllResultsByDefault() { stopSearchBar.getSearchInput().clearAndType(`LE*{enter}`); expectGraphQLCallToSucceed('@gqlfindLinesByStopSearch'); // Should contain and show all LE -lines - shouldHaveLines(range(SHOW_ALL_BY_DEFAULT_MAX).map((i) => `LE${i}`)); - stopSearchByLine.getShowAllLinesButton().should('not.exist'); - stopSearchByLine.getShowLessLinesButton().should('not.exist'); + stopGroupSelector.shouldHaveGroups( + range(SHOW_ALL_BY_DEFAULT_MAX).map((i) => `LE${i}`), + ); + stopGroupSelector.getShowAllGroupsButton().should('not.exist'); + stopGroupSelector.getShowLessGroupsButton().should('not.exist'); } function assertShowAllAndShowLessWork( @@ -473,26 +467,27 @@ describe('Stop search', () => { // the list of shown labels. cy.viewport(1000, 1080); // prettier-ignore - shouldHaveLines(['L20', 'L21', 'L22', 'L23', 'L24', 'L25', 'L26', 'L27', 'L28', 'L29']); + stopGroupSelector + .shouldHaveGroups(['L20', 'L21', 'L22', 'L23', 'L24', 'L25', 'L26', 'L27', 'L28', 'L29']); cy.viewport(500, 1080); // prettier-ignore const minimalResult = ['L20', 'L21', 'L22', 'L23', 'L24']; - shouldHaveLines(minimalResult); + stopGroupSelector.shouldHaveGroups(minimalResult); - stopSearchByLine.getShowLessLinesButton().should('not.exist'); - stopSearchByLine - .getShowAllLinesButton() + stopGroupSelector.getShowLessGroupsButton().should('not.exist'); + stopGroupSelector + .getShowAllGroupsButton() .should('contain', `Näytä kaikki (${SHOW_ALL_BY_DEFAULT_MAX * 2})`) .click(); - stopSearchByLine.getShowLessLinesButton().shouldBeVisible(); - stopSearchByLine.getShowAllLinesButton().should('not.exist'); - shouldHaveLines(allExtraLines); + stopGroupSelector.getShowLessGroupsButton().shouldBeVisible(); + stopGroupSelector.getShowAllGroupsButton().should('not.exist'); + stopGroupSelector.shouldHaveGroups(allExtraLines); - stopSearchByLine.getShowLessLinesButton().click(); + stopGroupSelector.getShowLessGroupsButton().click(); - shouldHaveLines(minimalResult); - stopSearchByLine.getShowAllLinesButton().shouldBeVisible(); + stopGroupSelector.shouldHaveGroups(minimalResult); + stopGroupSelector.getShowAllGroupsButton().shouldBeVisible(); } it('should have a working asterisk search and line selector', () => { diff --git a/cypress/pageObjects/stop-registry/StopGroupSelector.ts b/cypress/pageObjects/stop-registry/StopGroupSelector.ts new file mode 100644 index 000000000..2c9400782 --- /dev/null +++ b/cypress/pageObjects/stop-registry/StopGroupSelector.ts @@ -0,0 +1,25 @@ +export class StopGroupSelector { + getGroupSelectors() { + return cy.get('[data-group-id][data-visible="true"]'); + } + + getShowAllGroupsButton() { + return cy.getByTestId('StopGroupSelector::showAllButton'); + } + + getShowLessGroupsButton() { + return cy.getByTestId('StopGroupSelector::showLessButton'); + } + + shouldHaveGroups(groups: ReadonlyArray) { + const shouldHaveLength = this.getGroupSelectors().should( + 'have.length', + groups.length, + ); + + groups.reduce( + (should, group) => should.and('contain', group), + shouldHaveLength, + ); + } +} diff --git a/cypress/pageObjects/stop-registry/StopSearchByLine.ts b/cypress/pageObjects/stop-registry/StopSearchByLine.ts index b93414004..a8e82870c 100644 --- a/cypress/pageObjects/stop-registry/StopSearchByLine.ts +++ b/cypress/pageObjects/stop-registry/StopSearchByLine.ts @@ -1,16 +1,4 @@ export class StopSearchByLine { - getLineSelectors() { - return cy.get('[data-line-id][data-visible="true"]'); - } - - getShowAllLinesButton() { - return cy.getByTestId('StopSearchByLine::line::showAllButton'); - } - - getShowLessLinesButton() { - return cy.getByTestId('StopSearchByLine::line::showLessButton'); - } - getActiveLineName() { return cy.getByTestId('StopSearchByLine::line::name'); } diff --git a/cypress/pageObjects/stop-registry/index.ts b/cypress/pageObjects/stop-registry/index.ts index 86355feea..1f72720ae 100644 --- a/cypress/pageObjects/stop-registry/index.ts +++ b/cypress/pageObjects/stop-registry/index.ts @@ -1,6 +1,7 @@ export * from './StopAreaDetailsPage'; export * from './stop-details'; export * from './StopDetailsPage'; +export * from './StopGroupSelector'; export * from './StopSearchBar'; export * from './StopSearchByLine'; export * from './StopSearchResultsPage'; diff --git a/ui/src/components/stop-registry/search/StopSearchResultsPage.tsx b/ui/src/components/stop-registry/search/StopSearchResultsPage.tsx index 103fc652f..482aded6f 100644 --- a/ui/src/components/stop-registry/search/StopSearchResultsPage.tsx +++ b/ui/src/components/stop-registry/search/StopSearchResultsPage.tsx @@ -6,7 +6,7 @@ import { resetSelectedRowsAction } from '../../../redux'; import { Path } from '../../../router/routeDetails'; import { StopsByLineSearchResults } from './by-line'; import { StopSearchByStopResults } from './by-stop'; -import { StopSearchBar } from './StopSearchBar'; +import { StopSearchBar } from './components'; import { SearchBy, StopSearchFilters } from './types'; import { useStopSearchUrlState } from './utils'; diff --git a/ui/src/components/stop-registry/search/by-line/LineSelector.tsx b/ui/src/components/stop-registry/search/by-line/LineSelector.tsx index 034894043..7d75fa37b 100644 --- a/ui/src/components/stop-registry/search/by-line/LineSelector.tsx +++ b/ui/src/components/stop-registry/search/by-line/LineSelector.tsx @@ -1,17 +1,7 @@ -import { RadioGroup } from '@headlessui/react'; -import { FC, useEffect, useRef, useState } from 'react'; +import { FC, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { twJoin, twMerge } from 'tailwind-merge'; -import { Visible } from '../../../../layoutComponents'; +import { StopGroupSelector } from '../components'; import { FindStopByLineInfo } from './useFindLinesByStopSearch'; -import { useVisibilityMap } from './useVisibilityMap'; - -const testIds = { - lineSelector: (lineId: UUID) => - `StopSearchByLine::line::lineSelector::${lineId}`, - showAll: `StopSearchByLine::line::showAllButton`, - showLess: `StopSearchByLine::line::showLessButton`, -}; type LineSelectorProps = { readonly activeLineId: UUID | null; @@ -20,10 +10,6 @@ type LineSelectorProps = { readonly setActiveLineId: (activeLineId: UUID | null) => void; }; -const SHOW_ALL_BY_DEFAULT_MAX = 20; -const MAX_PADDING = 5; -const NO_BREAK_SPACE = '\xa0'; - export const LineSelector: FC = ({ activeLineId, className, @@ -32,89 +18,23 @@ export const LineSelector: FC = ({ }) => { const { t } = useTranslation(); - const showAllByDefault = lines.length <= SHOW_ALL_BY_DEFAULT_MAX; - const [showAll, setShowAll] = useState(showAllByDefault); - useEffect(() => setShowAll(showAllByDefault), [showAllByDefault]); - - const lineListRef = useRef(null); - const visibilityMap = useVisibilityMap(showAll, lines, lineListRef); - - const someLineIsHidden = Object.values(visibilityMap).some( - (visible) => !visible, - ); - - const longestLabel = Math.min( - Math.max(...lines.map((line) => line.label.length)), - MAX_PADDING, + const groups = useMemo( + () => + lines.map(({ line_id: id, label, name_i18n: { fi_FI: title = '' } }) => ({ + id, + label, + title, + })), + [lines], ); return ( - - - {t('stopRegistrySearch.lines')} - - -
- {lines.map( - ({ line_id: lineId, label, name_i18n: { fi_FI: title } }) => ( - - {label.padEnd( - showAll && !showAllByDefault ? longestLabel : 0, - NO_BREAK_SPACE, - )} - - ), - )} - - {/* Hide button is nested in here to render the button as a last element in the list. */} - - - -
- - {/* Show more button is outside the list as to exclude from the overflow calculations. */} - - - -
+ ); }; diff --git a/ui/src/components/stop-registry/search/by-line/RouteStopsTable.tsx b/ui/src/components/stop-registry/search/by-line/RouteStopsTable.tsx index 5257f504f..23d6a779e 100644 --- a/ui/src/components/stop-registry/search/by-line/RouteStopsTable.tsx +++ b/ui/src/components/stop-registry/search/by-line/RouteStopsTable.tsx @@ -1,11 +1,10 @@ import React, { FC } from 'react'; import { Visible } from '../../../../layoutComponents'; +import { LoadingStopsErrorRow, LoadingStopsRow } from '../components'; import { StopTableRow } from '../StopTableRow'; import { LocatorActionButton } from '../StopTableRow/ActionButtons/LocatorActionButton'; import { OpenDetailsPage } from '../StopTableRow/MenuItems/OpenDetailsPage'; import { ShowOnMap } from '../StopTableRow/MenuItems/ShowOnMap'; -import { LoadingStopsErrorRow } from './LoadingStopsErrorRow'; -import { LoadingStopsRow } from './LoadingStopsRow'; import { RouteInfoRow } from './RouteInfoRow'; import { FindStopByLineRouteInfo } from './useFindLinesByStopSearch'; import { useGetStopResultsByRouteId } from './useGetStopResultsByRouteId'; diff --git a/ui/src/components/stop-registry/search/by-line/useFindLinesByStopSearch.ts b/ui/src/components/stop-registry/search/by-line/useFindLinesByStopSearch.ts index 958ec6cb6..451ecaad3 100644 --- a/ui/src/components/stop-registry/search/by-line/useFindLinesByStopSearch.ts +++ b/ui/src/components/stop-registry/search/by-line/useFindLinesByStopSearch.ts @@ -1,12 +1,13 @@ import { gql } from '@apollo/client'; import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; import { FindStopByLineInfoFragment, FindStopByLineRouteInfoFragment, useFindLinesByStopSearchQuery, } from '../../../../generated/graphql'; +import { mapToSqlLikeValue } from '../../../../utils'; import { StopSearchFilters } from '../types'; +import { useNumericSortingCollator } from '../utils'; export type FindStopByLineInfo = FindStopByLineInfoFragment; export type FindStopByLineRouteInfo = FindStopByLineRouteInfoFragment; @@ -71,31 +72,12 @@ const GQL_FIND_LINES_BY_STOP_SEARCH_QUERY = gql` } `; -function searchKeyToQuery(searchKey: string): string { - return searchKey.replace(/\*/g, '%'); -} - -/** - * Allows sorting the labels so that letters come before numbers. - * 3Y -> Line 3 variant Y - * 30 → Line 30 > 3 - * 200 → Line 200 > 3 & 30 - */ -function useNumericSortingCollator() { - const { i18n } = useTranslation(); - - return useMemo( - () => new Intl.Collator(i18n.language, { numeric: true }), - [i18n.language], - ); -} - export function useFindLinesByStopSearch(filters: StopSearchFilters) { const labelSortCollator = useNumericSortingCollator(); const { data, ...rest } = useFindLinesByStopSearchQuery({ variables: { - query: searchKeyToQuery(filters.query), + query: mapToSqlLikeValue(filters.query), validOn: filters.observationDate, }, skip: filters.query.trim() === '', diff --git a/ui/src/components/stop-registry/search/by-stop/useStopSearchResults.ts b/ui/src/components/stop-registry/search/by-stop/useStopSearchResults.ts index 90be5e4b1..ca6100ebe 100644 --- a/ui/src/components/stop-registry/search/by-stop/useStopSearchResults.ts +++ b/ui/src/components/stop-registry/search/by-stop/useStopSearchResults.ts @@ -1,16 +1,8 @@ import { gql } from '@apollo/client'; import { useMemo } from 'react'; -import { - SearchStopsQuery, - StopTableRowFragment, - StopTableRowStopPlaceFragment, - useSearchStopsQuery, -} from '../../../../generated/graphql'; -import { - StopSearchFilters, - StopSearchRow, - hasMeaningfulFilters, -} from '../types'; +import { useSearchStopsQuery } from '../../../../generated/graphql'; +import { StopSearchFilters, hasMeaningfulFilters } from '../types'; +import { mapQueryResultToStopSearchRows } from '../utils'; import { buildSearchStopsGqlQueryVariables } from './filtersToQueryVariables'; const GQL_STOP_TABLE_ROW = gql` @@ -58,31 +50,6 @@ const GQL_SEARCH_STOPS = gql` } `; -const mapResultRowToStopSearchRow = ( - stopPlace: StopTableRowStopPlaceFragment, -) => { - return { - ...(stopPlace.scheduled_stop_point_instance as StopTableRowFragment), - stop_place: { - netexId: stopPlace.netex_id, - nameFin: stopPlace.name_value, - nameSwe: stopPlace.stop_place_alternative_names.find( - (alternativeName) => - alternativeName.alternative_name.name_lang === 'swe' && - alternativeName.alternative_name.name_type === 'TRANSLATION', - )?.alternative_name.name_value, - }, - }; -}; - -const mapQueryResultToStopSearchRows = ( - data: SearchStopsQuery, -): StopSearchRow[] => - data.stops_database?.stops_database_stop_place_newest_version - // Filter out stops which do not have a matching stop in routes and lines - .filter((stop) => !!stop.scheduled_stop_point_instance) - .map(mapResultRowToStopSearchRow) ?? []; - export const useStopSearchResults = (filters: StopSearchFilters) => { const stopFilter = buildSearchStopsGqlQueryVariables(filters); diff --git a/ui/src/components/stop-registry/search/by-line/LoadingStopsErrorRow.tsx b/ui/src/components/stop-registry/search/components/LoadingStopsErrorRow.tsx similarity index 87% rename from ui/src/components/stop-registry/search/by-line/LoadingStopsErrorRow.tsx rename to ui/src/components/stop-registry/search/components/LoadingStopsErrorRow.tsx index 1c3b15fcc..ba2205f1f 100644 --- a/ui/src/components/stop-registry/search/by-line/LoadingStopsErrorRow.tsx +++ b/ui/src/components/stop-registry/search/components/LoadingStopsErrorRow.tsx @@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'; import { SlimSimpleButton } from '../../stops/stop-details/layout'; const testIds = { - reason: 'StopSearchByLine::route::failedToLoadReason', - refetch: 'StopSearchByLine::route::refetchStopsButton', + reason: 'StopSearch::GroupedStops::failedToLoadReason', + refetch: 'StopSearch::GroupedStops::refetchStopsButton', }; type LoadingStopsErrorRowProps = { diff --git a/ui/src/components/stop-registry/search/by-line/LoadingStopsRow.tsx b/ui/src/components/stop-registry/search/components/LoadingStopsRow.tsx similarity index 91% rename from ui/src/components/stop-registry/search/by-line/LoadingStopsRow.tsx rename to ui/src/components/stop-registry/search/components/LoadingStopsRow.tsx index ae66f23d6..5b9708fe8 100644 --- a/ui/src/components/stop-registry/search/by-line/LoadingStopsRow.tsx +++ b/ui/src/components/stop-registry/search/components/LoadingStopsRow.tsx @@ -3,7 +3,7 @@ import PulseLoader from 'react-spinners/PulseLoader'; import { theme } from '../../../../generated/theme'; const testIds = { - loader: 'StopSearchByLine::route::loader', + loader: 'StopSearch::GroupedStops::loader', }; export const LoadingStopsRow: FC = () => { diff --git a/ui/src/components/stop-registry/search/components/StopGroupSelector/StopGroupSelector.tsx b/ui/src/components/stop-registry/search/components/StopGroupSelector/StopGroupSelector.tsx new file mode 100644 index 000000000..1fc92d98c --- /dev/null +++ b/ui/src/components/stop-registry/search/components/StopGroupSelector/StopGroupSelector.tsx @@ -0,0 +1,118 @@ +import { RadioGroup } from '@headlessui/react'; +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { twJoin, twMerge } from 'tailwind-merge'; +import { Visible } from '../../../../../layoutComponents'; +import { StopGroupSelectorItem } from './StopGroupSelectorItem'; +import { useVisibilityMap } from './useVisibilityMap'; + +const testIds = { + groupSelector: (id: UUID) => `StopGroupSelector::group::${id}`, + showAll: `StopGroupSelector::showAllButton`, + showLess: `StopGroupSelector::showLessButton`, +}; + +type StopGroupSelectorProps = { + readonly className?: string; + readonly groups: ReadonlyArray>; + readonly label: ReactNode; + readonly onSelect: (selected: UUID | null) => void; + readonly selected: ID | null; +}; + +const SHOW_ALL_BY_DEFAULT_MAX = 20; +const MAX_PADDING = 5; +const NO_BREAK_SPACE = '\xa0'; + +export const StopGroupSelector = ({ + className, + groups, + label: radioGroupLabel, + onSelect, + selected, +}: StopGroupSelectorProps) => { + const { t } = useTranslation(); + + const showAllByDefault = groups.length <= SHOW_ALL_BY_DEFAULT_MAX; + const [showAll, setShowAll] = useState(showAllByDefault); + useEffect(() => setShowAll(showAllByDefault), [showAllByDefault]); + + const groupListRef = useRef(null); + const groupIds = useMemo(() => groups.map((group) => group.id), [groups]); + const visibilityMap = useVisibilityMap(showAll, groupIds, groupListRef); + + const someGroupIsHidden = Object.values(visibilityMap).some( + (visible) => !visible, + ); + + const longestLabel = Math.min( + Math.max(...groups.map((group) => group.label.length)), + MAX_PADDING, + ); + + return ( + + {radioGroupLabel} + +
+ {groups.map(({ id, label, title }) => ( + + {label.padEnd( + showAll && !showAllByDefault ? longestLabel : 0, + NO_BREAK_SPACE, + )} + + ))} + + {/* Hide button is nested in here to render the button as a last element in the list. */} + + + +
+ + {/* Show more button is outside the list as to exclude from the overflow calculations. */} + + + +
+ ); +}; diff --git a/ui/src/components/stop-registry/search/components/StopGroupSelector/StopGroupSelectorItem.ts b/ui/src/components/stop-registry/search/components/StopGroupSelector/StopGroupSelectorItem.ts new file mode 100644 index 000000000..8ec58946b --- /dev/null +++ b/ui/src/components/stop-registry/search/components/StopGroupSelector/StopGroupSelectorItem.ts @@ -0,0 +1,5 @@ +export type StopGroupSelectorItem = { + readonly id: ID; + readonly label: string; + readonly title: string; +}; diff --git a/ui/src/components/stop-registry/search/components/StopGroupSelector/index.ts b/ui/src/components/stop-registry/search/components/StopGroupSelector/index.ts new file mode 100644 index 000000000..0a08867bb --- /dev/null +++ b/ui/src/components/stop-registry/search/components/StopGroupSelector/index.ts @@ -0,0 +1,2 @@ +export * from './StopGroupSelector'; +export * from './StopGroupSelectorItem'; diff --git a/ui/src/components/stop-registry/search/by-line/useVisibilityMap.tsx b/ui/src/components/stop-registry/search/components/StopGroupSelector/useVisibilityMap.tsx similarity index 59% rename from ui/src/components/stop-registry/search/by-line/useVisibilityMap.tsx rename to ui/src/components/stop-registry/search/components/StopGroupSelector/useVisibilityMap.tsx index 716bd2b8d..ed5ae86d0 100644 --- a/ui/src/components/stop-registry/search/by-line/useVisibilityMap.tsx +++ b/ui/src/components/stop-registry/search/components/StopGroupSelector/useVisibilityMap.tsx @@ -1,14 +1,11 @@ import noop from 'lodash/noop'; import { RefObject, useEffect, useState } from 'react'; -import { FindStopByLineInfo } from './useFindLinesByStopSearch'; type VisibilityMap = Readonly>; // Maps lines to { [lineId]: true } object. -function getAllShownMap( - lines: ReadonlyArray, -): VisibilityMap { - const entries = lines.map((line) => [line.line_id, true]); +function getAllShownMap(groups: ReadonlyArray): VisibilityMap { + const entries = groups.map((id) => [id, true]); return Object.fromEntries(entries); } @@ -18,21 +15,21 @@ function getAllShownMap( * @see https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API * * @param allShown if true, the visibility map is disabled/not needed. - * @param lines list of all lines that are expected to be in the list. - * @param lineListRef ref to list container. + * @param groups list of all lines that are expected to be in the list. + * @param groupListRef ref to list container. */ -export function useVisibilityMap( +export function useVisibilityMap( allShown: boolean, - lines: ReadonlyArray, - lineListRef: RefObject, + groups: ReadonlyArray, + groupListRef: RefObject, ) { const [visibilityMap, setVisibilityMap] = useState(() => - getAllShownMap(lines), + getAllShownMap(groups), ); useEffect(() => { // Reset to default state - setVisibilityMap(getAllShownMap(lines)); + setVisibilityMap(getAllShownMap(groups)); if (allShown) { return noop; @@ -42,7 +39,7 @@ export function useVisibilityMap( (entries) => { const update = Object.fromEntries( entries.map((it) => [ - (it.target as HTMLElement).dataset.lineId ?? '', + (it.target as HTMLElement).dataset.groupId ?? '', it.isIntersecting, ]), ); @@ -50,25 +47,23 @@ export function useVisibilityMap( setVisibilityMap((p) => ({ ...p, ...update })); }, { - root: lineListRef.current, // Check the lines against the container + root: groupListRef.current, // Check the lines against the container threshold: 1, // 1 = if even 1 pixel overflows, mark the line as intersecting }, ); - const lineIds = lines.map((line) => line.line_id); - // Observe each line = child - Array.from(lineListRef.current?.children ?? []).forEach((child) => { + Array.from(groupListRef.current?.children ?? []).forEach((child) => { if ( child instanceof HTMLElement && - lineIds.includes(child.dataset.lineId ?? '') + groups.includes(child.dataset.groupId as ID) ) { observer.observe(child); } }); return () => observer.disconnect(); // Stop observing on cleanup - }, [allShown, lines, lineListRef]); + }, [allShown, groups, groupListRef]); return visibilityMap; } diff --git a/ui/src/components/stop-registry/search/StopSearchBar/MunicipalityFilter.tsx b/ui/src/components/stop-registry/search/components/StopSearchBar/MunicipalityFilter.tsx similarity index 87% rename from ui/src/components/stop-registry/search/StopSearchBar/MunicipalityFilter.tsx rename to ui/src/components/stop-registry/search/components/StopSearchBar/MunicipalityFilter.tsx index e72423320..5c6489587 100644 --- a/ui/src/components/stop-registry/search/StopSearchBar/MunicipalityFilter.tsx +++ b/ui/src/components/stop-registry/search/components/StopSearchBar/MunicipalityFilter.tsx @@ -4,14 +4,13 @@ import without from 'lodash/without'; import React, { FC, ReactNode } from 'react'; import { useController } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { i18n } from '../../../../i18n'; -import { Column } from '../../../../layoutComponents'; -import { StopRegistryMunicipality } from '../../../../types/enums'; -import { ListboxButton, dropdownTransition } from '../../../../uiComponents'; -import { AllOptionEnum, numberEnumEntries } from '../../../../utils'; -import { InputLabel, ValidationErrorList } from '../../../forms/common'; -import { StopSearchFilters } from '../types'; -import { handleAllMunicipalities } from '../utils'; +import { Column } from '../../../../../layoutComponents'; +import { StopRegistryMunicipality } from '../../../../../types/enums'; +import { ListboxButton, dropdownTransition } from '../../../../../uiComponents'; +import { AllOptionEnum, numberEnumEntries } from '../../../../../utils'; +import { InputLabel, ValidationErrorList } from '../../../../forms/common'; +import { StopSearchFilters } from '../../types'; +import { handleAllMunicipalities } from '../../utils'; const testIds = { municipalitiesDropdown: 'StopSearchBar::municipalitiesDropdown', @@ -98,7 +97,7 @@ export const MunicipalityFilter: FC = ({ className="group flex border-b border-grey px-2 py-2 text-left ui-selected:bg-dark-grey ui-selected:text-white ui-active:bg-dark-grey ui-active:text-white" value={AllOptionEnum.All} > - {i18n.t('all')} + {t('all')} {numberEnumEntries(StopRegistryMunicipality).map( diff --git a/ui/src/components/stop-registry/search/StopSearchBar/SearchCriteriaRadioButtons.tsx b/ui/src/components/stop-registry/search/components/StopSearchBar/SearchCriteriaRadioButtons.tsx similarity index 87% rename from ui/src/components/stop-registry/search/StopSearchBar/SearchCriteriaRadioButtons.tsx rename to ui/src/components/stop-registry/search/components/StopSearchBar/SearchCriteriaRadioButtons.tsx index 78ff792be..0a9b0d76d 100644 --- a/ui/src/components/stop-registry/search/StopSearchBar/SearchCriteriaRadioButtons.tsx +++ b/ui/src/components/stop-registry/search/components/StopSearchBar/SearchCriteriaRadioButtons.tsx @@ -2,7 +2,7 @@ import { FC, useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { twMerge } from 'tailwind-merge'; -import { SearchBy, StopSearchFilters } from '../types'; +import { SearchBy, SearchFor, StopSearchFilters } from '../../types'; function useOptions() { const { t } = useTranslation(); @@ -33,7 +33,8 @@ type SearchCriteriaRadioButtonsProps = { export const SearchCriteriaRadioButtons: FC< SearchCriteriaRadioButtonsProps > = ({ className }) => { - const { register } = useFormContext(); + const { register, watch } = useFormContext(); + const disabled = watch('searchFor') !== SearchFor.Stops; const searchByOptions = useOptions(); @@ -51,6 +52,7 @@ export const SearchCriteriaRadioButtons: FC< id={option.name} data-testid={`SearchCriteriaRadioButtons::${option.name}`} value={option.name} + disabled={disabled} {...register('searchBy')} /> {option.label} diff --git a/ui/src/components/stop-registry/search/StopSearchBar/SearchForDropdown.tsx b/ui/src/components/stop-registry/search/components/StopSearchBar/SearchForDropdown.tsx similarity index 92% rename from ui/src/components/stop-registry/search/StopSearchBar/SearchForDropdown.tsx rename to ui/src/components/stop-registry/search/components/StopSearchBar/SearchForDropdown.tsx index 77c5c7b7f..49f35d534 100644 --- a/ui/src/components/stop-registry/search/StopSearchBar/SearchForDropdown.tsx +++ b/ui/src/components/stop-registry/search/components/StopSearchBar/SearchForDropdown.tsx @@ -4,9 +4,9 @@ import React, { FC, ReactNode } from 'react'; import { useController } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { twMerge } from 'tailwind-merge'; -import { ListboxButton, dropdownTransition } from '../../../../uiComponents'; -import { InputLabel } from '../../../forms/common'; -import { SearchFor, StopSearchFilters } from '../types'; +import { ListboxButton, dropdownTransition } from '../../../../../uiComponents'; +import { InputLabel } from '../../../../forms/common'; +import { SearchFor, StopSearchFilters } from '../../types'; const disabled: ReadonlyArray = [ SearchFor.StopAreas, diff --git a/ui/src/components/stop-registry/search/StopSearchBar/StopSearchBar.tsx b/ui/src/components/stop-registry/search/components/StopSearchBar/StopSearchBar.tsx similarity index 91% rename from ui/src/components/stop-registry/search/StopSearchBar/StopSearchBar.tsx rename to ui/src/components/stop-registry/search/components/StopSearchBar/StopSearchBar.tsx index 6744e6f7a..693f28cab 100644 --- a/ui/src/components/stop-registry/search/StopSearchBar/StopSearchBar.tsx +++ b/ui/src/components/stop-registry/search/components/StopSearchBar/StopSearchBar.tsx @@ -1,11 +1,11 @@ import React, { FC, useEffect, useRef } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useToggle } from '../../../../hooks'; -import { Column, Row, Visible } from '../../../../layoutComponents'; -import { ChevronToggle, SimpleButton } from '../../../../uiComponents'; -import { DateInputField, InputField } from '../../../forms/common'; -import { StopSearchFilters } from '../types'; +import { useToggle } from '../../../../../hooks'; +import { Column, Row, Visible } from '../../../../../layoutComponents'; +import { ChevronToggle, SimpleButton } from '../../../../../uiComponents'; +import { DateInputField, InputField } from '../../../../forms/common'; +import { SearchFor, StopSearchFilters } from '../../types'; import { MunicipalityFilter } from './MunicipalityFilter'; import { SearchCriteriaRadioButtons } from './SearchCriteriaRadioButtons'; import { SearchForDropdown } from './SearchForDropdown'; @@ -47,6 +47,8 @@ export const StopSearchBar: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchBy]); + const notForStops = methods.watch('searchFor') !== SearchFor.Stops; + return ( // eslint-disable-next-line react/jsx-props-no-spreading @@ -96,6 +98,7 @@ export const StopSearchBar: FC = ({ className="w-1/6" inputClassName="flex-grow" + disabled={notForStops} fieldPath="elyNumber" translationPrefix="stopRegistrySearch.fieldLabels" testId={testIds.elyInput} diff --git a/ui/src/components/stop-registry/search/StopSearchBar/index.ts b/ui/src/components/stop-registry/search/components/StopSearchBar/index.ts similarity index 100% rename from ui/src/components/stop-registry/search/StopSearchBar/index.ts rename to ui/src/components/stop-registry/search/components/StopSearchBar/index.ts diff --git a/ui/src/components/stop-registry/search/components/index.ts b/ui/src/components/stop-registry/search/components/index.ts new file mode 100644 index 000000000..4963938e6 --- /dev/null +++ b/ui/src/components/stop-registry/search/components/index.ts @@ -0,0 +1,4 @@ +export * from './LoadingStopsErrorRow'; +export * from './LoadingStopsRow'; +export * from './StopGroupSelector'; +export * from './StopSearchBar'; diff --git a/ui/src/components/stop-registry/search/index.ts b/ui/src/components/stop-registry/search/index.ts index 448b88af7..e6f56450c 100644 --- a/ui/src/components/stop-registry/search/index.ts +++ b/ui/src/components/stop-registry/search/index.ts @@ -1,4 +1,4 @@ -export * from './StopSearchBar'; +export { StopSearchBar } from './components'; export * from './StopSearchResultsPage'; export * from './StopTableRow/StopTableRow'; export * from './types'; diff --git a/ui/src/components/stop-registry/search/utils/index.ts b/ui/src/components/stop-registry/search/utils/index.ts index 035a167d6..9b3c134b0 100644 --- a/ui/src/components/stop-registry/search/utils/index.ts +++ b/ui/src/components/stop-registry/search/utils/index.ts @@ -1,2 +1,4 @@ export * from './handleAllMunicipalities'; +export * from './resultMappers'; +export * from './useNumericSortingCollator'; export * from './useStopSearchUrlState'; diff --git a/ui/src/components/stop-registry/search/utils/resultMappers.ts b/ui/src/components/stop-registry/search/utils/resultMappers.ts new file mode 100644 index 000000000..2a2bf6c40 --- /dev/null +++ b/ui/src/components/stop-registry/search/utils/resultMappers.ts @@ -0,0 +1,31 @@ +import { + SearchStopsQuery, + StopTableRowFragment, + StopTableRowStopPlaceFragment, +} from '../../../../generated/graphql'; +import { StopSearchRow } from '../types'; + +const mapResultRowToStopSearchRow = ( + stopPlace: StopTableRowStopPlaceFragment, +) => { + return { + ...(stopPlace.scheduled_stop_point_instance as StopTableRowFragment), + stop_place: { + netexId: stopPlace.netex_id, + nameFin: stopPlace.name_value, + nameSwe: stopPlace.stop_place_alternative_names.find( + (alternativeName) => + alternativeName.alternative_name.name_lang === 'swe' && + alternativeName.alternative_name.name_type === 'TRANSLATION', + )?.alternative_name.name_value, + }, + }; +}; + +export const mapQueryResultToStopSearchRows = ( + data: SearchStopsQuery, +): StopSearchRow[] => + data.stops_database?.stops_database_stop_place_newest_version + // Filter out stops which do not have a matching stop in routes and lines + .filter((stop) => !!stop.scheduled_stop_point_instance) + .map(mapResultRowToStopSearchRow) ?? []; diff --git a/ui/src/components/stop-registry/search/utils/useNumericSortingCollator.ts b/ui/src/components/stop-registry/search/utils/useNumericSortingCollator.ts new file mode 100644 index 000000000..eacf3c27b --- /dev/null +++ b/ui/src/components/stop-registry/search/utils/useNumericSortingCollator.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +/** + * Allows sorting the labels so that letters come before numbers. + * 3Y -> Line 3 variant Y + * 30 → Line 30 > 3 + * 200 → Line 200 > 3 & 30 + */ +export function useNumericSortingCollator() { + const { i18n } = useTranslation(); + + return useMemo( + () => new Intl.Collator(i18n.language, { numeric: true }), + [i18n.language], + ); +} diff --git a/ui/src/components/stop-registry/stop-areas/stop-area-details/components/StopAreaMinimap.tsx b/ui/src/components/stop-registry/stop-areas/stop-area-details/components/StopAreaMinimap.tsx index dd131fe9e..a799dc2e5 100644 --- a/ui/src/components/stop-registry/stop-areas/stop-area-details/components/StopAreaMinimap.tsx +++ b/ui/src/components/stop-registry/stop-areas/stop-area-details/components/StopAreaMinimap.tsx @@ -1,50 +1,13 @@ import { FC } from 'react'; -// Don't forget to remove the image from the repo! import { useTranslation } from 'react-i18next'; import { twMerge } from 'tailwind-merge'; -import { StopAreaDetailsFragment } from '../../../../../generated/graphql'; -import { - useAppDispatch, - useFilterStops, - useMapQueryParams, - useObservationDateQueryParam, -} from '../../../../../hooks'; -import { - FilterType, - resetMapState, - setSelectedMapStopAreaIdAction, -} from '../../../../../redux'; import { mapLngLatToPoint } from '../../../../../utils'; import { SlimSimpleButton } from '../../../stops/stop-details/layout'; +import { useShowStopAreaOnMap } from '../../../utils'; +// Don't forget to remove the image from the repo! import placeholderBg from '../PlaceholderMapFragment.png'; import { StopAreaComponentProps } from './StopAreaComponentProps'; -function useShowOnMap() { - const dispatch = useAppDispatch(); - const { observationDate } = useObservationDateQueryParam(); - const { openMapWithParameters } = useMapQueryParams(); - const { toggleFunction } = useFilterStops(); - const toggleShowAllStops = toggleFunction(FilterType.ShowAllBusStops); - - return (area: StopAreaDetailsFragment) => { - dispatch(resetMapState()).then(() => { - dispatch(setSelectedMapStopAreaIdAction(area.id ?? undefined)); - toggleShowAllStops(false); - - const point = mapLngLatToPoint(area.geometry?.coordinates ?? []); - openMapWithParameters({ - viewPortParams: { - latitude: point.latitude, - longitude: point.longitude, - zoom: 18, - }, - observationDate, - displayedRouteParams: {}, - }); - }); - }; -} - const testIds = { openMapButton: 'StopAreaMinimap::openMapButton', marker: 'StopAreaMinimap::marker', @@ -55,7 +18,7 @@ export const StopAreaMinimap: FC = ({ className = '', }) => { const { t } = useTranslation(); - const showOnMap = useShowOnMap(); + const showOnMap = useShowStopAreaOnMap(); const point = mapLngLatToPoint(area.geometry?.coordinates ?? []); @@ -84,7 +47,7 @@ export const StopAreaMinimap: FC = ({ showOnMap(area)} + onClick={() => showOnMap(area.id ?? undefined, point)} testId={testIds.openMapButton} > {t('stopAreaDetails.minimap.showOnMap')} diff --git a/ui/src/components/stop-registry/utils/index.ts b/ui/src/components/stop-registry/utils/index.ts new file mode 100644 index 000000000..ba271476b --- /dev/null +++ b/ui/src/components/stop-registry/utils/index.ts @@ -0,0 +1 @@ +export * from './useShowStopAreaOnMap'; diff --git a/ui/src/components/stop-registry/utils/useShowStopAreaOnMap.tsx b/ui/src/components/stop-registry/utils/useShowStopAreaOnMap.tsx new file mode 100644 index 000000000..1d4dda6ce --- /dev/null +++ b/ui/src/components/stop-registry/utils/useShowStopAreaOnMap.tsx @@ -0,0 +1,40 @@ +import { + useAppDispatch, + useMapQueryParams, + useObservationDateQueryParam, +} from '../../../hooks'; +import { + FilterType, + resetMapState, + setSelectedMapStopAreaIdAction, + setStopFilterAction, +} from '../../../redux'; +import { Point } from '../../../types'; + +export function useShowStopAreaOnMap() { + const dispatch = useAppDispatch(); + const { observationDate } = useObservationDateQueryParam(); + const { openMapWithParameters } = useMapQueryParams(); + + return (id: string | undefined, point: Point) => { + dispatch(resetMapState()).then(() => { + dispatch(setSelectedMapStopAreaIdAction(id)); + dispatch( + setStopFilterAction({ + filterType: FilterType.ShowAllBusStops, + isActive: false, + }), + ); + + openMapWithParameters({ + viewPortParams: { + latitude: point.latitude, + longitude: point.longitude, + zoom: 18, + }, + observationDate, + displayedRouteParams: {}, + }); + }); + }; +} diff --git a/ui/src/uiComponents/ListboxButton.tsx b/ui/src/uiComponents/ListboxButton.tsx index 8f4a54bef..3831d53e3 100644 --- a/ui/src/uiComponents/ListboxButton.tsx +++ b/ui/src/uiComponents/ListboxButton.tsx @@ -25,7 +25,7 @@ export const ListboxButton: FC = ({