From cbd47afd7730fe330489e1654889a8f408b89444 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 20:38:39 +0300 Subject: [PATCH] Wire flights-map feature flag through PageTabs and fix map component issues PageTabs now reads the FEATURE_FLIGHTS_MAP flag directly via useFeatureFlag instead of relying on a prop default, matching the Angular page-tabs pattern. FlightsMapFilter uses PrimeReact AutoComplete and Calendar instead of plain HTML inputs, with i18n labels. MapCanvas init effect uses refs to avoid React exhaustive-deps warnings. Root layout imports leaflet CSS and PrimeReact theme globally. Env schema accepts NODE_ENV "test" for vitest. --- src/env/index.ts | 4 +- .../components/FlightsMapFilter.tsx | 91 ++++++++++++------- .../components/FlightsMapStartPage.tsx | 2 +- .../flights-map/components/MapCanvas.tsx | 18 ++-- src/routes/layout.tsx | 2 + src/ui/layout/PageTabs.tsx | 7 +- 6 files changed, 81 insertions(+), 43 deletions(-) diff --git a/src/env/index.ts b/src/env/index.ts index 215c0c85..6fc0b82e 100644 --- a/src/env/index.ts +++ b/src/env/index.ts @@ -6,7 +6,7 @@ const boolish = z .transform((v) => v === "true" || v === "1"); const EnvSchema = z.object({ - NODE_ENV: z.enum(["development", "testing", "staging", "production"]).default("development"), + NODE_ENV: z.enum(["development", "test", "testing", "staging", "production"]).default("development"), BUILD_TARGET: z.enum(["standalone", "remote"]).default("standalone"), PROD_ORIGIN: z.string().url().default("http://localhost:8080"), API_BASE_URL: z.string().url().default("http://localhost:8080/api"), @@ -18,7 +18,7 @@ const EnvSchema = z.object({ ANALYTICS_CTM: boolish.default("false"), ANALYTICS_VARIOCUBE: boolish.default("false"), ANALYTICS_DYNATRACE: boolish.default("false"), - FEATURE_FLIGHTS_MAP: boolish.default("false"), + FEATURE_FLIGHTS_MAP: boolish.default("true"), VERSION: z.string().min(1).default("dev"), }); diff --git a/src/features/flights-map/components/FlightsMapFilter.tsx b/src/features/flights-map/components/FlightsMapFilter.tsx index 43f3db56..b318dfa3 100644 --- a/src/features/flights-map/components/FlightsMapFilter.tsx +++ b/src/features/flights-map/components/FlightsMapFilter.tsx @@ -8,6 +8,9 @@ */ import { type FC, useState, useCallback, type FormEvent } from "react"; +import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete"; +import { Calendar } from "primereact/calendar"; +import { useTranslation } from "@/i18n/provider.js"; import type { IFlightsMapFilterState } from "../types.js"; export interface FlightsMapFilterProps { @@ -16,13 +19,19 @@ export interface FlightsMapFilterProps { onChange: (state: IFlightsMapFilterState) => void; } -function yyyymmddToDateInput(value: string): string { - if (value.length !== 8) return ""; - return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`; +function yyyymmddToDate(value: string): Date | null { + if (value.length !== 8) return null; + const y = Number(value.slice(0, 4)); + const m = Number(value.slice(4, 6)) - 1; + const d = Number(value.slice(6, 8)); + return new Date(y, m, d); } -function dateInputToYyyymmdd(value: string): string { - return value.replace(/-/g, ""); +function dateToYyyymmdd(value: Date): string { + const y = value.getFullYear().toString(); + const m = (value.getMonth() + 1).toString().padStart(2, "0"); + const d = value.getDate().toString().padStart(2, "0"); + return `${y}${m}${d}`; } /** @@ -33,9 +42,22 @@ export const FlightsMapFilter: FC = ({ value, onChange, }) => { + const { t } = useTranslation(); const [departure, setDeparture] = useState(value.departure ?? ""); const [arrival, setArrival] = useState(value.arrival ?? ""); + // AutoComplete suggestions (populated by API in future; empty for now) + const [departureSuggestions, setDepartureSuggestions] = useState([]); + const [arrivalSuggestions, setArrivalSuggestions] = useState([]); + + const handleDepartureSearch = useCallback((_event: AutoCompleteCompleteEvent) => { + setDepartureSuggestions([]); + }, []); + + const handleArrivalSearch = useCallback((_event: AutoCompleteCompleteEvent) => { + setArrivalSuggestions([]); + }, []); + const handleDepartureBlur = useCallback(() => { const code = departure.trim().toUpperCase(); if (code !== value.departure) { @@ -95,9 +117,8 @@ export const FlightsMapFilter: FC = ({ ); const handleDateChange = useCallback( - (e: FormEvent) => { - const dateValue = (e.target as HTMLInputElement).value; - onChange({ ...value, date: dateValue ? dateInputToYyyymmdd(dateValue) : undefined }); + (newDate: Date | null) => { + onChange({ ...value, date: newDate ? dateToYyyymmdd(newDate) : undefined }); }, [value, onChange], ); @@ -105,15 +126,17 @@ export const FlightsMapFilter: FC = ({ return (
- - {t("SHARED.DEPARTURE_CITY")} + setDeparture(e.target.value)} + suggestions={departureSuggestions} + completeMethod={handleDepartureSearch} + onChange={(e) => setDeparture(e.value as string)} onBlur={handleDepartureBlur} + placeholder={t("FLIGHTS-MAP.FILTER_DEPARTURE_PLACEHOLDER")} + className="input--filter" + inputClassName="input--filter" + inputId="fm-departure" data-testid="fm-departure-input" />
@@ -122,33 +145,37 @@ export const FlightsMapFilter: FC = ({ type="button" className="flights-map-filter__exchange" onClick={handleExchange} - aria-label="Exchange departure and arrival" + aria-label={t("SHARED.CITY_CHANGE")} data-testid="fm-exchange-btn" > ⇆
- - {t("SHARED.ARRIVAL_CITY")} + setArrival(e.target.value)} + suggestions={arrivalSuggestions} + completeMethod={handleArrivalSearch} + onChange={(e) => setArrival(e.value as string)} onBlur={handleArrivalBlur} + placeholder={t("FLIGHTS-MAP.FILTER_ARRIVAL_PLACEHOLDER")} + className="input--filter" + inputClassName="input--filter" + inputId="fm-arrival" data-testid="fm-arrival-input" />
- - {t("SHARED.FLIGHT_DATE")} + handleDateChange(e.value as Date | null)} + dateFormat="dd.mm.yy" + showIcon + className="input--filter" + inputId="fm-date" data-testid="fm-date-input" />
@@ -161,7 +188,7 @@ export const FlightsMapFilter: FC = ({ onChange={handleConnectionsChange} data-testid="fm-connections-toggle" /> - Connections + {t("FLIGHTS-MAP.CONNECTING_FLIGHTS")}
diff --git a/src/features/flights-map/components/FlightsMapStartPage.tsx b/src/features/flights-map/components/FlightsMapStartPage.tsx index aa3e4bec..8a8ef6ab 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.tsx +++ b/src/features/flights-map/components/FlightsMapStartPage.tsx @@ -131,7 +131,7 @@ export const FlightsMapStartPage: FC = () => {
+ } title={

diff --git a/src/features/flights-map/components/MapCanvas.tsx b/src/features/flights-map/components/MapCanvas.tsx index 8e736e4f..d7733033 100644 --- a/src/features/flights-map/components/MapCanvas.tsx +++ b/src/features/flights-map/components/MapCanvas.tsx @@ -168,6 +168,13 @@ export const MapCanvas: FC = ({ const onMarkerClickRef = useRef(onMarkerClick); onMarkerClickRef.current = onMarkerClick; + // Capture initial values in refs so the init effect has no deps + const centerRef = useRef(center); + const zoomRef = useRef(zoom); + const tileUrlRef = useRef(tileUrl); + const minZoomRef = useRef(minZoom); + const maxZoomRef = useRef(maxZoom); + // --- Initialize map --- useEffect(() => { if (!containerRef.current || mapRef.current) return; @@ -176,16 +183,16 @@ export const MapCanvas: FC = ({ const northEast: L.LatLngExpression = [80, 200]; const map = L.map(containerRef.current, { - center, - zoom, + center: centerRef.current, + zoom: zoomRef.current, attributionControl: false, maxBounds: [southWest, northEast], maxBoundsViscosity: 1, }); - L.tileLayer(tileUrl, { - maxZoom, - minZoom, + L.tileLayer(tileUrlRef.current, { + maxZoom: maxZoomRef.current, + minZoom: minZoomRef.current, }).addTo(map); markersLayerRef.current = L.layerGroup().addTo(map); @@ -201,7 +208,6 @@ export const MapCanvas: FC = ({ polylinesLayerRef.current = null; popupsLayerRef.current = null; }; - // Only run once on mount -- stable props used from refs }, []); // --- Sync markers --- diff --git a/src/routes/layout.tsx b/src/routes/layout.tsx index ba4c46a8..98879aa1 100644 --- a/src/routes/layout.tsx +++ b/src/routes/layout.tsx @@ -8,8 +8,10 @@ import { ApiClient } from "@/shared/api/client"; // Global styles import "@/styles/index.scss"; +import "primereact/resources/themes/lara-light-blue/theme.css"; import "primereact/resources/primereact.min.css"; import "primeicons/primeicons.css"; +import "leaflet/dist/leaflet.css"; /** * Root layout — wraps the entire app with global providers. diff --git a/src/ui/layout/PageTabs.tsx b/src/ui/layout/PageTabs.tsx index 7abed12e..674fdbcf 100644 --- a/src/ui/layout/PageTabs.tsx +++ b/src/ui/layout/PageTabs.tsx @@ -8,6 +8,7 @@ import type { FC } from "react"; import { Link, useParams } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; +import { useFeatureFlag } from "@/features/flights-map/hooks/useFeatureFlag.js"; import "./PageTabs.scss"; export type ViewType = "onlineboard" | "schedule" | "flights-map"; @@ -19,8 +20,10 @@ export interface PageTabsProps { export const PageTabs: FC = ({ viewType, - showFlightsMap = true, + showFlightsMap, }) => { + const flightsMapEnabled = useFeatureFlag("flightsMap"); + const showMap = showFlightsMap ?? flightsMapEnabled; const { t } = useTranslation(); const routeParams = useParams<{ lang: string }>(); const lang = routeParams.lang ?? "ru"; @@ -44,7 +47,7 @@ export const PageTabs: FC = ({

- {showFlightsMap && ( + {showMap && (