import { useState, type KeyboardEvent, type ReactElement } from 'react' import { fieldLabel, filterText, fromDraft, groupFields, opText, readTickers, toDraft, unitHint, type Draft, type Field, type Filter, type Op, } from './screener' // What is filtered, and how it is changed by hand: one chip per filter with a cross, and a dialog // over the view to add and edit them. The dialog edits a draft and writes the list once, on Apply, // because the orchestrator writes it the same way — the complete set, one re-run. Which fields // there are, and what each takes, comes from the server's guide. /** The part of the state that says which companies are on the list. */ export interface Listed { filters: Filter[] tickers: string[] includeUnlisted: boolean } interface BarProps { listed: Listed fields: Map onChange: (next: Listed) => void /** Opens the dialog, from its button or from a chip: a filter is edited where the rest are. */ onOpen: () => void onColumns: () => void } export function FilterBar({ listed, fields, onChange, onOpen, onColumns }: BarProps): ReactElement { const { filters, tickers, includeUnlisted } = listed return ( <> {tickers.length > 0 && ( onChange({ ...listed, tickers: [] })} /> )} {filters.map((filter, index) => ( onChange({ ...listed, filters: filters.filter((_, i) => i !== index) })} /> ))} {includeUnlisted && onChange({ ...listed, includeUnlisted: false })} />} ) } function Chip({ label, onOpen, onRemove }: { label: string; onOpen: () => void; onRemove: () => void }): ReactElement { return ( ) } interface DialogProps { listed: Listed fields: Field[] /** Why there are no fields to pick from, when there are none. */ fieldsError: string | null onApply: (next: Listed) => void onClose: () => void } /** A row of the dialog: the filter as typed, and the filter it came from while nobody has touched it. */ interface Row extends Draft { original?: Filter } const NEW_ROW: Row = { field: '', op: 'gte', a: '', b: '' } export function FilterDialog({ listed, fields, fieldsError, onApply, onClose }: DialogProps): ReactElement { const byName = new Map(fields.map((field) => [field.field, field])) // The dialog is mounted when it opens, so the draft is seeded once, here, rather than in an effect. const [rows, setRows] = useState(() => listed.filters.map((filter) => ({ ...toDraft(filter, byName.get(filter.field)), original: filter }))) const [tickers, setTickers] = useState(listed.tickers.join(' ')) const [includeUnlisted, setIncludeUnlisted] = useState(listed.includeUnlisted) const [errors, setErrors] = useState>({}) const groups = groupFields(fields.filter((field) => field.ops.length > 0)) function change(index: number, patch: Partial): void { setRows((previous) => previous.map((row, i) => (i === index ? { ...row, ...patch, original: undefined } : row))) setErrors((previous) => ({ ...previous, [index]: '' })) } /** A new field keeps the op when it takes it, and never the value: a percentage is not a dollar figure. */ function changeField(index: number, name: string): void { const ops = byName.get(name)?.ops ?? [] const op = rows[index]!.op change(index, { field: name, op: ops.includes(op) ? op : (ops[0] ?? 'eq'), a: '', b: '' }) } function apply(): void { const filters: Filter[] = [] const found: Record = {} rows.forEach((row, index) => { // A row nobody filled in is not a filter, and one nobody touched is the filter it was: the // orchestrator may have set one on a field the guide does not list, or the guide may be out. if (!row.field) return if (row.original) { filters.push(row.original) return } const read = fromDraft(row, byName.get(row.field)) if ('error' in read) found[index] = read.error else filters.push(read.filter) }) setErrors(found) if (Object.keys(found).length > 0) return onApply({ filters, tickers: readTickers(tickers), includeUnlisted }) } /** A form never submits in a view's frame, so Enter is read here. */ function onEnter(event: KeyboardEvent): void { if (event.key === 'Enter') apply() } return (
event.stopPropagation()}>
Screener filters
Filters (a company passes every one) {fields.length === 0 &&

{fieldsError ?? 'The server sent no list of fields.'}

} {rows.map((row, index) => { const field = byName.get(row.field) const valued = row.op !== 'is_null' && row.op !== 'not_null' return (
{valued && ( change(index, { a: event.target.value })} onKeyDown={onEnter} /> )} {row.op === 'between' && ( change(index, { b: event.target.value })} onKeyDown={onEnter} /> )} {valued && field && {unitHint(field)}}
{field &&

{field.description}

} {errors[index] &&

{errors[index]}

}
) })}