From afcafa87a36bae8af62df2710a39fa94dd549179 Mon Sep 17 00:00:00 2001 From: Felix <23635466+its-felix@users.noreply.github.com> Date: Mon, 18 Nov 2024 05:22:11 +0100 Subject: [PATCH] add flight search --- go/api/data/query_option.go | 4 +- go/api/main.go | 1 + go/api/web/data.go | 7 + go/api/web/schedule.go | 15 +- .../select/aircraft-multiselect.tsx | 4 +- ui/src/components/select/aircraft-select.tsx | 86 +++ .../components/select/airline-multiselect.tsx | 56 ++ ui/src/components/select/airport-select.tsx | 127 ++++ ui/src/components/sidenav/sidenav.tsx | 1 + ui/src/components/util/state/data.ts | 44 +- .../util/state/use-route-context.ts | 5 + ui/src/lib/api/api.model.ts | 15 + ui/src/lib/api/api.ts | 50 +- ui/src/pages/allegris.tsx | 6 +- ui/src/pages/index.tsx | 5 + ui/src/pages/tools/flight-search.tsx | 576 ++++++++++++++++++ 16 files changed, 990 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/select/aircraft-select.tsx create mode 100644 ui/src/components/select/airline-multiselect.tsx create mode 100644 ui/src/components/select/airport-select.tsx create mode 100644 ui/src/pages/tools/flight-search.tsx diff --git a/go/api/data/query_option.go b/go/api/data/query_option.go index 0a51b30..42fab0b 100644 --- a/go/api/data/query_option.go +++ b/go/api/data/query_option.go @@ -232,7 +232,7 @@ func WithAll(opts ...QueryScheduleOption) QueryScheduleOption { childs := make([]querySchedulesOptions, len(opts)) for i, opt := range opts { - err = errors.Join(err, opt(&childs[i])) + err = errors.Join(err, childs[i].apply(opt)) } return errors.Join(err, o.and(childs)) @@ -249,7 +249,7 @@ func WithAny(opts ...QueryScheduleOption) QueryScheduleOption { childs := make([]querySchedulesOptions, len(opts)) for i, opt := range opts { - err = errors.Join(err, opt(&childs[i])) + err = errors.Join(err, childs[i].apply(opt)) } return errors.Join(err, o.or(childs)) diff --git a/go/api/main.go b/go/api/main.go index 4685b83..b85daea 100644 --- a/go/api/main.go +++ b/go/api/main.go @@ -77,6 +77,7 @@ func main() { e.GET("/auth/oauth2/code/:issuer", authHandler.Code) e.GET("/data/sitemap.xml", web.NewSitemapHandler(dataHandler)) + e.GET("/data/airlines.json", web.NewAirlinesEndpoint(dataHandler)) e.GET("/data/airports.json", web.NewAirportsEndpoint(dataHandler)) e.GET("/data/aircraft.json", web.NewAircraftEndpoint(dataHandler)) e.GET("/data/flight/:fn", web.NewFlightNumberEndpoint(dataHandler)) diff --git a/go/api/web/data.go b/go/api/web/data.go index c86a5d3..09c3af3 100644 --- a/go/api/web/data.go +++ b/go/api/web/data.go @@ -13,6 +13,13 @@ import ( "time" ) +func NewAirlinesEndpoint(dh *data.Handler) echo.HandlerFunc { + return func(c echo.Context) error { + airlines, err := dh.Airlines(c.Request().Context(), "") + return jsonResponse(c, airlines, err, func(v []common.AirlineIdentifier) bool { return false }) + } +} + func NewAirportsEndpoint(dh *data.Handler) echo.HandlerFunc { return func(c echo.Context) error { airports, err := dh.Airports(c.Request().Context()) diff --git a/go/api/web/schedule.go b/go/api/web/schedule.go index b7ef503..81f2d21 100644 --- a/go/api/web/schedule.go +++ b/go/api/web/schedule.go @@ -16,14 +16,19 @@ func NewQueryFlightSchedulesEndpoint(dh *data.Handler) echo.HandlerFunc { data.WithIgnoreCodeShares(), } + highFrequencyFilters := 0 for k, values := range c.QueryParams() { if len(values) < 1 { continue } subOpts := make([]data.QueryScheduleOption, 0, len(values)) + isHighFrequency := false + switch k { case "airline": + isHighFrequency = true + for _, value := range values { subOpts = append(subOpts, data.WithAirlines(common.AirlineIdentifier(value))) } @@ -69,6 +74,8 @@ func NewQueryFlightSchedulesEndpoint(dh *data.Handler) echo.HandlerFunc { } case "minDepartureTime": + isHighFrequency = true + minDepartureTime, err := time.Parse(time.RFC3339, values[0]) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) @@ -77,6 +84,8 @@ func NewQueryFlightSchedulesEndpoint(dh *data.Handler) echo.HandlerFunc { subOpts = append(subOpts, data.WithMinDepartureTime(minDepartureTime)) case "maxDepartureTime": + isHighFrequency = true + maxDepartureTime, err := time.Parse(time.RFC3339, values[0]) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) @@ -87,10 +96,14 @@ func NewQueryFlightSchedulesEndpoint(dh *data.Handler) echo.HandlerFunc { if len(subOpts) > 0 { options = append(options, data.WithAny(subOpts...)) + + if isHighFrequency { + highFrequencyFilters++ + } } } - if len(options) < 3 { + if (len(options) - highFrequencyFilters) < 3 { return echo.NewHTTPError(http.StatusBadRequest, "too few filters") } diff --git a/ui/src/components/select/aircraft-multiselect.tsx b/ui/src/components/select/aircraft-multiselect.tsx index 3cf9cc3..4819d6c 100644 --- a/ui/src/components/select/aircraft-multiselect.tsx +++ b/ui/src/components/select/aircraft-multiselect.tsx @@ -8,9 +8,10 @@ export interface AircraftMultiselectProps { loading: boolean; disabled: boolean; onChange: (options: ReadonlyArray) => void; + placeholder?: string; } -export function AircraftMultiselect({ aircraft, selectedAircraftCodes, loading, disabled, onChange }: AircraftMultiselectProps) { +export function AircraftMultiselect({ aircraft, selectedAircraftCodes, loading, disabled, onChange, placeholder }: AircraftMultiselectProps) { const [options, optionByAircraftCode] = useMemo(() => { const optionByAircraftCode: Record = {}; const otherOptions: Array = []; @@ -85,6 +86,7 @@ export function AircraftMultiselect({ aircraft, selectedAircraftCodes, loading, tokenLimit={2} disabled={disabled} statusType={loading ? 'loading' : 'finished'} + placeholder={placeholder} /> ); } \ No newline at end of file diff --git a/ui/src/components/select/aircraft-select.tsx b/ui/src/components/select/aircraft-select.tsx new file mode 100644 index 0000000..d93e9fc --- /dev/null +++ b/ui/src/components/select/aircraft-select.tsx @@ -0,0 +1,86 @@ +import { Aircraft } from '../../lib/api/api.model'; +import React, { useMemo } from 'react'; +import { Select, SelectProps } from '@cloudscape-design/components'; + +export interface AircraftSelectProps { + aircraft: ReadonlyArray; + selectedAircraftCode: string | null; + loading: boolean; + disabled: boolean; + onChange: (v: string | null) => void; + placeholder?: string; +} + +export function AircraftSelect({ aircraft, selectedAircraftCode, loading, disabled, onChange, placeholder }: AircraftSelectProps) { + const [options, optionByAircraftCode] = useMemo(() => { + const optionByAircraftCode: Record = {}; + const otherOptions: Array = []; + const groups: ReadonlyArray<{ name: string, options: Array }> = [ + { name: 'Airbus', options: [] }, + { name: 'Boeing', options: [] }, + { name: 'Embraer', options: [] }, + { name: 'BAE Systems', options: [] }, + { name: 'Antonov', options: [] }, + { name: 'Bombardier', options: [] }, + { name: 'Tupolev', options: [] }, + ]; + + for (const a of aircraft) { + const option = { + label: a.name, + value: a.code, + description: a.equipCode, + } satisfies SelectProps.Option; + + let addedToGroup = false; + for (const group of groups) { + if (a.name.toLowerCase().includes(group.name.toLowerCase())) { + group.options.push(option); + addedToGroup = true; + break; + } + } + + if (!addedToGroup) { + otherOptions.push(option); + } + + optionByAircraftCode[a.code] = option; + } + + const options: Array = []; + for (const group of groups) { + if (group.options.length > 0) { + options.push({ + label: group.name, + options: group.options, + }); + } + } + + options.push(...otherOptions); + + return [options, optionByAircraftCode]; + }, [aircraft]); + + const selectedOption = useMemo(() => { + if (!selectedAircraftCode) { + return null; + } + + return optionByAircraftCode[selectedAircraftCode]; + }, [optionByAircraftCode, selectedAircraftCode]); + + return ( + onChange(e.detail.selectedOption?.value ?? null)} + virtualScroll={true} + disabled={disabled} + statusType={loading ? 'loading' : 'finished'} + filteringType={'manual'} + onLoadItems={(e) => setFilterText(e.detail.filteringText)} + placeholder={placeholder} + /> + ); +} + +function filterOptions(filter: string, options: ReadonlyArray): [ReadonlyArray, boolean] { + const matchByLabel: Array = []; + const matchByDescription: Array = []; + + for (const option of options) { + const label = option.label?.toUpperCase(); + const description = option.description?.toUpperCase(); + let matched = false; + + if (label?.includes(filter)) { + matchByLabel.push(option); + matched = true; + } else if (isOptionGroup(option)) { + const [filtered, anyMatchedByLabel] = filterOptions(filter, option.options); + if (filtered.length > 0) { + const filteredOption = { + ...option, + options: filtered, + } satisfies SelectProps.OptionGroup; + + if (anyMatchedByLabel) { + matchByLabel.push(filteredOption); + } else { + matchByDescription.push(filteredOption); + } + + matched = true; + } + } + + if (!matched && description?.includes(filter)) { + matchByDescription.push(option); + } + } + + return [[...matchByLabel, ...matchByDescription], matchByLabel.length > 0]; +} + +function isOptionGroup(option: SelectProps.Option | SelectProps.OptionGroup): option is SelectProps.OptionGroup { + return !!(option as SelectProps.OptionGroup).options; +} \ No newline at end of file diff --git a/ui/src/components/sidenav/sidenav.tsx b/ui/src/components/sidenav/sidenav.tsx index 614adb2..ff6d683 100644 --- a/ui/src/components/sidenav/sidenav.tsx +++ b/ui/src/components/sidenav/sidenav.tsx @@ -19,6 +19,7 @@ export function SideNav() { type: 'section-group', title: 'Tools', items: [ + { type: 'link', text: 'Flight Search', href: useHref('/tools/flight-search') }, { type: 'link', text: 'M&M Quick Search', href: useHref('/tools/mm-quick-search') }, { type: 'link', text: 'Links', href: useHref('/tools/links') }, ], diff --git a/ui/src/components/util/state/data.ts b/ui/src/components/util/state/data.ts index afbf395..ec17614 100644 --- a/ui/src/components/util/state/data.ts +++ b/ui/src/components/util/state/data.ts @@ -2,6 +2,20 @@ import { useHttpClient } from '../context/http-client'; import { useQuery } from '@tanstack/react-query'; import { ApiError, expectSuccess } from '../../../lib/api/api'; import { DateTime } from 'luxon'; +import { QuerySchedulesRequest } from '../../../lib/api/api.model'; + +export function useAirlines() { + const { apiClient } = useHttpClient(); + return useQuery({ + queryKey: ['airlines'], + queryFn: async () => { + const { body } = expectSuccess(await apiClient.getAirlines()); + return body; + }, + retry: 5, + initialData: [], + }); +} export function useAirports() { const { apiClient } = useHttpClient(); @@ -79,12 +93,36 @@ export function useSeatMap(flightNumber: string, }); } -export function useQueryFlightSchedules(airline: string, aircraftType: string, aircraftConfigurationVersion: string) { +export function useFlightSchedulesByConfiguration(airline: string, aircraftType: string, aircraftConfigurationVersion: string) { const { apiClient } = useHttpClient(); return useQuery({ - queryKey: ['query_flight_schedules', airline, aircraftType, aircraftConfigurationVersion], + queryKey: ['flight_schedules_by_configuration', airline, aircraftType, aircraftConfigurationVersion], queryFn: async () => { - const { body } = expectSuccess(await apiClient.queryFlightSchedules(airline, aircraftType, aircraftConfigurationVersion)); + const { body } = expectSuccess(await apiClient.getFlightSchedulesByConfiguration(airline, aircraftType, aircraftConfigurationVersion)); + return body; + }, + retry: (count, e) => { + if (count > 3) { + return false; + } else if (e instanceof ApiError && (e.response.status === 400 || e.response.status === 404)) { + return false; + } + + return true; + }, + }); +} + +export function useQueryFlightSchedules(req: QuerySchedulesRequest) { + const { apiClient } = useHttpClient(); + return useQuery({ + queryKey: ['query_flight_schedules', req], + queryFn: async () => { + if (Object.entries(req).length < 1) { + return {}; + } + + const { body } = expectSuccess(await apiClient.queryFlightSchedules(req)); return body; }, retry: (count, e) => { diff --git a/ui/src/components/util/state/use-route-context.ts b/ui/src/components/util/state/use-route-context.ts index 1152197..af28239 100644 --- a/ui/src/components/util/state/use-route-context.ts +++ b/ui/src/components/util/state/use-route-context.ts @@ -43,6 +43,11 @@ const ROUTES = [{ { path: 'tools', children: [ + { + path: 'flight-search', + title: 'Flight Search', + breadcrumb: 'Flight Search', + }, { path: 'mm-quick-search', title: 'M&M Quick Search', diff --git a/ui/src/lib/api/api.model.ts b/ui/src/lib/api/api.model.ts index 7833f6b..59ad30f 100644 --- a/ui/src/lib/api/api.model.ts +++ b/ui/src/lib/api/api.model.ts @@ -1,3 +1,5 @@ +import { DateTime } from 'luxon'; + export type JsonObject = { [k: string]: JsonType }; export type JsonArray = ReadonlyArray; export type JsonType = JsonObject | JsonArray | string | number | boolean | null; @@ -94,6 +96,7 @@ export interface Aircraft { code: string; equipCode: string; name: string; + configurations: ReadonlyArray; } export interface ConnectionSearchShare { @@ -242,6 +245,18 @@ export enum ComponentFeature { TABLE = 'TA', } +export interface QuerySchedulesRequest { + airline?: ReadonlyArray; + aircraftType?: ReadonlyArray; + aircraftConfigurationVersion?: ReadonlyArray; + aircraft?: ReadonlyArray<[string, string]>; + departureAirport?: ReadonlyArray; + arrivalAirport?: ReadonlyArray; + route?: ReadonlyArray<[string, string]>; + minDepartureTime?: DateTime; + maxDepartureTime?: DateTime; +} + export type QueryScheduleResponse = Record>; export interface RouteAndRange { diff --git a/ui/src/lib/api/api.ts b/ui/src/lib/api/api.ts index 522d773..f1f90b1 100644 --- a/ui/src/lib/api/api.ts +++ b/ui/src/lib/api/api.ts @@ -10,7 +10,7 @@ import { ConnectionsSearchRequest, ConnectionsSearchResponseWithSearch, ConnectionsSearchResponse, - FlightSchedule, SeatMap, QueryScheduleResponse + FlightSchedule, SeatMap, QueryScheduleResponse, QuerySchedulesRequest } from './api.model'; import { ConcurrencyLimit } from './concurrency-limit'; import { DateTime } from 'luxon'; @@ -66,6 +66,10 @@ export class ApiClient { ); } + getAirlines(): Promise>> { + return transform(this.httpClient.fetch('/data/airlines.json')); + } + getAirports(): Promise> { return transform(this.httpClient.fetch('/data/airports.json')); } @@ -122,10 +126,52 @@ export class ApiClient { return transform(this.httpClient.fetch(url)); } - queryFlightSchedules(airline: string, aircraftType: string, aircraftConfigurationVersion: string): Promise> { + getFlightSchedulesByConfiguration(airline: string, aircraftType: string, aircraftConfigurationVersion: string): Promise> { return transform(this.httpClient.fetch(`/data/${encodeURIComponent(airline)}/schedule/${encodeURIComponent(aircraftType)}/${aircraftConfigurationVersion}/v2`)); } + queryFlightSchedules(req: QuerySchedulesRequest): Promise> { + const params = new URLSearchParams(); + + for (const airline of req.airline ?? []) { + params.append('airline', airline); + } + + for (const aircraftType of req.aircraftType ?? []) { + params.append('aircraftType', aircraftType); + } + + for (const aircraftConfigurationVersion of req.aircraftConfigurationVersion ?? []) { + params.append('aircraftConfigurationVersion', aircraftConfigurationVersion); + } + + for (const [aircraftType, aircraftConfigurationVersion] of req.aircraft ?? []) { + params.append('aircraft', `${aircraftType}-${aircraftConfigurationVersion}`); + } + + for (const departureAirport of req.departureAirport ?? []) { + params.append('departureAirport', departureAirport); + } + + for (const arrivalAirport of req.arrivalAirport ?? []) { + params.append('arrivalAirport', arrivalAirport); + } + + for (const [departureAirport, arrivalAirport] of req.route ?? []) { + params.append('route', `${departureAirport}-${arrivalAirport}`); + } + + if (req.minDepartureTime) { + params.set('minDepartureTime', req.minDepartureTime.toISO()); + } + + if (req.maxDepartureTime) { + params.set('maxDepartureTime', req.maxDepartureTime.toISO()); + } + + return transform(this.httpClient.fetch(`/api/schedule/search?${params.toString()}`)); + } + search(query: string): Promise>> { const params = new URLSearchParams(); params.set('q', query); diff --git a/ui/src/pages/allegris.tsx b/ui/src/pages/allegris.tsx index 1947eef..4fd0b5c 100644 --- a/ui/src/pages/allegris.tsx +++ b/ui/src/pages/allegris.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo } from 'react'; import { Box, Button, ContentLayout, Header, Pagination, SpaceBetween, Table } from '@cloudscape-design/components'; -import { useQueryFlightSchedules } from '../components/util/state/data'; +import { useFlightSchedulesByConfiguration } from '../components/util/state/data'; import { UseQueryResult } from '@tanstack/react-query'; import { QueryScheduleResponse } from '../lib/api/api.model'; import { useCollection } from '@cloudscape-design/collection-hooks'; @@ -17,8 +17,8 @@ import { ErrorNotificationContent, useAppControls } from '../components/util/con const AIRCRAFT_TYPE_A350_900 = '359'; export function Allegris() { - const queryRegular = useQueryFlightSchedules('LH', AIRCRAFT_TYPE_A350_900, AircraftConfigurationVersion.LH_A350_900_ALLEGRIS); - const queryWithFirst = useQueryFlightSchedules('LH', AIRCRAFT_TYPE_A350_900, AircraftConfigurationVersion.LH_A350_900_ALLEGRIS_FIRST); + const queryRegular = useFlightSchedulesByConfiguration('LH', AIRCRAFT_TYPE_A350_900, AircraftConfigurationVersion.LH_A350_900_ALLEGRIS); + const queryWithFirst = useFlightSchedulesByConfiguration('LH', AIRCRAFT_TYPE_A350_900, AircraftConfigurationVersion.LH_A350_900_ALLEGRIS_FIRST); const actions = ( diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index b1dbd37..f93b510 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -13,6 +13,7 @@ import { Links } from './tools/links'; import { FlightView } from './flight'; import { FlightSelect } from './flight-select'; import { Allegris } from './allegris'; +import { FlightSearch } from './tools/flight-search'; // region router const router = createBrowserRouter([ @@ -46,6 +47,10 @@ const router = createBrowserRouter([ path: 'allegris', element: , }, + { + path: 'tools/flight-search', + element: , + }, { path: 'tools/mm-quick-search', element: , diff --git a/ui/src/pages/tools/flight-search.tsx b/ui/src/pages/tools/flight-search.tsx new file mode 100644 index 0000000..d7c0d17 --- /dev/null +++ b/ui/src/pages/tools/flight-search.tsx @@ -0,0 +1,576 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, + AttributeEditor, Box, + Button, + ColumnLayout, + Container, + ContentLayout, DateRangePicker, DateRangePickerProps, + Form, + FormField, Grid, + Header, Pagination, Select, SelectProps, Table +} from '@cloudscape-design/components'; +import { Aircraft, QueryScheduleResponse, QuerySchedulesRequest } from '../../lib/api/api.model'; +import { useAircraft, useAirlines, useAirports, useQueryFlightSchedules } from '../../components/util/state/data'; +import { AirlineMultiselect } from '../../components/select/airline-multiselect'; +import { AirportSelect } from '../../components/select/airport-select'; +import { AircraftSelect } from '../../components/select/aircraft-select'; +import { AircraftConfigurationVersion } from '../../lib/consts'; +import { UseQueryResult } from '@tanstack/react-query'; +import { ErrorNotificationContent, useAppControls } from '../../components/util/context/app-controls'; +import { DateTime } from 'luxon'; +import { useCollection } from '@cloudscape-design/collection-hooks'; +import { FlightLink } from '../../components/common/flight-link'; + +export function FlightSearch() { + const [request, setRequest] = useState({}); + const [activeRequest, setActiveRequest] = useState({}); + const schedules = useQueryFlightSchedules(activeRequest); + + return ( + Flight Search}> + + + This page is still work in progress. Please be aware that this might impact your experience with this tool. + + + setActiveRequest(request)}>Search} + disabled={schedules.isLoading} + request={request} + onUpdate={setRequest} + /> + + + + + ); +} + +type Empty = [null, null]; + +function SearchForm({ actions, disabled, request, onUpdate }: { actions: React.ReactNode, disabled: boolean, request: QuerySchedulesRequest, onUpdate: React.Dispatch> }) { + const airlines = useAirlines(); + + const departureRangeValue = useMemo(() => { + if (!request.minDepartureTime || !request.maxDepartureTime) { + return null; + } + + return { type: 'absolute', startDate: request.minDepartureTime.toISO(), endDate: request.maxDepartureTime.toISO() }; + }, [request.minDepartureTime, request.maxDepartureTime]); + + return ( + +
e.preventDefault()}> + + + + onUpdate((prev) => ({ ...prev, airline: v }))} + placeholder={'Leave empty to search all airlines'} + /> + + + + { + const value = e.detail.value; + if (value === null || value.type !== 'absolute') { + onUpdate((prev) => ({ + ...prev, + minDepartureTime: undefined, + maxDepartureTime: undefined, + })); + return; + } + + const start = DateTime.fromISO(value.startDate, { setZone: true }); + const end = DateTime.fromISO(value.endDate, { setZone: true }); + if (!start.isValid || !end.isValid) { + return; + } + + onUpdate((prev) => ({ + ...prev, + minDepartureTime: start, + maxDepartureTime: end, + })) + }} + relativeOptions={[]} + isValidRange={(v) => { + if (v === null || v.type !== 'absolute') { + return { + valid: false, + errorMessage: 'Absolute range is required', + }; + } + + const start = DateTime.fromISO(v.startDate, { setZone: true }); + const end = DateTime.fromISO(v.endDate, { setZone: true }); + if (!start.isValid || !end.isValid) { + return { + valid: false, + errorMessage: 'Invalid dates', + }; + } + + return { valid: true }; + }} + rangeSelectorMode={'absolute-only'} + disabled={disabled} + placeholder={'Filter flights by their departure time'} + /> + + + + onUpdate((prev) => ({ ...prev, aircraftType: v }))} + onAircraftConfigurationVersionChange={(v) => onUpdate((prev) => ({ ...prev, aircraftConfigurationVersion: v }))} + onAircraftChange={(v) => onUpdate((prev) => ({ ...prev, aircraft: v }))} + disabled={disabled} + /> + + + + onUpdate((prev) => ({ ...prev, departureAirport: v }))} + onArrivalAirportsChange={(v) => onUpdate((prev) => ({ ...prev, arrivalAirport: v }))} + onRoutesChange={(v) => onUpdate((prev) => ({ ...prev, route: v }))} + disabled={disabled} + /> + + +
+ +
+ ); +} + +interface AircraftSelectionProps { + aircraftTypes: ReadonlyArray; + aircraftConfigurationVersions: ReadonlyArray; + aircraft: ReadonlyArray<[string, string]>; + onAircraftTypesChange: (v: ReadonlyArray) => void; + onAircraftConfigurationVersionChange: (v: ReadonlyArray) => void; + onAircraftChange: (v: ReadonlyArray<[string, string]>) => void; + disabled: boolean; +} + +type AircraftType = [string, null]; +type XAircraftConfigurationVersion = [null, string]; +type AircraftAndConfiguration = [string, string]; +type AircraftItem = Empty | AircraftType | XAircraftConfigurationVersion | AircraftAndConfiguration; + +function AircraftSelection({ aircraftTypes, aircraftConfigurationVersions, aircraft, onAircraftTypesChange, onAircraftConfigurationVersionChange, onAircraftChange, disabled }: AircraftSelectionProps) { + const aircraftQuery = useAircraft(); + + const [aircraftItems, setAircraftItems] = useState>((() => { + const result: Array = []; + + for (const aircraftType of aircraftTypes) { + result.push([aircraftType, null]); + } + + for (const aircraftConfigurationVersion of aircraftConfigurationVersions) { + result.push([null, aircraftConfigurationVersion]); + } + + for (const v of aircraft) { + result.push(v); + } + + if (result.length < 1) { + result.push([null, null]); + } + + return result; + })()); + + useEffect(() => { + const aircraftTypes: Array = []; + const aircraftConfigurationVersions: Array = []; + const aircraft: Array<[string, string]> = []; + + for (const item of aircraftItems) { + if (item[0] && item[1]) { + aircraft.push(item); + } else if (item[0]) { + aircraftTypes.push(item[0]); + } else if (item[1]) { + aircraftConfigurationVersions.push(item[1]); + } + } + + onAircraftTypesChange(aircraftTypes); + onAircraftConfigurationVersionChange(aircraftConfigurationVersions); + onAircraftChange(aircraft); + }, [aircraftItems]); + + const updateAircraftItem = useCallback((itemIndex: number, updateIndex: number, value: string | null) => { + setAircraftItems((prev) => { + const updated = [...prev]; + updated[itemIndex][updateIndex] = value; + return updated; + }); + }, []); + + return ( + setAircraftItems((prev) => [...prev, [null, null]])} + onRemoveButtonClick={(e) => setAircraftItems((prev) => prev.toSpliced(e.detail.itemIndex, 1))} + items={aircraftItems} + definition={[ + { + label: 'Aircraft', + control: (item, index) => ( + + updateAircraftItem(index, 0, v)} + placeholder={'Any'} + /> +