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.
This commit is contained in:
2026-04-15 20:38:39 +03:00
parent 2f1aacea4f
commit cbd47afd77
6 changed files with 81 additions and 43 deletions
+2 -2
View File
@@ -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"),
});
@@ -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<FlightsMapFilterProps> = ({
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<string[]>([]);
const [arrivalSuggestions, setArrivalSuggestions] = useState<string[]>([]);
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<FlightsMapFilterProps> = ({
);
const handleDateChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
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<FlightsMapFilterProps> = ({
return (
<div className="flights-map-filter" data-testid="flights-map-filter">
<div className="flights-map-filter__field">
<label htmlFor="fm-departure">Departure</label>
<input
id="fm-departure"
type="text"
placeholder="e.g. SVO"
maxLength={3}
<label htmlFor="fm-departure">{t("SHARED.DEPARTURE_CITY")}</label>
<AutoComplete
value={departure}
onChange={(e) => 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"
/>
</div>
@@ -122,33 +145,37 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
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"
>
&#8646;
</button>
<div className="flights-map-filter__field">
<label htmlFor="fm-arrival">Arrival</label>
<input
id="fm-arrival"
type="text"
placeholder="e.g. LED"
maxLength={3}
<label htmlFor="fm-arrival">{t("SHARED.ARRIVAL_CITY")}</label>
<AutoComplete
value={arrival}
onChange={(e) => 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"
/>
</div>
<div className="flights-map-filter__field">
<label htmlFor="fm-date">Date</label>
<input
id="fm-date"
type="date"
value={value.date ? yyyymmddToDateInput(value.date) : ""}
onChange={handleDateChange}
<label htmlFor="fm-date">{t("SHARED.FLIGHT_DATE")}</label>
<Calendar
value={value.date ? yyyymmddToDate(value.date) : null}
onChange={(e) => handleDateChange(e.value as Date | null)}
dateFormat="dd.mm.yy"
showIcon
className="input--filter"
inputId="fm-date"
data-testid="fm-date-input"
/>
</div>
@@ -161,7 +188,7 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
onChange={handleConnectionsChange}
data-testid="fm-connections-toggle"
/>
Connections
{t("FLIGHTS-MAP.CONNECTING_FLIGHTS")}
</label>
<label>
@@ -171,7 +198,7 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
onChange={handleDomesticChange}
data-testid="fm-domestic-toggle"
/>
Domestic
{t("FLIGHTS-MAP.DOMESTIC_FLIGHTS")}
</label>
<label>
@@ -181,7 +208,7 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
onChange={handleInternationalChange}
data-testid="fm-international-toggle"
/>
International
{t("FLIGHTS-MAP.INTERNATIONAL_FLIGHTS")}
</label>
</div>
</div>
@@ -131,7 +131,7 @@ export const FlightsMapStartPage: FC = () => {
<div className="flights-map-start-page" data-testid="flights-map-start">
<PageLayout
headerLeft={
<PageTabs viewType="flights-map" showFlightsMap />
<PageTabs viewType="flights-map" />
}
title={
<h1 className="text--white page-title">
@@ -168,6 +168,13 @@ export const MapCanvas: FC<MapCanvasProps> = ({
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<MapCanvasProps> = ({
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<MapCanvasProps> = ({
polylinesLayerRef.current = null;
popupsLayerRef.current = null;
};
// Only run once on mount -- stable props used from refs
}, []);
// --- Sync markers ---
+2
View File
@@ -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.
+5 -2
View File
@@ -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<PageTabsProps> = ({
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<PageTabsProps> = ({
</Link>
</div>
{showFlightsMap && (
{showMap && (
<div className="tabs__row">
<Link
className={`tabs__tab tabs__tab--full${viewType === "flights-map" ? " active" : ""}`}