Line data Source code
1 : import * as React from 'react';
2 :
3 : import { classNames } from '../../utils/classnames.js';
4 : import { sessionInputControl, SessionInputControl, type SessionInput } from './session_inputs.js';
5 :
6 : import * as styles from './session_inputs.module.css';
7 :
8 : /// How a card is drawn: a chip in the strip of the feed and the dashboard, a block in a box of the dataflow
9 : export type SessionInputCardVariant = 'chip' | 'block';
10 :
11 : interface Props {
12 : input: SessionInput;
13 : /// The literal of a value of the control in the Hyper dialect, or the problem with the value
14 : literalOf: (input: SessionInput, value: string) => { literal: string } | { error: string };
15 : /// Write the literal and run the scripts that use the input
16 : onCommit: (name: string, literal: string) => void;
17 : /// Do the scripts of the input run?
18 : busy?: boolean;
19 : variant?: SessionInputCardVariant;
20 : className?: string;
21 : }
22 :
23 : /// A `datetime-local` value from a SQL timestamp, null if the control can't show it
24 0 : function toDateTimeLocal(value: string): string | null {
25 0 : const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(:\d{2})?)$/.exec(value.trim());
26 0 : return match == null ? null : `${match[1]}T${match[2]}`;
27 0 : }
28 :
29 : /// A SQL timestamp from a `datetime-local` value
30 0 : function fromDateTimeLocal(value: string): string {
31 0 : return value.replace('T', ' ');
32 0 : }
33 :
34 : /// The units of an interval, an interval is a number and one unit (docs/design/session_inputs.md)
35 1 : const INTERVAL_UNITS = ['year', 'month', 'day', 'hour', 'minute', 'second'];
36 :
37 : /// The amount and the unit of the value of an interval, `3 day`, the unit is null without one
38 1 : function splitInterval(value: string): { amount: string; unit: string | null } {
39 1 : const match = /^\s*(\S*)\s+(\S+)\s*$/.exec(value);
40 1 : return match == null ? { amount: value.trim(), unit: null } : { amount: match[1], unit: match[2].toLowerCase() };
41 1 : }
42 :
43 1 : const PLACEHOLDERS: Partial<Record<SessionInputControl, string>> = {
44 1 : [SessionInputControl.Interval]: '3',
45 1 : [SessionInputControl.Number]: '0',
46 1 : [SessionInputControl.Integer]: '0',
47 1 : };
48 :
49 : /// How long a typed draft rests before it is written
50 : export const SESSION_INPUT_COMMIT_DELAY_MS = 600;
51 :
52 : /// The value of an input in a card: the name of the parameter, its type and a control that follows the type. The
53 : /// control owns its draft, a draft that rests for a moment is written as literal and runs the scripts that use the
54 : /// input. Enter and leaving the control write it at once, Escape restores the value.
55 1 : export function SessionInputCard(props: Props): React.ReactElement {
56 1 : const { input, onCommit, literalOf } = props;
57 1 : const value = input.value ?? '';
58 1 : let control = sessionInputControl(input.type);
59 : // A timestamp that the picker can't show is text
60 1 : if (control === SessionInputControl.Timestamp && value.length > 0 && toDateTimeLocal(value) == null) control = SessionInputControl.Text;
61 1 : const shown = control === SessionInputControl.Timestamp ? (toDateTimeLocal(value) ?? '') : value;
62 1 : const [draft, setDraft] = React.useState(shown);
63 : // The draft that waits to be written, a newer value of the input doesn't replace it
64 1 : const pending = React.useRef<{ value: string; timer: ReturnType<typeof setTimeout> } | null>(null);
65 1 : React.useEffect(() => {
66 1 : if (pending.current == null) setDraft(shown);
67 1 : }, [shown]);
68 1 : const committed = control === SessionInputControl.Timestamp ? fromDateTimeLocal(draft) : draft;
69 1 : const dirty = draft !== shown;
70 1 : const result = React.useMemo(() => literalOf(input, committed), [literalOf, input, committed]);
71 1 : const problem = draft.trim().length === 0 || !('error' in result) ? null : result.error;
72 1 : const unset = input.value == null;
73 : // The unit of an interval without an amount, the draft holds the unit once it has one
74 1 : const [emptyUnit, setEmptyUnit] = React.useState('day');
75 1 : const interval = splitInterval(draft);
76 1 : const intervalUnit = interval.unit ?? emptyUnit;
77 :
78 1 : const commit = React.useCallback((next: string) => {
79 1 : const literal = literalOf(input, next);
80 1 : if ('literal' in literal) onCommit(input.name, literal.literal);
81 1 : }, [input, literalOf, onCommit]);
82 1 : const commitRef = React.useRef(commit);
83 1 : commitRef.current = commit;
84 1 : const cancelPending = React.useCallback(() => {
85 1 : if (pending.current == null) return;
86 1 : clearTimeout(pending.current.timer);
87 1 : pending.current = null;
88 1 : }, []);
89 1 : const flushPending = React.useCallback(() => {
90 1 : const next = pending.current;
91 1 : if (next == null) return;
92 1 : clearTimeout(next.timer);
93 1 : pending.current = null;
94 1 : commitRef.current(next.value);
95 1 : }, []);
96 1 : React.useEffect(() => cancelPending, [cancelPending]);
97 : /// Take a typed draft and write it once it rests, a draft that equals the value writes nothing
98 1 : const editDraft = (next: string) => {
99 1 : setDraft(next);
100 1 : cancelPending();
101 1 : if (next === shown) return;
102 1 : const value = control === SessionInputControl.Timestamp ? fromDateTimeLocal(next) : next;
103 1 : pending.current = { value, timer: setTimeout(flushPending, SESSION_INPUT_COMMIT_DELAY_MS) };
104 1 : };
105 1 : const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
106 1 : if (event.key === 'Enter') {
107 1 : event.preventDefault();
108 1 : cancelPending();
109 1 : commit(committed);
110 1 : } else if (event.key === 'Escape') {
111 1 : event.preventDefault();
112 1 : cancelPending();
113 1 : setDraft(shown);
114 1 : event.currentTarget.blur();
115 1 : } else {
116 0 : return;
117 : }
118 1 : event.stopPropagation();
119 1 : }, [cancelPending, commit, committed, shown]);
120 :
121 1 : const label = `Value of :${input.name}`;
122 1 : const typeTitle = input.type.cast
123 0 : ? `Cast to ${input.type.sql}`
124 1 : : input.type.known ? `The type of the value ${input.literal ?? ''}` : 'Without a value or a cast, the input is text';
125 1 : const title = problem ?? input.warnings[0] ?? (unset ? 'The input has no value, the scripts that use it fail' : input.literal ?? undefined);
126 1 : return (
127 1 : <div
128 1 : className={classNames(styles.card, props.className, { [styles.card_block]: props.variant === 'block' })}
129 1 : data-session-input={input.name}
130 1 : data-dirty={dirty || undefined}
131 1 : data-invalid={problem != null || undefined}
132 1 : data-unset={(unset && !dirty) || undefined}
133 1 : data-busy={props.busy || undefined}
134 1 : title={title}
135 1 : onPointerDown={event => event.stopPropagation()}
136 : >
137 1 : {props.variant !== 'block' && <span className={styles.name}>:{input.name}</span>}
138 1 : {props.variant !== 'block' && <span className={styles.type} data-inferred={!input.type.cast || undefined} title={typeTitle}>{input.type.sql}</span>}
139 1 : {control === SessionInputControl.Interval ? (
140 1 : <>
141 1 : <input
142 1 : className={classNames(styles.control, styles.control_amount)}
143 1 : aria-label={label}
144 1 : type="text"
145 1 : inputMode="decimal"
146 1 : value={interval.amount}
147 1 : placeholder={PLACEHOLDERS[control]}
148 1 : spellCheck={false}
149 1 : autoComplete="off"
150 1 : onChange={event => {
151 1 : const amount = event.currentTarget.value.replace(/\s+/g, '');
152 1 : editDraft(amount.length === 0 ? '' : `${amount} ${intervalUnit}`);
153 1 : }}
154 1 : onKeyDown={onKeyDown}
155 1 : onBlur={flushPending}
156 1 : />
157 1 : <select
158 1 : className={classNames(styles.control, styles.control_unit)}
159 1 : aria-label={`Unit of :${input.name}`}
160 1 : value={intervalUnit}
161 1 : onChange={event => {
162 1 : const unit = event.currentTarget.value;
163 1 : if (interval.amount.length === 0) {
164 1 : setEmptyUnit(unit);
165 1 : return;
166 1 : }
167 1 : const next = `${interval.amount} ${unit}`;
168 1 : cancelPending();
169 1 : setDraft(next);
170 1 : commit(next);
171 1 : }}
172 : >
173 1 : {!INTERVAL_UNITS.includes(intervalUnit) && <option value={intervalUnit} disabled>{intervalUnit}</option>}
174 1 : {INTERVAL_UNITS.map(unit => <option key={unit} value={unit}>{unit}</option>)}
175 1 : </select>
176 1 : </>
177 0 : ) : control === SessionInputControl.Boolean ? (
178 0 : <select
179 0 : className={styles.control}
180 0 : aria-label={label}
181 0 : value={/^(true|false)$/i.test(draft) ? draft.toLowerCase() : ''}
182 0 : onChange={event => {
183 0 : setDraft(event.currentTarget.value);
184 0 : if (event.currentTarget.value !== '') commit(event.currentTarget.value);
185 0 : }}
186 : >
187 0 : <option value="" disabled>unset</option>
188 0 : <option value="true">true</option>
189 0 : <option value="false">false</option>
190 0 : </select>
191 : ) : (
192 0 : <input
193 0 : className={styles.control}
194 0 : aria-label={label}
195 0 : type={control === SessionInputControl.Date ? 'date' : control === SessionInputControl.Timestamp ? 'datetime-local' : 'text'}
196 0 : inputMode={control === SessionInputControl.Number || control === SessionInputControl.Integer ? 'decimal' : undefined}
197 0 : step={control === SessionInputControl.Timestamp ? 1 : undefined}
198 0 : value={draft}
199 0 : placeholder={PLACEHOLDERS[control] ?? 'value'}
200 0 : spellCheck={false}
201 0 : autoComplete="off"
202 0 : onChange={event => editDraft(event.currentTarget.value)}
203 0 : onKeyDown={onKeyDown}
204 0 : onBlur={flushPending}
205 0 : />
206 : )}
207 1 : <span className={styles.status} aria-hidden="true" />
208 1 : </div>
209 : );
210 1 : }
|