diff --git a/cypress/e2e/stop-registry/hybridStop.cy.ts b/cypress/e2e/stop-registry/hybridStop.cy.ts new file mode 100644 index 0000000000..17118e6c70 --- /dev/null +++ b/cypress/e2e/stop-registry/hybridStop.cy.ts @@ -0,0 +1,134 @@ +import { + Priority, + ReusableComponentsVehicleModeEnum, + StopAreaInput, + StopRegistryGeoJsonType, + StopRegistryTransportModeType, +} from '@hsl/jore4-test-db-manager/dist/CypressSpecExports'; +import { Tag } from '../../enums'; +import { MapPage, StopDetailsPage, Toast } from '../../pageObjects'; +import { InsertedStopRegistryIds } from '../utils'; + +// Test labels +const busStopLabel = 'H9901'; +const busAreaCode = 'HYB01'; +const tramAreaCode = 'HYB02'; + +// Coordinates where bus infra links are known to exist (same as createStop.cy.ts) +const testCoordinates = { + lat: 60.164074274478054, + lng: 24.93021804533524, +}; + +const stopAreaInput: Array = [ + { + StopArea: { + transportMode: StopRegistryTransportModeType.Bus, + name: { lang: 'fin', value: 'Hybrid bussi-alue' }, + privateCode: { type: 'HSL/TEST', value: busAreaCode }, + geometry: { + coordinates: [testCoordinates.lng, testCoordinates.lat], + type: StopRegistryGeoJsonType.Point, + }, + }, + organisations: null, + }, + { + StopArea: { + transportMode: StopRegistryTransportModeType.Tram, + name: { lang: 'fin', value: 'Hybrid ratikka-alue' }, + privateCode: { type: 'HSL/TEST', value: tramAreaCode }, + geometry: { + coordinates: [testCoordinates.lng, testCoordinates.lat], + type: StopRegistryGeoJsonType.Point, + }, + }, + organisations: null, + }, +]; + +describe( + 'Hybrid stop (multi-transport-mode)', + { tags: [Tag.StopRegistry, Tag.Stops] }, + () => { + beforeEach(() => { + cy.task('resetDbs'); + + cy.task('insertStopRegistryData', { + stopPlaces: stopAreaInput, + stopPointsRequired: false, + }); + + cy.setupTests(); + cy.mockLogin(); + }); + + it('Should create a bus stop, make it hybrid (add tram), then remove tram', () => { + // Step 1: Create a bus stop on the map + MapPage.map.visit({ + zoom: 16, + lat: testCoordinates.lat, + lng: testCoordinates.lng, + }); + + MapPage.createStopAtLocation({ + stopFormInfo: { + publicCode: busStopLabel, + stopPlace: busAreaCode, + validityStartISODate: '2024-01-01', + priority: Priority.Standard, + reasonForChange: 'E2E test', + }, + clickRelativePoint: { + xPercentage: 40, + yPercentage: 55, + }, + vehicleMode: ReusableComponentsVehicleModeEnum.Bus, + }); + + MapPage.gqlStopShouldBeCreatedSuccessfully(); + MapPage.checkStopSubmitSuccessToast(); + + // Step 2: Navigate to stop details page + StopDetailsPage.visit(busStopLabel); + StopDetailsPage.page().shouldBeVisible(); + + // Step 3: Open "Make hybrid" modal via extra actions menu + StopDetailsPage.titleRow.actionsMenuButton().click(); + StopDetailsPage.titleRow.actionsMenuMakeHybridButton().click(); + + // Step 4: Select tram transport mode + StopDetailsPage.makeHybridModal.modal().shouldBeVisible(); + StopDetailsPage.makeHybridModal.transportModeDropdown().click(); + cy.get('[role="option"]').contains('Raitiovaunu').click(); + + // Step 5: Search and select the tram stop area + StopDetailsPage.makeHybridModal.stopAreaInput().type(tramAreaCode); + StopDetailsPage.makeHybridModal.stopAreaOption(tramAreaCode).click(); + + // Step 6: Confirm + StopDetailsPage.makeHybridModal.confirmButton().click(); + + Toast.expectSuccessToast('Yhteiskäyttöpysäkki luotu onnistuneesti'); + + // Step 7: Verify the mirrored quay card appears + StopDetailsPage.mirroredQuayDetails.cards().should('exist'); + + // Step 8: Remove the hybrid relation + StopDetailsPage.mirroredQuayDetails + .cards() + .first() + .within(() => { + StopDetailsPage.mirroredQuayDetails.removeButton().click(); + }); + + // Confirm removal in the dialog + StopDetailsPage.mirroredQuayDetails.confirmationDialog + .getConfirmButton() + .click(); + + // Step 9: Verify the mirrored card is gone + StopDetailsPage.mirroredQuayDetails.cards().should('not.exist'); + }); + }, +); diff --git a/cypress/pageObjects/stop-registry/StopDetailsPage.ts b/cypress/pageObjects/stop-registry/StopDetailsPage.ts index daff81054f..88e96eda97 100644 --- a/cypress/pageObjects/stop-registry/StopDetailsPage.ts +++ b/cypress/pageObjects/stop-registry/StopDetailsPage.ts @@ -5,7 +5,9 @@ import { InfoSpotsSection, LocationDetailsSection, MaintenanceSection, + MakeHybridStopModal, MeasurementsSection, + MirroredQuayDetails, SheltersSection, SignageDetailsSection, StopHeaderSummaryRow, @@ -42,6 +44,10 @@ export class StopDetailsPage { static headerSummaryRow = StopHeaderSummaryRow; + static makeHybridModal = MakeHybridStopModal; + + static mirroredQuayDetails = MirroredQuayDetails; + static visit(label: string) { cy.visit(`/stop-registry/stops/${label}`); } diff --git a/cypress/pageObjects/stop-registry/stop-details/MakeHybridStopModal.ts b/cypress/pageObjects/stop-registry/stop-details/MakeHybridStopModal.ts new file mode 100644 index 0000000000..f76d2af631 --- /dev/null +++ b/cypress/pageObjects/stop-registry/stop-details/MakeHybridStopModal.ts @@ -0,0 +1,25 @@ +export class MakeHybridStopModal { + static modal() { + return cy.getByTestId('MakeHybridStopModal'); + } + + static transportModeDropdown() { + return cy.getByTestId('MakeHybridStopModal::transportMode::ListboxButton'); + } + + static stopAreaInput() { + return cy.getByTestId('MakeHybridStopModal::stopAreaInput'); + } + + static stopAreaOption(code: string) { + return cy.getByTestId(`MakeHybridStopModal::stopArea::${code}`); + } + + static confirmButton() { + return cy.getByTestId('MakeHybridStopModal::confirm'); + } + + static cancelButton() { + return cy.getByTestId('MakeHybridStopModal::cancel'); + } +} diff --git a/cypress/pageObjects/stop-registry/stop-details/MirroredQuayDetails.ts b/cypress/pageObjects/stop-registry/stop-details/MirroredQuayDetails.ts new file mode 100644 index 0000000000..6320ce5cf1 --- /dev/null +++ b/cypress/pageObjects/stop-registry/stop-details/MirroredQuayDetails.ts @@ -0,0 +1,13 @@ +import { ConfirmationDialog } from '../../shared-components'; + +export class MirroredQuayDetails { + static cards() { + return cy.getByTestId('MirroredQuayDetails::container'); + } + + static removeButton() { + return cy.getByTestId('MirroredQuayDetails::remove'); + } + + static confirmationDialog = ConfirmationDialog; +} diff --git a/cypress/pageObjects/stop-registry/stop-details/StopTitleRow.ts b/cypress/pageObjects/stop-registry/stop-details/StopTitleRow.ts index dab67f4a82..1991132f77 100644 --- a/cypress/pageObjects/stop-registry/stop-details/StopTitleRow.ts +++ b/cypress/pageObjects/stop-registry/stop-details/StopTitleRow.ts @@ -18,4 +18,8 @@ export class StopTitleRow { static actionsMenuCopyButton() { return cy.getByTestId('StopTitleRow::extraActions::copy'); } + + static actionsMenuMakeHybridButton() { + return cy.getByTestId('StopTitleRow::extraActions::makeHybrid'); + } } diff --git a/cypress/pageObjects/stop-registry/stop-details/index.ts b/cypress/pageObjects/stop-registry/stop-details/index.ts index 6f25787bfb..041592e9a6 100644 --- a/cypress/pageObjects/stop-registry/stop-details/index.ts +++ b/cypress/pageObjects/stop-registry/stop-details/index.ts @@ -24,3 +24,5 @@ export * from './SignageDetailsSection'; export * from './SignageDetailsViewCard'; export * from './StopHeaderSummaryRow'; export * from './StopTitleRow'; +export * from './MakeHybridStopModal'; +export * from './MirroredQuayDetails'; diff --git a/test-db-manager/src/types/enums.ts b/test-db-manager/src/types/enums.ts index 7238eef6bb..78fee67979 100644 --- a/test-db-manager/src/types/enums.ts +++ b/test-db-manager/src/types/enums.ts @@ -22,6 +22,8 @@ export enum KnownValueKey { StopOwner = 'stopOwner', OwnerContractId = 'owner-contractId', OwnerNote = 'owner-note', + TimingPlaceId = 'timingPlaceId', + Mirrors = 'mirrors', } // Represents the values of hsl_municipality in LegacyHslMunicipalityCode table. diff --git a/ui/src/components/common/info-container/DefaultHeaderButtons.tsx b/ui/src/components/common/info-container/DefaultHeaderButtons.tsx index 4f500121f1..07120f154d 100644 --- a/ui/src/components/common/info-container/DefaultHeaderButtons.tsx +++ b/ui/src/components/common/info-container/DefaultHeaderButtons.tsx @@ -42,11 +42,14 @@ export const DefaultHeaderButtons: FC = ({ setIsExpanded((expanded) => !expanded)} - inverted={!isExpanded} + inverted={inverted ?? !isExpanded} testId={testIds.toggle(testIdPrefix)} > {isExpanded ? ( - + ) : ( )} diff --git a/ui/src/components/stop-registry/stops/queries/useDeleteQuay.ts b/ui/src/components/stop-registry/stops/queries/useDeleteQuay.ts index 45c3b62ceb..b109be7387 100644 --- a/ui/src/components/stop-registry/stops/queries/useDeleteQuay.ts +++ b/ui/src/components/stop-registry/stops/queries/useDeleteQuay.ts @@ -26,7 +26,11 @@ export function useDeleteQuay() { (stopPlaceId: string, quayId: string) => deleteQuay({ variables: { stopPlaceId, quayId }, - refetchQueries: ['GetMapStops', 'getStopPlaceDetails'], + refetchQueries: [ + 'GetMapStops', + 'getStopPlaceDetails', + 'GetStopDetails', + ], awaitRefetchQueries: true, }), [deleteQuay], diff --git a/ui/src/components/stop-registry/stops/stop-details/StopDetailsPage.tsx b/ui/src/components/stop-registry/stops/stop-details/StopDetailsPage.tsx index 7dd59da7fc..4c98efdd82 100644 --- a/ui/src/components/stop-registry/stops/stop-details/StopDetailsPage.tsx +++ b/ui/src/components/stop-registry/stops/stop-details/StopDetailsPage.tsx @@ -1,4 +1,5 @@ -import { FC, useContext, useState } from 'react'; +import compact from 'lodash/compact'; +import { FC, useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { MdWarning } from 'react-icons/md'; import { Link } from 'react-router'; @@ -22,6 +23,7 @@ import { } from './DetailTabSelector'; import { EditStopValidityButton } from './EditStopValidityButton'; import { StopExternalLinks } from './external-links/StopExternalLinks'; +import { MirroredQuayDetailsCard } from './hybrid-stop'; import { SheltersInfoSpotsSection } from './info-spots/SheltersInfoSpots'; import { LocationDetailsSection } from './location-details'; import { MaintenanceSection } from './maintenance'; @@ -55,11 +57,20 @@ export const StopDetailsPage: FC = () => { const selectDetailTab = (nextTab: DetailTabType) => requestNavigation(() => setActiveDetailTab(nextTab)); - const { stopDetails, loading, error } = useGetStopDetails(); + const { stopDetails, loading, error, mirroredQuays } = useGetStopDetails(); + + const mirroredTransportModes = useMemo( + () => compact(mirroredQuays.map((mq) => mq.stopPlace.transportMode)), + [mirroredQuays], + ); return ( - +
@@ -121,7 +132,13 @@ export const StopDetailsPage: FC = () => { aria-labelledby={detailTabs.basic.buttonId} role="tabpanel" > - + 0} + /> + {mirroredQuays.map((mq) => ( + + ))} diff --git a/ui/src/components/stop-registry/stops/stop-details/basic-details/BasicDetailsSection.tsx b/ui/src/components/stop-registry/stops/stop-details/basic-details/BasicDetailsSection.tsx index d881fc55de..e055b95b21 100644 --- a/ui/src/components/stop-registry/stops/stop-details/basic-details/BasicDetailsSection.tsx +++ b/ui/src/components/stop-registry/stops/stop-details/basic-details/BasicDetailsSection.tsx @@ -1,5 +1,6 @@ -import { FC, useRef } from 'react'; +import { FC, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { mapTransportModeToStopTypeName } from '../../../../../i18n/uiNameMappings'; import { StopWithDetails } from '../../../../../types'; import { showSuccessToast, submitFormByRef } from '../../../../../utils'; import { InfoContainer, useInfoContainerControls } from '../../../../common'; @@ -32,9 +33,13 @@ const mapStopBasicDetailsDataToFormState = (stop: StopWithDetails) => { }; type BasicDetailsSectionProps = { readonly stop: StopWithDetails; + readonly isHybrid: boolean; }; -export const BasicDetailsSection: FC = ({ stop }) => { +export const BasicDetailsSection: FC = ({ + stop, + isHybrid, +}) => { const { t } = useTranslation(); const { saveStopPlaceDetails, defaultErrorHandler } = @@ -60,11 +65,21 @@ export const BasicDetailsSection: FC = ({ stop }) => { const defaultValues = mapStopBasicDetailsDataToFormState(stop); + const transportMode = stop.stop_place?.transportMode; + const title = useMemo(() => { + const base = t(($) => $.stopDetails.basicDetails.title); + if (!isHybrid || !transportMode) { + return base; + } + const modeName = mapTransportModeToStopTypeName(t, transportMode); + return `${base} | ${modeName}`; + }, [t, isHybrid, transportMode]); + return ( $.stopDetails.basicDetails.title)} + title={title} testIdPrefix="BasicDetailsSection" > {infoContainerControls.isInEditMode && !!defaultValues ? ( @@ -73,6 +88,7 @@ export const BasicDetailsSection: FC = ({ stop }) => { ref={formRef} onSubmit={onSubmit} stop={stop} + isHybrid={isHybrid} onCancel={() => infoContainerControls.setIsInEditMode(false)} testIdPrefix="BasicDetailsSection" /> diff --git a/ui/src/components/stop-registry/stops/stop-details/basic-details/basic-details-form/StopBasicDetailsForm.tsx b/ui/src/components/stop-registry/stops/stop-details/basic-details/basic-details-form/StopBasicDetailsForm.tsx index 5047d0c1b2..c0870b7eba 100644 --- a/ui/src/components/stop-registry/stops/stop-details/basic-details/basic-details-form/StopBasicDetailsForm.tsx +++ b/ui/src/components/stop-registry/stops/stop-details/basic-details/basic-details-form/StopBasicDetailsForm.tsx @@ -30,6 +30,7 @@ type StopBasicDetailsFormComponentProps = { readonly defaultValues: Partial; readonly onSubmit: (state: StopBasicDetailsFormState) => void; readonly stop: StopWithDetails; + readonly isHybrid?: boolean; readonly onCancel: () => void; readonly testIdPrefix: string; }; @@ -38,7 +39,15 @@ const StopBasicDetailsFormComponent: ForwardRefRenderFunction< HTMLFormElement, StopBasicDetailsFormComponentProps > = ( - { className, defaultValues, onSubmit, stop, onCancel, testIdPrefix }, + { + className, + defaultValues, + onSubmit, + stop, + isHybrid, + onCancel, + testIdPrefix, + }, ref, ) => { const dispatch = useDispatch(); @@ -69,6 +78,7 @@ const StopBasicDetailsFormComponent: ForwardRefRenderFunction< diff --git a/ui/src/components/stop-registry/stops/stop-details/basic-details/basic-details-form/StopOtherDetailsFormRow.tsx b/ui/src/components/stop-registry/stops/stop-details/basic-details/basic-details-form/StopOtherDetailsFormRow.tsx index 6b1c830c86..638b34a551 100644 --- a/ui/src/components/stop-registry/stops/stop-details/basic-details/basic-details-form/StopOtherDetailsFormRow.tsx +++ b/ui/src/components/stop-registry/stops/stop-details/basic-details/basic-details-form/StopOtherDetailsFormRow.tsx @@ -17,10 +17,12 @@ const testIds = { }; type StopOtherDetailsFormRowProps = { readonly onClickOpenTimingSettingsModal: () => void; + readonly isTransportModeLocked?: boolean; }; export const StopOtherDetailsFormRow: FC = ({ onClickOpenTimingSettingsModal, + isTransportModeLocked, }) => { const { t } = useTranslation(); @@ -45,7 +47,9 @@ export const StopOtherDetailsFormRow: FC = ({ uiNameMapper={(value) => mapStopRegistryTransportModeTypeToUiName(t, value) } - disabled={isRailReplacement || isTrunkLine} + disabled={ + isRailReplacement || isTrunkLine || isTransportModeLocked + } // eslint-disable-next-line react/jsx-props-no-spreading {...props} /> diff --git a/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/MakeHybridStopModal.tsx b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/MakeHybridStopModal.tsx new file mode 100644 index 0000000000..9f31e9ad7f --- /dev/null +++ b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/MakeHybridStopModal.tsx @@ -0,0 +1,181 @@ +import { FC, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StopRegistryTransportModeType } from '../../../../../generated/graphql'; +import { mapStopRegistryTransportModeTypeToUiName } from '../../../../../i18n/uiNameMappings'; +import { StopWithDetails } from '../../../../../types'; +import { + JoreListbox, + ListboxOptionItem, + Modal, + ModalBody, + ModalHeader, + NewModalFooter, + SimpleButton, +} from '../../../../../uiComponents'; +import { parseVehicleMode } from '../../../../../utils'; +import { + showDangerToastWithError, + showSuccessToast, +} from '../../../../../utils/toastService'; +import { StopModalStopAreaFormSchema } from '../../../../forms/stop/types'; +import { StopAreaSearchCombobox } from './StopAreaSearchCombobox'; +import { useCreateMirrorQuay } from './useCreateMirrorQuay'; + +const testIds = { + modal: 'MakeHybridStopModal', + transportModeDropdown: 'MakeHybridStopModal::transportMode', + stopAreaInput: 'MakeHybridStopModal::stopAreaInput', + stopAreaOption: (code: string) => `MakeHybridStopModal::stopArea::${code}`, + confirmButton: 'MakeHybridStopModal::confirm', + cancelButton: 'MakeHybridStopModal::cancel', +}; + +const supportedTransportModes = [ + StopRegistryTransportModeType.Bus, + StopRegistryTransportModeType.Tram, +] as const; + +type MakeHybridStopModalProps = { + readonly isOpen: boolean; + readonly onClose: () => void; + readonly parentStop: StopWithDetails | null; +}; + +export const MakeHybridStopModal: FC = ({ + isOpen, + onClose, + parentStop, +}) => { + const { t } = useTranslation(); + + const [selectedMode, setSelectedMode] = + useState(null); + const [selectedStopArea, setSelectedStopArea] = + useState(null); + + const { createMirrorQuay, loading: saving } = useCreateMirrorQuay(); + + const currentTransportMode = parentStop?.stop_place?.transportMode ?? null; + + const availableModes = useMemo( + () => + supportedTransportModes.filter((mode) => mode !== currentTransportMode), + [currentTransportMode], + ); + + const transportModeOptions: ReadonlyArray< + ListboxOptionItem + > = useMemo( + () => + availableModes.map((mode) => ({ + value: mode, + content: mapStopRegistryTransportModeTypeToUiName(t, mode), + })), + [availableModes, t], + ); + + const vehicleMode = selectedMode ? parseVehicleMode(selectedMode) : null; + + const handleModeChange = (mode: StopRegistryTransportModeType) => { + setSelectedMode(mode); + setSelectedStopArea(null); + }; + + const resetState = () => { + setSelectedMode(null); + setSelectedStopArea(null); + }; + + const handleClose = () => { + resetState(); + onClose(); + }; + + const handleConfirm = async () => { + if (!parentStop || !selectedStopArea || !selectedMode || !vehicleMode) { + return; + } + + try { + const success = await createMirrorQuay({ + targetStopPlaceId: selectedStopArea.netexId, + parentStop, + vehicleMode, + }); + + if (success) { + showSuccessToast(t(($) => $.stopDetails.hybrid.success)); + handleClose(); + } + } catch (err) { + showDangerToastWithError( + t(($) => $.stopDetails.hybrid.error), + err, + ); + } + }; + + const canConfirm = !!selectedMode && !!selectedStopArea && !saving; + + return ( + + $.stopDetails.hybrid.title)} + /> + +
+ + + buttonContent={ + selectedMode + ? mapStopRegistryTransportModeTypeToUiName(t, selectedMode) + : t(($) => $.stopDetails.hybrid.selectTransportMode) + } + options={transportModeOptions} + value={selectedMode ?? undefined} + onChange={handleModeChange} + testId={testIds.transportModeDropdown} + /> +
+ +
+ + +
+
+ + + {t(($) => $.stopDetails.hybrid.cancel)} + + + {saving ? '...' : t(($) => $.stopDetails.hybrid.confirm)} + + +
+ ); +}; diff --git a/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/MirroredQuayDetailsCard.tsx b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/MirroredQuayDetailsCard.tsx new file mode 100644 index 0000000000..282d70d0b2 --- /dev/null +++ b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/MirroredQuayDetailsCard.tsx @@ -0,0 +1,115 @@ +import { FC, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { InfrastructureNetworkDirectionEnum } from '../../../../../generated/graphql'; +import { mapTransportModeToStopTypeName } from '../../../../../i18n/uiNameMappings'; +import { StopWithDetails } from '../../../../../types'; +import { Priority } from '../../../../../types/enums'; +import { ConfirmationDialog, SimpleButton } from '../../../../../uiComponents'; +import { showSuccessToast } from '../../../../../utils'; +import { InfoContainer, useInfoContainerControls } from '../../../../common'; +import { StopAreaDetailsSection } from '../basic-details/BasicDetailsStopAreaFields'; +import { StopDetailsSection } from '../basic-details/BasicDetailsStopFields'; +import { getContainerColorsByTransportMode } from '../stopInfoContainerColors'; +import { MirroredQuayDetails } from '../useGetStopDetails'; +import { useRemoveMirrorRelation } from './useRemoveMirrorRelation'; + +function toStopWithDetails(details: MirroredQuayDetails): StopWithDetails { + const { quay, stopPlace } = details; + const coords = quay.geometry?.coordinates; + return { + scheduled_stop_point_id: '' as UUID, + label: quay.publicCode ?? '', + priority: quay.priority ?? Priority.Standard, + direction: InfrastructureNetworkDirectionEnum.Forward, + validity_start: null, + validity_end: null, + located_on_infrastructure_link_id: '' as UUID, + stop_place_ref: null, + measured_location: { + type: 'Point' as const, + coordinates: coords ?? [0, 0], + }, + timing_place_id: (quay.timingPlaceId ?? null) as UUID | null, + timing_place: null, + vehicle_mode_on_scheduled_stop_point: [], + stop_place: stopPlace, + quay, + location: { + longitude: coords?.[0] ?? 0, + latitude: coords?.[1] ?? 0, + }, + }; +} + +type MirroredQuayDetailsCardProps = { + readonly details: MirroredQuayDetails; +}; + +export const MirroredQuayDetailsCard: FC = ({ + details, +}) => { + const { t } = useTranslation(); + const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const { removeMirrorRelation, loading: removing } = useRemoveMirrorRelation(); + + const { transportMode } = details.stopPlace; + const colors = getContainerColorsByTransportMode(transportMode); + + const infoContainerControls = useInfoContainerControls({ + isEditable: false, + isExpandable: true, + }); + + const transportModeName = transportMode + ? mapTransportModeToStopTypeName(t, transportMode) + : ''; + + const title = `${t(($) => $.stopDetails.basicDetails.title)} | ${transportModeName}`; + + const pseudoStop = useMemo(() => toStopWithDetails(details), [details]); + + const handleRemove = async () => { + const success = await removeMirrorRelation({ + childQuayId: details.quay.id ?? '', + childStopPlaceId: details.stopPlace.id ?? '', + }); + setShowRemoveDialog(false); + if (success) { + showSuccessToast(t(($) => $.stopDetails.hybrid.removeSuccess)); + } + }; + + return ( + <> + + + +
+ setShowRemoveDialog(true)} + testId="MirroredQuayDetails::remove" + > + {t(($) => $.stopDetails.hybrid.removeButton)} + +
+
+ setShowRemoveDialog(false)} + title={t(($) => $.stopDetails.hybrid.removeConfirmTitle)} + description={t(($) => $.stopDetails.hybrid.removeConfirmDescription)} + confirmText={t(($) => $.stopDetails.hybrid.removeConfirm)} + cancelText={t(($) => $.stopDetails.hybrid.removeCancel)} + isConfirming={removing} + /> + + ); +}; diff --git a/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/StopAreaSearchCombobox.tsx b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/StopAreaSearchCombobox.tsx new file mode 100644 index 0000000000..aa86429d0c --- /dev/null +++ b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/StopAreaSearchCombobox.tsx @@ -0,0 +1,143 @@ +import { + Combobox, + ComboboxButton, + ComboboxInput, + ComboboxOption, + ComboboxOptions, +} from '@headlessui/react'; +import debounce from 'lodash/debounce'; +import { FC, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MdOutlineSearch } from 'react-icons/md'; +import { ReusableComponentsVehicleModeEnum } from '../../../../../generated/graphql'; +import { comboboxStyles } from '../../../../../uiComponents'; +import { StopModalStopAreaFormSchema } from '../../../../forms/stop/types'; +import { + formatIsoDateString, + useFindStopAreas, +} from '../../../../forms/stop/utils'; + +type StopAreaSearchComboboxProps = { + readonly vehicleMode: ReusableComponentsVehicleModeEnum | null | undefined; + readonly value: StopModalStopAreaFormSchema | null; + readonly onChange: (value: StopModalStopAreaFormSchema | null) => void; + readonly disabled: boolean; + readonly inputTestId: string; + readonly optionTestId: (code: string) => string; +}; + +export const StopAreaSearchCombobox: FC = ({ + vehicleMode, + value, + onChange, + disabled, + inputTestId, + optionTestId, +}) => { + const { t } = useTranslation(); + + const [query, setQuery] = useState(''); + const [queryDebounced, setQueryDebounced] = useState(false); + const { areas, loading: loadingAreas } = useFindStopAreas(query, vehicleMode); + + useEffect(() => { + if (!loadingAreas) { + setQueryDebounced(false); + } + }, [loadingAreas]); + + const onQueryChange = useMemo(() => { + const debouncedSetQuery = debounce(setQuery, 500); + return (newQuery: string) => { + if (newQuery === '') { + debouncedSetQuery.cancel(); + setQueryDebounced(false); + setQuery(''); + } else { + setQueryDebounced(true); + debouncedSetQuery(newQuery); + } + }; + }, []); + + const areaSearchLoading = loadingAreas || queryDebounced; + + return ( + +
+ + className={comboboxStyles.input( + 'grow border-r-0 outline-0 ui-not-open:rounded-tr-none ui-not-open:rounded-br-none', + 'ui-open:rounded-bl-none', + )} + onChange={(e) => onQueryChange(e.target.value)} + displayValue={(it) => it?.nameFin ?? ''} + autoComplete="off" + placeholder={t(($) => $.stopDetails.hybrid.searchStopArea)} + data-testid={inputTestId} + /> + + + +
+ + {areaSearchLoading && ( + + {t(($) => $.stops.stopArea.label)} + + )} + + {areas.map((area) => ( + + + {area.privateCode} + +
+ {area.nameFin ?? area.nameSwe} + + {`${formatIsoDateString(area.validityStart)} - ${formatIsoDateString(area.validityEnd)}`} + +
+
+ ))} + + {!query && !areaSearchLoading && ( + + {t(($) => $.stops.stopArea.help)} + + )} +
+
+ ); +}; diff --git a/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/index.ts b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/index.ts new file mode 100644 index 0000000000..30b4a2e8f4 --- /dev/null +++ b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/index.ts @@ -0,0 +1,2 @@ +export { MakeHybridStopModal } from './MakeHybridStopModal'; +export { MirroredQuayDetailsCard } from './MirroredQuayDetailsCard'; diff --git a/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/useCreateMirrorQuay.ts b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/useCreateMirrorQuay.ts new file mode 100644 index 0000000000..c21bdd8358 --- /dev/null +++ b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/useCreateMirrorQuay.ts @@ -0,0 +1,175 @@ +import { useCallback, useState } from 'react'; +import { + ReusableComponentsVehicleModeEnum, + StopRegistryKeyValuesInput, + StopRegistryQuayInput, + useInsertQuayIntoStopPlaceMutation, + useInsertStopPointMutation, +} from '../../../../../generated/graphql'; +import { Operation } from '../../../../../redux'; +import { StopWithDetails } from '../../../../../types'; +import { KnownValueKey } from '../../../../../utils'; +import { useLoader } from '../../../../common/hooks'; +import { useGetStopLinkAndDirection } from '../../../../map/stops/hooks/useGetStopLinkAndDirection'; +import { setMirrorParent } from '../../../utils/mirrorRelation'; + +type CreateMirrorQuayParams = { + readonly targetStopPlaceId: string; + readonly parentStop: StopWithDetails; + readonly vehicleMode: ReusableComponentsVehicleModeEnum; +}; + +const keysToInherit: ReadonlyArray = [ + KnownValueKey.Priority, + KnownValueKey.ValidityStart, + KnownValueKey.ValidityEnd, + KnownValueKey.StopState, +]; + +function buildQuayInput(parentStop: StopWithDetails): StopRegistryQuayInput { + const { quay } = parentStop; + if (!quay) { + throw new Error('Parent stop has no quay'); + } + + const inheritedKeyValues = (quay.keyValues ?? []).flatMap((kv) => { + if (kv?.key && keysToInherit.includes(kv.key)) { + return [{ key: kv.key, values: [...(kv.values ?? [])] }]; + } + return []; + }); + + const keyValues = [ + ...setMirrorParent([], quay.id ?? ''), + ...inheritedKeyValues, + ]; + + return { + geometry: + quay.geometry?.coordinates && quay.geometry.type + ? { coordinates: quay.geometry.coordinates, type: quay.geometry.type } + : undefined, + publicCode: quay.publicCode, + description: quay.description + ? { lang: quay.description.lang, value: quay.description.value } + : undefined, + alternativeNames: quay.alternativeNames + ?.filter((an): an is NonNullable => an !== null) + .map((an) => ({ + nameType: an.nameType, + name: { lang: an.name.lang, value: an.name.value }, + })), + keyValues: keyValues as StopRegistryKeyValuesInput[], + }; +} + +type QuayWithKeyValues = { + readonly id?: string | null; + readonly keyValues?: ReadonlyArray<{ + readonly key?: string | null; + readonly values?: ReadonlyArray | null; + } | null> | null; +}; + +function findNewChildQuayId( + quays: ReadonlyArray | null, + parentQuayNetexId: string, +): string | null { + const child = quays?.find((q) => + q?.keyValues?.some( + (kv) => + kv?.key === KnownValueKey.Mirrors && + kv?.values?.includes(parentQuayNetexId), + ), + ); + return child?.id ?? null; +} + +export function useCreateMirrorQuay() { + const [loading, setLoading] = useState(false); + const { setIsLoading } = useLoader(Operation.CreateMirrorQuay); + + const [insertQuayIntoStopPlace] = useInsertQuayIntoStopPlaceMutation({ + refetchQueries: ['GetStopDetails'], + awaitRefetchQueries: true, + }); + + const [insertStopPoint] = useInsertStopPointMutation(); + const [getStopLinkAndDirection] = useGetStopLinkAndDirection(); + + const createMirrorQuay = useCallback( + async ({ + targetStopPlaceId, + parentStop, + vehicleMode, + }: CreateMirrorQuayParams) => { + const parentQuayId = parentStop.quay?.id; + const parentStopPlaceId = parentStop.stop_place?.id; + if (!parentQuayId || !parentStopPlaceId) { + return false; + } + + setLoading(true); + setIsLoading(true); + try { + // Resolve infra link first — if this fails, no orphan quay is created + const { closestLink, direction } = await getStopLinkAndDirection({ + stopLocation: parentStop.measured_location, + vehicleMode, + }); + + const quayInput = buildQuayInput(parentStop); + + const insertResponse = await insertQuayIntoStopPlace({ + variables: { + stopPlaceId: targetStopPlaceId, + quayInput, + }, + }); + + const resultQuays = + insertResponse.data?.stop_registry?.mutateStopPlace?.at(0)?.quays; + const childQuayId = findNewChildQuayId( + resultQuays ?? null, + parentQuayId, + ); + + if (!childQuayId) { + throw new Error('Could not find newly created child quay'); + } + + const stopPointInput = { + measured_location: parentStop.measured_location, + located_on_infrastructure_link_id: closestLink.infrastructure_link_id, + direction, + label: parentStop.label, + priority: parentStop.priority, + validity_start: parentStop.validity_start, + validity_end: parentStop.validity_end, + timing_place_id: parentStop.timing_place_id, + stop_place_ref: childQuayId, + vehicle_mode_on_scheduled_stop_point: { + data: [{ vehicle_mode: vehicleMode }], + }, + }; + + await insertStopPoint({ + variables: { stopPoint: stopPointInput }, + }); + + return true; + } finally { + setLoading(false); + setIsLoading(false); + } + }, + [ + insertQuayIntoStopPlace, + insertStopPoint, + getStopLinkAndDirection, + setIsLoading, + ], + ); + + return { createMirrorQuay, loading }; +} diff --git a/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/useRemoveMirrorRelation.ts b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/useRemoveMirrorRelation.ts new file mode 100644 index 0000000000..3bee994138 --- /dev/null +++ b/ui/src/components/stop-registry/stops/stop-details/hybrid-stop/useRemoveMirrorRelation.ts @@ -0,0 +1,50 @@ +import { useCallback, useState } from 'react'; +import { + useGetStopPointsByQuayIdLazyQuery, + useRemoveStopMutation, +} from '../../../../../generated/graphql'; +import { useDeleteQuay } from '../../queries/useDeleteQuay'; + +type RemoveMirrorRelationParams = { + readonly childQuayId: string; + readonly childStopPlaceId: string; +}; + +export function useRemoveMirrorRelation() { + const [loading, setLoading] = useState(false); + + const [getStopPointsByQuayId] = useGetStopPointsByQuayIdLazyQuery(); + const [removeStopMutation] = useRemoveStopMutation(); + const deleteQuay = useDeleteQuay(); + + const removeMirrorRelation = useCallback( + async ({ childQuayId, childStopPlaceId }: RemoveMirrorRelationParams) => { + setLoading(true); + try { + // 1. Remove the child's SSP (scheduled stop point) + const sspResult = await getStopPointsByQuayId({ + variables: { quayIds: [childQuayId] }, + }); + const childStopPoints = + sspResult.data?.service_pattern_scheduled_stop_point ?? []; + await Promise.all( + childStopPoints.map((ssp) => + removeStopMutation({ + variables: { stop_id: ssp.scheduled_stop_point_id }, + }), + ), + ); + + // 2. Delete the child quay from Tiamat + await deleteQuay(childStopPlaceId, childQuayId); + + return true; + } finally { + setLoading(false); + } + }, + [getStopPointsByQuayId, removeStopMutation, deleteQuay], + ); + + return { removeMirrorRelation, loading }; +} diff --git a/ui/src/components/stop-registry/stops/stop-details/stopInfoContainerColors.ts b/ui/src/components/stop-registry/stops/stop-details/stopInfoContainerColors.ts index 47313a4c96..f39fa5ebd2 100644 --- a/ui/src/components/stop-registry/stops/stop-details/stopInfoContainerColors.ts +++ b/ui/src/components/stop-registry/stops/stop-details/stopInfoContainerColors.ts @@ -1,3 +1,4 @@ +import { StopRegistryTransportModeType } from '../../../../generated/graphql'; import { theme } from '../../../../generated/theme'; import { InfoContainerColors } from '../../../common'; @@ -5,3 +6,22 @@ export const stopInfoContainerColors: InfoContainerColors = { backgroundColor: theme.colors.hslNeutralBlue, borderColor: theme.colors.border.hslBlue, }; + +export function getContainerColorsByTransportMode( + mode: StopRegistryTransportModeType | null | undefined, +): InfoContainerColors { + switch (mode) { + case StopRegistryTransportModeType.Tram: + return { + backgroundColor: theme.colors.hslTramDarkGreen, + borderColor: theme.colors.border.hslTramGreen, + textColorClassName: 'text-white', + }; + default: + return { + backgroundColor: theme.colors.tweakedBrand, + borderColor: theme.colors.border.hslBlue, + textColorClassName: 'text-white', + }; + } +} diff --git a/ui/src/components/stop-registry/stops/stop-details/title-row/ExtraActions.tsx b/ui/src/components/stop-registry/stops/stop-details/title-row/ExtraActions.tsx index 160d145921..19d70622ab 100644 --- a/ui/src/components/stop-registry/stops/stop-details/title-row/ExtraActions.tsx +++ b/ui/src/components/stop-registry/stops/stop-details/title-row/ExtraActions.tsx @@ -5,11 +5,14 @@ import { SimpleDropdownMenu, SimpleDropdownMenuItem, } from '../../../../../uiComponents'; +import { isMirrorChild } from '../../../utils/mirrorRelation'; +import { MakeHybridStopModal } from '../hybrid-stop'; import { CopyStopModal } from '../stop-version'; const testIds = { actionMenu: 'StopTitleRow::extraActions::menu', copy: 'StopTitleRow::extraActions::copy', + makeHybrid: 'StopTitleRow::extraActions::makeHybrid', }; type ExtraActionsProps = { @@ -21,6 +24,9 @@ export const ExtraActions: FC = ({ className, stop }) => { const { t } = useTranslation(); const [showCopyModal, setShowCopyModal] = useState(false); + const [showHybridModal, setShowHybridModal] = useState(false); + + const isAlreadyHybridChild = stop?.quay ? isMirrorChild(stop.quay) : false; return ( <> @@ -37,12 +43,28 @@ export const ExtraActions: FC = ({ className, stop }) => { onClick={() => setShowCopyModal(true)} testId={testIds.copy} /> + $.stopDetails.actions.extra.makeHybrid)} + onClick={() => setShowHybridModal(true)} + disabled={isAlreadyHybridChild} + title={ + isAlreadyHybridChild + ? t(($) => $.stopDetails.actions.extra.makeHybridDisabled) + : undefined + } + testId={testIds.makeHybrid} + /> setShowCopyModal(false)} originalStop={stop} /> + setShowHybridModal(false)} + parentStop={stop} + /> ); }; diff --git a/ui/src/components/stop-registry/stops/stop-details/title-row/StopTitleRow.tsx b/ui/src/components/stop-registry/stops/stop-details/title-row/StopTitleRow.tsx index b563dc0327..7040331624 100644 --- a/ui/src/components/stop-registry/stops/stop-details/title-row/StopTitleRow.tsx +++ b/ui/src/components/stop-registry/stops/stop-details/title-row/StopTitleRow.tsx @@ -1,7 +1,11 @@ import compact from 'lodash/compact'; +import uniq from 'lodash/uniq'; import { FC } from 'react'; +import { twJoin } from 'tailwind-merge'; +import { StopRegistryTransportModeType } from '../../../../../generated/graphql'; import { StopWithDetails } from '../../../../../types'; import { PageTitle } from '../../../../common'; +import { getTransportModeIcon } from '../../../utils/getTransportModeIcon'; import { ExtraActions } from './ExtraActions'; import { OpenOnMapButton } from './OpenOnMapButton'; import { StopTimetablesButton } from './StopTimetablesButton'; @@ -14,12 +18,29 @@ const testIds = { type StopTitleRowProps = { readonly stopDetails: StopWithDetails | null; readonly label: string; + readonly mirroredTransportModes?: ReadonlyArray; }; -export const StopTitleRow: FC = ({ stopDetails, label }) => { +export const StopTitleRow: FC = ({ + stopDetails, + label, + mirroredTransportModes = [], +}) => { + const ownMode = stopDetails?.stop_place?.transportMode; + const allModes = uniq(compact([ownMode, ...mirroredTransportModes])); + return (
- + {allModes.length > 0 ? ( + allModes.map((mode) => ( + + )) + ) : ( + + )} it.publicCode === requestedPublicCode) + .filter((it) => !isMirrorChild(it)) .filter(validOn(observationDateTs)); if (Number.isFinite(priority)) { @@ -450,6 +453,52 @@ const getStopDetails = ( }; }; +export type MirroredQuayDetails = { + readonly quay: EnrichedQuay; + readonly stopPlace: EnrichedStopPlace; +}; + +function getMirroredQuays( + data: GetStopDetailsQuery | undefined, + parentQuay: Quay | null, +): ReadonlyArray { + if (!parentQuay?.id) { + return []; + } + + const parentQuayId = parentQuay.id; + const stopPlaceResults = data?.stopsDb?.newestVersion ?? []; + + return compact( + stopPlaceResults.flatMap((result) => { + const [stopPlace] = getStopPlacesFromQueryResult( + result.TiamatStopPlace, + ); + if (!stopPlace) { + return []; + } + + const enrichedStopPlace = getEnrichedStopPlace(stopPlace); + if (!enrichedStopPlace) { + return []; + } + + return compact(stopPlace.quays) + .filter((q) => getMirrorParentId(q) === parentQuayId) + .map((quay) => { + const enrichedQuay = mapToEnrichedQuay( + quay, + stopPlace.accessibilityAssessment, + ); + if (!enrichedQuay) { + return null; + } + return { quay: enrichedQuay, stopPlace: enrichedStopPlace }; + }); + }), + ); +} + function getWhereCondition( label: string, ): StopsDatabaseStopPlaceNewestVersionBoolExp { @@ -498,7 +547,12 @@ export const useGetStopDetails = () => { ], ); - return { ...rest, stopDetails }; + const mirroredQuays = useMemo( + () => getMirroredQuays(data, stopDetails?.quay ?? null), + [data, stopDetails?.quay], + ); + + return { ...rest, stopDetails, mirroredQuays }; }; export const useGetStopDetailsLazy = () => { diff --git a/ui/src/components/stop-registry/utils/index.ts b/ui/src/components/stop-registry/utils/index.ts index 249188fc91..63cac9bb70 100644 --- a/ui/src/components/stop-registry/utils/index.ts +++ b/ui/src/components/stop-registry/utils/index.ts @@ -4,6 +4,7 @@ export * from './findPreviousTiamatHistoryItemVersion'; export * from './mapInfoSpotToInput'; export * from './mapQuayToInput'; export * from './mapToEnrichedQuay'; +export * from './mirrorRelation'; export * from './parseTerminalType'; export { sortByVersion } from './sortTiamatChangeHistoryItems'; export * from './useShowStopAreaOnMap'; diff --git a/ui/src/components/stop-registry/utils/mirrorRelation.spec.ts b/ui/src/components/stop-registry/utils/mirrorRelation.spec.ts new file mode 100644 index 0000000000..7f28590c42 --- /dev/null +++ b/ui/src/components/stop-registry/utils/mirrorRelation.spec.ts @@ -0,0 +1,70 @@ +import { KnownValueKey } from '../../../utils/knownValueKey'; +import { + getMirrorParentId, + isMirrorChild, + setMirrorParent, +} from './mirrorRelation'; + +describe('Mirror relation utils', () => { + describe('getMirrorParentId', () => { + it('should return parent netexId when mirrors key exists', () => { + const quay = { + keyValues: [{ key: KnownValueKey.Mirrors, values: ['NSR:Quay:123'] }], + }; + expect(getMirrorParentId(quay)).toBe('NSR:Quay:123'); + }); + + it('should return null when no mirrors key exists', () => { + const quay = { + keyValues: [{ key: 'someOtherKey', values: ['value'] }], + }; + expect(getMirrorParentId(quay)).toBeNull(); + }); + + it('should return null for empty keyValues', () => { + expect(getMirrorParentId({ keyValues: [] })).toBeNull(); + expect(getMirrorParentId({ keyValues: undefined })).toBeNull(); + }); + }); + + describe('isMirrorChild', () => { + it('should return true when quay has mirrors key', () => { + const quay = { + keyValues: [{ key: KnownValueKey.Mirrors, values: ['NSR:Quay:123'] }], + }; + expect(isMirrorChild(quay)).toBe(true); + }); + + it('should return false when quay has no mirrors key', () => { + expect(isMirrorChild({ keyValues: [] })).toBe(false); + }); + }); + + describe('setMirrorParent', () => { + it('should set mirrors key on empty keyValues', () => { + const result = setMirrorParent([], 'NSR:Quay:123'); + expect(result).toEqual([ + { key: KnownValueKey.Mirrors, values: ['NSR:Quay:123'] }, + ]); + }); + + it('should update existing mirrors key', () => { + const existing = [ + { key: KnownValueKey.Mirrors, values: ['NSR:Quay:old'] }, + ]; + const result = setMirrorParent(existing, 'NSR:Quay:new'); + expect(result).toEqual([ + { key: KnownValueKey.Mirrors, values: ['NSR:Quay:new'] }, + ]); + }); + + it('should preserve other keyValues', () => { + const existing = [{ key: 'otherKey', values: ['otherValue'] }]; + const result = setMirrorParent(existing, 'NSR:Quay:123'); + expect(result).toEqual([ + { key: 'otherKey', values: ['otherValue'] }, + { key: KnownValueKey.Mirrors, values: ['NSR:Quay:123'] }, + ]); + }); + }); +}); diff --git a/ui/src/components/stop-registry/utils/mirrorRelation.ts b/ui/src/components/stop-registry/utils/mirrorRelation.ts new file mode 100644 index 0000000000..478a68072f --- /dev/null +++ b/ui/src/components/stop-registry/utils/mirrorRelation.ts @@ -0,0 +1,34 @@ +import omit from 'lodash/omit'; +import { + StopRegistryKeyValues, + StopRegistryKeyValuesInput, +} from '../../../generated/graphql'; +import { + ElementWithKeyValues, + findKeyValue, +} from '../../../utils/findKeyValue'; +import { KnownValueKey } from '../../../utils/knownValueKey'; +import { setKeyValue } from '../../../utils/stop-registry/stopPlace'; + +export function stripKeyValueTypenames( + keyValues: ReadonlyArray, +): StopRegistryKeyValuesInput[] { + return keyValues + .filter(Boolean) + .map((kv) => omit(kv, '__typename') as StopRegistryKeyValuesInput); +} + +export function getMirrorParentId(quay: ElementWithKeyValues): string | null { + return findKeyValue(quay, KnownValueKey.Mirrors); +} + +export function isMirrorChild(quay: ElementWithKeyValues): boolean { + return getMirrorParentId(quay) !== null; +} + +export function setMirrorParent( + childKeyValues: ReadonlyArray | undefined, + parentNetexId: string, +): (StopRegistryKeyValues | null)[] { + return setKeyValue(childKeyValues, KnownValueKey.Mirrors, [parentNetexId]); +} diff --git a/ui/src/i18n/uiNameMappings.ts b/ui/src/i18n/uiNameMappings.ts index 660d8556d6..1a4e6211e5 100644 --- a/ui/src/i18n/uiNameMappings.ts +++ b/ui/src/i18n/uiNameMappings.ts @@ -182,6 +182,22 @@ export const mapStopRegistryTransportModeTypeToUiName = genTranslationMapper< t(($) => $.stopRegistryTransportModeTypeEnum.water), }); +export const mapTransportModeToStopTypeName = genTranslationMapper< + JoreStopRegistryTransportModeType, + JoreStopRegistryTransportModeType | StopRegistryTransportModeType +>({ + [JoreStopRegistryTransportModeType.Bus]: (t) => + t(($) => $.stopDetails.hybrid.stopTypeName.bus), + [JoreStopRegistryTransportModeType.Metro]: (t) => + t(($) => $.stopDetails.hybrid.stopTypeName.metro), + [JoreStopRegistryTransportModeType.Rail]: (t) => + t(($) => $.stopDetails.hybrid.stopTypeName.rail), + [JoreStopRegistryTransportModeType.Tram]: (t) => + t(($) => $.stopDetails.hybrid.stopTypeName.tram), + [JoreStopRegistryTransportModeType.Water]: (t) => + t(($) => $.stopDetails.hybrid.stopTypeName.water), +}); + export const mapStopPlaceStateToUiName = genTranslationMapper({ [StopPlaceState.InOperation]: (t) => t(($) => $.stopPlaceStateEnum.InOperation), diff --git a/ui/src/locales/en-US/common.json b/ui/src/locales/en-US/common.json index 8219f44a84..d46764d199 100644 --- a/ui/src/locales/en-US/common.json +++ b/ui/src/locales/en-US/common.json @@ -536,9 +536,35 @@ "actions": { "showVersions": "Versions", "extra": { - "copy": "Make a new copy" + "copy": "Make a new copy", + "makeHybrid": "Make hybrid stop", + "makeHybridDisabled": "Stop is already a hybrid stop" } }, + "hybrid": { + "title": "Make hybrid stop", + "transportMode": "Transport mode to add", + "selectTransportMode": "Select", + "stopArea": "Search stop area", + "searchStopArea": "Search stop area", + "confirm": "Add", + "cancel": "Cancel", + "success": "Hybrid stop created successfully", + "error": "Failed to create hybrid stop: {{reason}}", + "stopTypeName": { + "bus": "Bus stop", + "tram": "Tram stop", + "metro": "Metro station", + "rail": "Train station", + "water": "Ferry stop" + }, + "removeButton": "Remove from hybrid", + "removeConfirmTitle": "Remove transport mode", + "removeConfirmDescription": "Are you sure you want to remove this transport mode from hybrid use?", + "removeConfirm": "Remove", + "removeCancel": "Cancel", + "removeSuccess": "Stop removed from hybrid use" + }, "version": { "copyBoilerPlate": "All properties from the existing stop will be copied over. You can edit the properties of the new copy after its creation.", "title": { diff --git a/ui/src/locales/fi-FI/common.json b/ui/src/locales/fi-FI/common.json index ab78900776..5dc69ae5ab 100644 --- a/ui/src/locales/fi-FI/common.json +++ b/ui/src/locales/fi-FI/common.json @@ -536,9 +536,35 @@ "actions": { "showVersions": "Versiot", "extra": { - "copy": "Kopioi uudeksi versioksi" + "copy": "Kopioi uudeksi versioksi", + "makeHybrid": "Tee yhteiskäyttöpysäkiksi", + "makeHybridDisabled": "Pysäkki on jo yhteiskäyttöpysäkki" } }, + "hybrid": { + "title": "Tee yhteiskäyttöpysäkiksi", + "transportMode": "Lisättävä joukkoliikennetyyppi", + "selectTransportMode": "Valitse", + "stopArea": "Hae pysäkkialue", + "searchStopArea": "Hae pysäkkialue", + "confirm": "Lisää", + "cancel": "Peruuta", + "success": "Yhteiskäyttöpysäkki luotu onnistuneesti", + "error": "Yhteiskäyttöpysäkin luonti epäonnistui: {{reason}}", + "stopTypeName": { + "bus": "Bussipysäkki", + "tram": "Raitiotiepysäkki", + "metro": "Metroasema", + "rail": "Juna-asema", + "water": "Lauttapysäkki" + }, + "removeButton": "Poista yhteiskäytöstä", + "removeConfirmTitle": "Poista liikennemuoto", + "removeConfirmDescription": "Haluatko varmasti poistaa tämän liikennemuodon yhteiskäytöstä?", + "removeConfirm": "Poista", + "removeCancel": "Peruuta", + "removeSuccess": "Pysäkki poistettu yhteiskäytöstä" + }, "version": { "copyBoilerPlate": "Pysäkin kaikki ominaisuudet kopioidaan mukana. Voit muokata uuden version ominaisuuksia kopioinnin jälkeen.", "title": { diff --git a/ui/src/redux/slices/loader.ts b/ui/src/redux/slices/loader.ts index a9c9c0fa37..6695804062 100644 --- a/ui/src/redux/slices/loader.ts +++ b/ui/src/redux/slices/loader.ts @@ -30,6 +30,7 @@ export enum Operation { ResolveScheduledStopPoint = 'resolveScheduledStopPoint', UpdateRouteJourneyPattern = 'updateRouteJourneyPattern', UpdateLine = 'updateLine', + CreateMirrorQuay = 'createMirrorQuay', } /** @@ -106,6 +107,7 @@ export const joreOperations = [ Operation.DeleteTimetable, Operation.UpdateRouteJourneyPattern, Operation.UpdateLine, + Operation.CreateMirrorQuay, ]; type IState = { diff --git a/ui/src/utils/knownValueKey.ts b/ui/src/utils/knownValueKey.ts index 1a92b01c3d..c48c3dd2cd 100644 --- a/ui/src/utils/knownValueKey.ts +++ b/ui/src/utils/knownValueKey.ts @@ -25,4 +25,5 @@ export enum KnownValueKey { TimingPlaceId = 'timingPlaceId', SpeedTramStop = 'speedTramStop', TrunkLineStop = 'trunkLineStop', + Mirrors = 'mirrors', } diff --git a/ui/src/utils/stop-registry/index.ts b/ui/src/utils/stop-registry/index.ts index df640fd4d5..10fcaa9d4a 100644 --- a/ui/src/utils/stop-registry/index.ts +++ b/ui/src/utils/stop-registry/index.ts @@ -1,4 +1,4 @@ -export * from './stopPlace'; export * from './alternativeNames'; export * from './buildSearchStopByLabelOrNameFilter'; +export * from './stopPlace'; export * from './transportMode'; diff --git a/ui/src/utils/stop-registry/mirrorRelation.spec.ts b/ui/src/utils/stop-registry/mirrorRelation.spec.ts new file mode 100644 index 0000000000..b882926547 --- /dev/null +++ b/ui/src/utils/stop-registry/mirrorRelation.spec.ts @@ -0,0 +1,70 @@ +import { KnownValueKey } from '../knownValueKey'; +import { + getMirrorParentId, + isMirrorChild, + setMirrorParent, +} from './mirrorRelation'; + +describe('Mirror relation utils', () => { + describe('getMirrorParentId', () => { + it('should return parent netexId when mirrors key exists', () => { + const quay = { + keyValues: [{ key: KnownValueKey.Mirrors, values: ['NSR:Quay:123'] }], + }; + expect(getMirrorParentId(quay)).toBe('NSR:Quay:123'); + }); + + it('should return null when no mirrors key exists', () => { + const quay = { + keyValues: [{ key: 'someOtherKey', values: ['value'] }], + }; + expect(getMirrorParentId(quay)).toBeNull(); + }); + + it('should return null for empty keyValues', () => { + expect(getMirrorParentId({ keyValues: [] })).toBeNull(); + expect(getMirrorParentId({ keyValues: undefined })).toBeNull(); + }); + }); + + describe('isMirrorChild', () => { + it('should return true when quay has mirrors key', () => { + const quay = { + keyValues: [{ key: KnownValueKey.Mirrors, values: ['NSR:Quay:123'] }], + }; + expect(isMirrorChild(quay)).toBe(true); + }); + + it('should return false when quay has no mirrors key', () => { + expect(isMirrorChild({ keyValues: [] })).toBe(false); + }); + }); + + describe('setMirrorParent', () => { + it('should set mirrors key on empty keyValues', () => { + const result = setMirrorParent([], 'NSR:Quay:123'); + expect(result).toEqual([ + { key: KnownValueKey.Mirrors, values: ['NSR:Quay:123'] }, + ]); + }); + + it('should update existing mirrors key', () => { + const existing = [ + { key: KnownValueKey.Mirrors, values: ['NSR:Quay:old'] }, + ]; + const result = setMirrorParent(existing, 'NSR:Quay:new'); + expect(result).toEqual([ + { key: KnownValueKey.Mirrors, values: ['NSR:Quay:new'] }, + ]); + }); + + it('should preserve other keyValues', () => { + const existing = [{ key: 'otherKey', values: ['otherValue'] }]; + const result = setMirrorParent(existing, 'NSR:Quay:123'); + expect(result).toEqual([ + { key: 'otherKey', values: ['otherValue'] }, + { key: KnownValueKey.Mirrors, values: ['NSR:Quay:123'] }, + ]); + }); + }); +}); diff --git a/ui/src/utils/stop-registry/mirrorRelation.ts b/ui/src/utils/stop-registry/mirrorRelation.ts new file mode 100644 index 0000000000..ca55459bdb --- /dev/null +++ b/ui/src/utils/stop-registry/mirrorRelation.ts @@ -0,0 +1,31 @@ +import omit from 'lodash/omit'; +import { + StopRegistryKeyValues, + StopRegistryKeyValuesInput, +} from '../../generated/graphql'; +import { ElementWithKeyValues, findKeyValue } from '../findKeyValue'; +import { KnownValueKey } from '../knownValueKey'; +import { setKeyValue } from './stopPlace'; + +export function stripKeyValueTypenames( + keyValues: ReadonlyArray, +): StopRegistryKeyValuesInput[] { + return keyValues + .filter(Boolean) + .map((kv) => omit(kv, '__typename') as StopRegistryKeyValuesInput); +} + +export function getMirrorParentId(quay: ElementWithKeyValues): string | null { + return findKeyValue(quay, KnownValueKey.Mirrors); +} + +export function isMirrorChild(quay: ElementWithKeyValues): boolean { + return getMirrorParentId(quay) !== null; +} + +export function setMirrorParent( + childKeyValues: ReadonlyArray | undefined, + parentNetexId: string, +): (StopRegistryKeyValues | null)[] { + return setKeyValue(childKeyValues, KnownValueKey.Mirrors, [parentNetexId]); +}