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:
Vendored
+2
-2
@@ -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"
|
||||
>
|
||||
⇆
|
||||
</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 ---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
Reference in New Issue
Block a user