import * as React from 'react'; import { _ } from '@joplin/lib/locale'; import { themeStyle } from '@joplin/lib/theme'; import CreatableSelect from 'react-select/creatable'; import Select from 'react-select'; import makeAnimated from 'react-select/animated'; import { focus } from '@joplin/lib/utils/focusHandler'; import Dialog from '@joplin/lib/components/Dialog'; import { ChangeEvent } from 'react'; import { formatDateTimeLocalToMs, isValidDate } from '@joplin/utils/time'; import lightTheme from '@joplin/lib/themes/light'; import PasswordInput from './PasswordInput/PasswordInput'; interface Props { themeId: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Heterogeneous: string for text/datetime inputs, TagOption[] for tags, string for datetime; consumers dispatch by inputType defaultValue: any; visible: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DialogState.promptOptions.buttons is `unknown[]`; matching the looser upstream type avoids casts at every call site buttons: any[]; onClose: (answer: unknown, buttonType: string|null)=> void; inputType: string; description: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Mirrors defaultValue — same heterogeneous shape answer?: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- React-select autocomplete options array; per-prompt shape (TagOption[] / PromptSuggestion[]) autocomplete: any; label: string; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- State holds the current answer which mirrors the heterogeneous defaultValue type export default class PromptDialog extends React.Component { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Holds either an HTMLInputElement ref or a react-select ref depending on inputType private answerInput_: any; private focusInput_: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Heterogeneous style blocks plus computed react-select theme/styles factories private styles_: any; private styleKey_: string; private menuIsOpened_ = false; public constructor(props: Props) { super(props); this.answerInput_ = React.createRef(); this.select_menuOpen = this.select_menuOpen.bind(this); this.select_menuClose = this.select_menuClose.bind(this); } public UNSAFE_componentWillMount() { this.setState({ visible: false, answer: this.props.defaultValue ? this.props.defaultValue : '', }); this.focusInput_ = true; this.menuIsOpened_ = false; } public UNSAFE_componentWillReceiveProps(newProps: Props) { if ('visible' in newProps && newProps.visible !== this.props.visible) { this.setState({ visible: newProps.visible }); if (newProps.visible) this.focusInput_ = true; } if ('defaultValue' in newProps && newProps.defaultValue !== this.props.defaultValue) { this.setState({ answer: newProps.defaultValue }); } } private select_menuOpen() { this.menuIsOpened_ = true; } private select_menuClose() { this.menuIsOpened_ = false; } public componentDidUpdate() { if (this.focusInput_ && this.answerInput_.current) focus('PromptDialog::componentDidUpdate', this.answerInput_.current); this.focusInput_ = false; } public styles(themeId: number, visible: boolean) { const styleKey = `${themeId}_${visible}`; if (styleKey === this.styleKey_) return this.styles_; const theme = themeStyle(themeId); this.styleKey_ = styleKey; this.styles_ = {}; this.styles_.button = { minWidth: theme.buttonMinWidth, minHeight: 30, color: theme.color, backgroundColor: theme.backgroundColor, border: '1px solid', borderColor: theme.dividerColor, borderRadius: 6, }; this.styles_.label = { marginRight: 5, fontSize: theme.fontSize, color: theme.color, fontFamily: theme.fontFamily, verticalAlign: 'middle', }; this.styles_.input = { width: 'var(--prompt-width)', maxWidth: '100%', minHeight: 30, color: theme.color, backgroundColor: theme.backgroundColor, border: '1px solid', borderColor: theme.dividerColor, borderRadius: 6, paddingLeft: 8, paddingRight: 8, boxSizing: 'border-box', }; // The button to change the date/time cannot be customized easily so we need to use the // light theme for that particular component. this.styles_.dateTimeInput = { ...this.styles_.input, color: lightTheme.color, backgroundColor: lightTheme.backgroundColor, borderColor: lightTheme.dividerColor, }; this.styles_.select = { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic control: (provided: any) => { return { ...provided, minWidth: 'calc(var(--prompt-width) * 0.2)', maxWidth: 'calc(var(--prompt-width) * 0.5)', fontFamily: theme.fontFamily, }; }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic input: (provided: any) => { return { ...provided, minWidth: '20px', color: theme.color, }; }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic menu: (provided: any) => { return { ...provided, color: theme.color, fontFamily: theme.fontFamily, backgroundColor: theme.backgroundColor, }; }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic option: (provided: any, state: any) => { return { ...provided, color: theme.color, fontFamily: theme.fontFamily, paddingLeft: `${10 + (state.data.indentDepth || 0) * 20}px`, }; }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic multiValueLabel: (provided: any) => { return { ...provided, fontFamily: theme.fontFamily, }; }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic multiValueRemove: (provided: any) => { return { ...provided, color: theme.color, }; }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select style/theme factories take the library's own provided styles; tightening would require importing each StyleConfig generic this.styles_.selectTheme = (tagTheme: any) => { return { ...tagTheme, borderRadius: 2, colors: { ...tagTheme.colors, primary: theme.raisedBackgroundColor, primary25: theme.raisedBackgroundColor, neutral0: theme.backgroundColor, neutral5: theme.backgroundColor, neutral10: theme.raisedBackgroundColor, neutral20: theme.raisedBackgroundColor, neutral30: theme.raisedBackgroundColor, neutral40: theme.color, neutral50: theme.color, neutral60: theme.color, neutral70: theme.color, neutral80: theme.color, neutral90: theme.color, danger: theme.backgroundColor, dangerLight: theme.colorError2, }, }; }; this.styles_.desc = { ...theme.textStyle, color: theme.colorFaded, marginTop: 8 }; return this.styles_; } public render() { if (!this.state.visible) return null; const theme = themeStyle(this.props.themeId); const buttonTypes = this.props.buttons ? this.props.buttons : ['ok', 'cancel']; const styles = this.styles(this.props.themeId, this.state.visible); const onClose = (accept: boolean, buttonType: string|null = null) => { if (this.props.onClose) { let outputAnswer = this.state.answer; if (this.props.inputType === 'datetime') { outputAnswer = isValidDate(outputAnswer) ? formatDateTimeLocalToMs(outputAnswer) : null; } this.props.onClose(accept ? outputAnswer : null, buttonType); } this.setState({ visible: false, answer: '' }); }; const onChange = (event: ChangeEvent) => { this.setState({ answer: event.target.value }); }; // const anythingToDate = (o) => { // if (o && o.toDate) return o.toDate(); // if (!o) return null; // let m = moment(o, time.dateTimeFormat()); // if (m.isValid()) return m.toDate(); // m = moment(o, time.dateFormat()); // return m.isValid() ? m.toDate() : null; // } const onSelectChange = (newValue: unknown) => { this.setState({ answer: newValue }); this.focusInput_ = true; }; const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { // If the dropdown is open, we don't close the dialog - instead // the currently item will be selected. If it is closed however // we confirm the dialog. if ((this.props.inputType === 'tags' || this.props.inputType === 'dropdown') && this.menuIsOpened_) { // Do nothing } else { onClose(true); } } else if (event.key === 'Escape') { // react-select calls preventDefault() on the Escape keydown // event, which prevents the native cancel event from // firing. We handle Escape explicitly here to ensure the dialog // can be closed with the Escape key. if ((this.props.inputType === 'tags' || this.props.inputType === 'dropdown') && this.menuIsOpened_) { // Let react-select close the dropdown menu } else { onClose(false, 'cancel'); } } }; const descComp = this.props.description ?
{this.props.description}
: null; let inputComp = null; if (this.props.inputType === 'datetime') { inputComp = ; } else if (this.props.inputType === 'tags') { const uniqueAutocomplete = []; const seenLabels = new Set(); const autocompleteOptions = this.props.autocomplete || []; for (const option of autocompleteOptions) { const key = (option.label || '').trim().normalize('NFC').toLowerCase(); if (!seenLabels.has(key)) { uniqueAutocomplete.push(option); seenLabels.add(key); } } inputComp = { const input = (rawInput || '').trim().normalize('NFC').toLowerCase(); const label = (option.label || '').trim().normalize('NFC').toLowerCase(); return label.includes(input); }} isValidNewOption={(inputValue, _selectValue, selectOptions) => { const input = (inputValue || '').trim().normalize('NFC').toLowerCase(); if (!input) return false; // If it matches an existing option (case-insensitive + normalized), it's not a valid "new" option const exists = selectOptions.some(option => { return (option.label || '').trim().normalize('NFC').toLowerCase() === input; }); return !exists; }} />; } else if (this.props.inputType === 'dropdown') { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- makeAnimated's generic doesn't unify with react-select's expected SelectComponents shape inputComp = onChange(event)} onKeyDown={event => onKeyDown(event)} />; } const buttonComps = []; if (buttonTypes.indexOf('create') >= 0) { buttonComps.push( , ); } if (buttonTypes.indexOf('ok') >= 0) { buttonComps.push( , ); } if (buttonTypes.indexOf('cancel') >= 0) { buttonComps.push( , ); } if (buttonTypes.indexOf('clear') >= 0) { buttonComps.push( , ); } // A password prompt puts its input below the label at a fixed width, shrinking with the window. const isPassword = this.props.inputType === 'password'; return ( onClose(false, 'cancel')}>
{inputComp} {descComp}
{buttonComps}
); } }