Skip to content

Commit a8660a8

Browse files
committed
Add hybrid stop create logic and components
1 parent ab8722d commit a8660a8

8 files changed

Lines changed: 584 additions & 4 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { FC, useMemo, useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { StopRegistryTransportModeType } from '../../../../../generated/graphql';
4+
import { mapStopRegistryTransportModeTypeToUiName } from '../../../../../i18n/uiNameMappings';
5+
import { StopWithDetails } from '../../../../../types';
6+
import {
7+
JoreListbox,
8+
ListboxOptionItem,
9+
Modal,
10+
ModalBody,
11+
ModalHeader,
12+
NewModalFooter,
13+
SimpleButton,
14+
} from '../../../../../uiComponents';
15+
import { parseVehicleMode } from '../../../../../utils';
16+
import {
17+
showDangerToastWithError,
18+
showSuccessToast,
19+
} from '../../../../../utils/toastService';
20+
import { StopModalStopAreaFormSchema } from '../../../../forms/stop/types';
21+
import { StopAreaSearchCombobox } from './StopAreaSearchCombobox';
22+
import { useCreateMirrorQuay } from './useCreateMirrorQuay';
23+
24+
const testIds = {
25+
modal: 'MakeHybridStopModal',
26+
transportModeDropdown: 'MakeHybridStopModal::transportMode',
27+
stopAreaInput: 'MakeHybridStopModal::stopAreaInput',
28+
stopAreaOption: (code: string) => `MakeHybridStopModal::stopArea::${code}`,
29+
confirmButton: 'MakeHybridStopModal::confirm',
30+
cancelButton: 'MakeHybridStopModal::cancel',
31+
};
32+
33+
const SUPPORTED_TRANSPORT_MODES = [
34+
StopRegistryTransportModeType.Bus,
35+
StopRegistryTransportModeType.Tram,
36+
] as const;
37+
38+
type MakeHybridStopModalProps = {
39+
readonly isOpen: boolean;
40+
readonly onClose: () => void;
41+
readonly parentStop: StopWithDetails | null;
42+
};
43+
44+
export const MakeHybridStopModal: FC<MakeHybridStopModalProps> = ({
45+
isOpen,
46+
onClose,
47+
parentStop,
48+
}) => {
49+
const { t } = useTranslation();
50+
51+
const [selectedMode, setSelectedMode] =
52+
useState<StopRegistryTransportModeType | null>(null);
53+
const [selectedStopArea, setSelectedStopArea] =
54+
useState<StopModalStopAreaFormSchema | null>(null);
55+
56+
const { createMirrorQuay, loading: saving } = useCreateMirrorQuay();
57+
58+
const currentTransportMode = parentStop?.stop_place?.transportMode ?? null;
59+
60+
const availableModes = useMemo(
61+
() =>
62+
SUPPORTED_TRANSPORT_MODES.filter((mode) => mode !== currentTransportMode),
63+
[currentTransportMode],
64+
);
65+
66+
const transportModeOptions: ReadonlyArray<
67+
ListboxOptionItem<StopRegistryTransportModeType>
68+
> = useMemo(
69+
() =>
70+
availableModes.map((mode) => ({
71+
value: mode,
72+
content: mapStopRegistryTransportModeTypeToUiName(t, mode),
73+
})),
74+
[availableModes, t],
75+
);
76+
77+
const vehicleMode = selectedMode ? parseVehicleMode(selectedMode) : null;
78+
79+
const handleModeChange = (mode: StopRegistryTransportModeType) => {
80+
setSelectedMode(mode);
81+
setSelectedStopArea(null);
82+
};
83+
84+
const resetState = () => {
85+
setSelectedMode(null);
86+
setSelectedStopArea(null);
87+
};
88+
89+
const handleClose = () => {
90+
resetState();
91+
onClose();
92+
};
93+
94+
const handleConfirm = async () => {
95+
if (!parentStop || !selectedStopArea || !selectedMode || !vehicleMode) {
96+
return;
97+
}
98+
99+
try {
100+
const success = await createMirrorQuay({
101+
targetStopPlaceId: selectedStopArea.netexId,
102+
parentStop,
103+
vehicleMode,
104+
});
105+
106+
if (success) {
107+
showSuccessToast(t(($) => $.stopDetails.hybrid.success));
108+
handleClose();
109+
}
110+
} catch (err) {
111+
showDangerToastWithError(
112+
t(($) => $.stopDetails.hybrid.error),
113+
err,
114+
);
115+
}
116+
};
117+
118+
const canConfirm = !!selectedMode && !!selectedStopArea && !saving;
119+
120+
return (
121+
<Modal
122+
isOpen={isOpen}
123+
onClose={handleClose}
124+
contentClassName="w-1/3"
125+
testId={testIds.modal}
126+
>
127+
<ModalHeader
128+
onClose={handleClose}
129+
heading={t(($) => $.stopDetails.hybrid.title)}
130+
/>
131+
<ModalBody className="space-y-4">
132+
<div>
133+
<label className="mb-1 block text-sm font-bold">
134+
{t(($) => $.stopDetails.hybrid.transportMode)}
135+
</label>
136+
<JoreListbox<StopRegistryTransportModeType>
137+
buttonContent={
138+
selectedMode
139+
? mapStopRegistryTransportModeTypeToUiName(t, selectedMode)
140+
: t(($) => $.stopDetails.hybrid.selectTransportMode)
141+
}
142+
options={transportModeOptions}
143+
value={selectedMode ?? undefined}
144+
onChange={handleModeChange}
145+
testId={testIds.transportModeDropdown}
146+
/>
147+
</div>
148+
149+
<div>
150+
<label className="mb-1 block text-sm font-bold">
151+
{t(($) => $.stopDetails.hybrid.stopArea)}
152+
</label>
153+
<StopAreaSearchCombobox
154+
vehicleMode={vehicleMode}
155+
value={selectedStopArea}
156+
onChange={setSelectedStopArea}
157+
disabled={!selectedMode}
158+
inputTestId={testIds.stopAreaInput}
159+
optionTestId={testIds.stopAreaOption}
160+
/>
161+
</div>
162+
</ModalBody>
163+
<NewModalFooter>
164+
<SimpleButton
165+
inverted
166+
onClick={handleClose}
167+
testId={testIds.cancelButton}
168+
>
169+
{t(($) => $.stopDetails.hybrid.cancel)}
170+
</SimpleButton>
171+
<SimpleButton
172+
onClick={handleConfirm}
173+
disabled={!canConfirm}
174+
testId={testIds.confirmButton}
175+
>
176+
{saving ? '...' : t(($) => $.stopDetails.hybrid.confirm)}
177+
</SimpleButton>
178+
</NewModalFooter>
179+
</Modal>
180+
);
181+
};
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import {
2+
Combobox,
3+
ComboboxButton,
4+
ComboboxInput,
5+
ComboboxOption,
6+
ComboboxOptions,
7+
} from '@headlessui/react';
8+
import debounce from 'lodash/debounce';
9+
import { FC, useEffect, useMemo, useState } from 'react';
10+
import { useTranslation } from 'react-i18next';
11+
import { MdOutlineSearch } from 'react-icons/md';
12+
import { ReusableComponentsVehicleModeEnum } from '../../../../../generated/graphql';
13+
import { comboboxStyles } from '../../../../../uiComponents';
14+
import { StopModalStopAreaFormSchema } from '../../../../forms/stop/types';
15+
import {
16+
formatIsoDateString,
17+
useFindStopAreas,
18+
} from '../../../../forms/stop/utils';
19+
20+
type StopAreaSearchComboboxProps = {
21+
readonly vehicleMode: ReusableComponentsVehicleModeEnum | null | undefined;
22+
readonly value: StopModalStopAreaFormSchema | null;
23+
readonly onChange: (value: StopModalStopAreaFormSchema | null) => void;
24+
readonly disabled: boolean;
25+
readonly inputTestId: string;
26+
readonly optionTestId: (code: string) => string;
27+
};
28+
29+
export const StopAreaSearchCombobox: FC<StopAreaSearchComboboxProps> = ({
30+
vehicleMode,
31+
value,
32+
onChange,
33+
disabled,
34+
inputTestId,
35+
optionTestId,
36+
}) => {
37+
const { t } = useTranslation();
38+
39+
const [query, setQuery] = useState('');
40+
const [queryDebounced, setQueryDebounced] = useState(false);
41+
const { areas, loading: loadingAreas } = useFindStopAreas(query, vehicleMode);
42+
43+
useEffect(() => {
44+
if (!loadingAreas) {
45+
setQueryDebounced(false);
46+
}
47+
}, [loadingAreas]);
48+
49+
const onQueryChange = useMemo(() => {
50+
const debouncedSetQuery = debounce(setQuery, 500);
51+
return (newQuery: string) => {
52+
if (newQuery === '') {
53+
debouncedSetQuery.cancel();
54+
setQueryDebounced(false);
55+
setQuery('');
56+
} else {
57+
setQueryDebounced(true);
58+
debouncedSetQuery(newQuery);
59+
}
60+
};
61+
}, []);
62+
63+
const areaSearchLoading = loadingAreas || queryDebounced;
64+
65+
return (
66+
<Combobox
67+
as="div"
68+
className={comboboxStyles.root('flex flex-col')}
69+
value={value}
70+
onChange={onChange}
71+
disabled={disabled}
72+
>
73+
<div className="flex h-(--input-height)">
74+
<ComboboxInput<StopModalStopAreaFormSchema>
75+
className={comboboxStyles.input(
76+
'grow border-r-0 outline-0 ui-not-open:rounded-tr-none ui-not-open:rounded-br-none',
77+
'ui-open:rounded-bl-none',
78+
)}
79+
onChange={(e) => onQueryChange(e.target.value)}
80+
displayValue={(it) => it?.nameFin ?? ''}
81+
autoComplete="off"
82+
placeholder={t(($) => $.stopDetails.hybrid.searchStopArea)}
83+
data-testid={inputTestId}
84+
/>
85+
<ComboboxButton
86+
disabled={!query}
87+
className={comboboxStyles.button(
88+
'static flex h-(--input-height) w-(--input-height) justify-center rounded-tr-[5px] rounded-br-[5px] bg-tweaked-brand text-xl',
89+
'ui-open:rounded-br-none',
90+
)}
91+
>
92+
<MdOutlineSearch color="white" />
93+
</ComboboxButton>
94+
</div>
95+
<ComboboxOptions
96+
anchor="bottom start"
97+
className={comboboxStyles.options(
98+
'min-w-[calc(var(--button-width)+var(--input-width))]',
99+
)}
100+
transition
101+
>
102+
{areaSearchLoading && (
103+
<ComboboxOption
104+
className={comboboxStyles.option()}
105+
value={null}
106+
disabled
107+
>
108+
{t(($) => $.stops.stopArea.label)}
109+
</ComboboxOption>
110+
)}
111+
112+
{areas.map((area) => (
113+
<ComboboxOption
114+
className={comboboxStyles.option()}
115+
key={area.netexId}
116+
value={area}
117+
data-testid={optionTestId(area.privateCode)}
118+
>
119+
<span className="shrink-0 self-start font-bold">
120+
{area.privateCode}
121+
</span>
122+
<div className="mx-2 flex grow flex-col">
123+
<span>{area.nameFin ?? area.nameSwe}</span>
124+
<span className="font-bold">
125+
{`${formatIsoDateString(area.validityStart)} - ${formatIsoDateString(area.validityEnd)}`}
126+
</span>
127+
</div>
128+
</ComboboxOption>
129+
))}
130+
131+
{!query && !areaSearchLoading && (
132+
<ComboboxOption
133+
className={comboboxStyles.option()}
134+
value={null}
135+
disabled
136+
>
137+
{t(($) => $.stops.stopArea.help)}
138+
</ComboboxOption>
139+
)}
140+
</ComboboxOptions>
141+
</Combobox>
142+
);
143+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { MakeHybridStopModal } from './MakeHybridStopModal';

0 commit comments

Comments
 (0)