From 656c47cf68290715c7496bb5d93edcfe8362d7b1 Mon Sep 17 00:00:00 2001 From: Alexandre Alencar Date: Thu, 10 Mar 2022 09:27:44 -0300 Subject: [PATCH] fix: make value behavior consistent #1154 --- py/examples/debug.py | 135 +++++++++++++++++++++++++++++++++++++++ ui/src/checkbox.tsx | 29 +++++---- ui/src/checklist.tsx | 9 +-- ui/src/choice_group.tsx | 22 ++++--- ui/src/combobox.tsx | 6 +- ui/src/copyable_text.tsx | 2 +- ui/src/date_picker.tsx | 15 +++-- ui/src/dropdown.tsx | 32 ++++++++-- ui/src/hooks.ts | 7 ++ ui/src/nav.tsx | 127 ++++++++++++------------------------ ui/src/picker.tsx | 7 +- ui/src/slider.tsx | 12 +++- ui/src/spinbox.tsx | 6 +- ui/src/tab.tsx | 47 +++++++++----- ui/src/tabs.tsx | 22 ++++--- ui/src/textbox.tsx | 21 +++--- ui/src/toggle.tsx | 25 +++++--- 17 files changed, 346 insertions(+), 178 deletions(-) create mode 100644 py/examples/debug.py create mode 100644 ui/src/hooks.ts diff --git a/py/examples/debug.py b/py/examples/debug.py new file mode 100644 index 00000000000..5ceba450096 --- /dev/null +++ b/py/examples/debug.py @@ -0,0 +1,135 @@ +# Nav +# Use nav cards to display #sidebar #navigation. +# --- +from h2o_wave import main, app, Q, ui + + +persona = 'https://images.pexels.com/photos/220453/pexels-photo-220453.jpeg?auto=compress&h=750&w=1260' + +choices = [ + ui.choice('A', 'Option A'), + ui.choice('B', 'Option B'), + ui.choice('C', 'Option C', disabled=True), + ui.choice('D', 'Option D'), +] + +combobox_choices = ['Cyan', 'Magenta', 'Yellow', 'Black'] + +tabs = [ + ui.tab(name='email', label='Mail', icon='Mail'), + ui.tab(name='events', label='Events', icon='Calendar'), + ui.tab(name='spam', label='Spam'), +] + + +@app('/demo') +async def serve(q: Q): + + content = 'Welcome to our store!' + location = q.args['#'] + if location: + if location == 'menu/spam': + content = "Sorry, we're out of spam!" + elif location == 'menu/ham': + content = "Sorry, we're out of ham!" + elif location == 'menu/eggs': + content = "Sorry, we're out of eggs!" + elif location == 'about': + content = 'Everything here is gluten-free!' + + if not q.client.initialized: + q.page['example'] = ui.form_card(box='3 4 4 10', items=[ + ui.checkbox(name='checkbox', label='Checkbox', value=False), + ui.dropdown(name='dropdown', label='Choices', value='', + choices=[ui.choice(name=x, label=x) for x in ['Egg', 'Bacon', 'Spam']]), + ui.dropdown(name='dropdown2', label='Choices', values=[], + choices=[ui.choice(name=x, label=x) for x in ['Egg', 'Bacon', 'Spam']]), + ui.dropdown(name='dropdown3', label='Choices', popup='always', value='', + choices=[ui.choice(name=x, label=x) for x in ['Egg', 'Bacon', 'Spam']]), + ui.dropdown(name='dropdown4', label='Choices', popup='always', values=[], + choices=[ui.choice(name=x, label=x) for x in ['Egg', 'Bacon', 'Spam']]), + ui.checklist(name='checklist', values=['Egg', 'Spam'], label='Choices', + choices=[ui.choice(name=x, label=x) for x in ['Egg', 'Bacon', 'Spam']]), + ui.choice_group(name='choice_group', label='Pick one', value='B', required=True, choices=choices), + ui.color_picker(name='color', label='Pick a color', value='#F25F5C'), + ui.combobox(name='combobox', label='Enter or choose a color', placeholder='Color...', value='Blue', + choices=combobox_choices), + ui.copyable_text(label='Copyable text', value='foo'), + ui.date_picker(name='date', label='Standard date picker', value='2017-10-19'), + ui.picker(name='picker', label='Place an order (try Spam, Eggs or Ham):', choices=[ + ui.choice(name='spam', label='Spam'), + ui.choice(name='eggs', label='Eggs'), + ui.choice(name='ham', label='Ham'), + ui.choice(name='cheese', label='Cheese'), + ui.choice(name='beans', label='Beans'), + ui.choice(name='toast', label='Toast'), + ], values=['eggs']), + ui.progress(label='Indeterminate Progress', caption='Goes on forever', value=0.5), + ui.slider(name='slider', label='Standard slider', min=0, max=100, step=10, value=30), + ui.spinbox(name='spinbox', label='Standard spinbox', min=0, max=100, step=10, value=30), + ui.tabs(name='menu', value='spam', items=tabs), + ui.toggle(name='toggle', label='Not checked'), + ui.button(name='change', label='Change', primary=True), + ]) + + q.page['tab'] = ui.tab_card( + box='3 1 4 1', + items=[ + ui.tab(name='#menu/spam', label='Spam'), + ui.tab(name='#menu/ham', label='Ham'), + ui.tab(name='#menu/eggs', label='Eggs'), + ui.tab(name='#about', label='About'), + ], + value='#menu/ham', + ) + + q.page['nav'] = ui.nav_card( + box='1 1 2 -1', + value='#menu/spam', + title='H2O Wave', + subtitle='And now for something completely different!', + items=[ + ui.nav_group('Menu', items=[ + ui.nav_item(name='#menu/spam', label='Spam'), + ui.nav_item(name='#menu/ham', label='Ham'), + ui.nav_item(name='#menu/eggs', label='Eggs', tooltip='Make me a scrambled egg.'), + ui.nav_item(name='#menu/toast', label='Toast'), + ]) + ], + ) + + q.page['blurb'] = ui.markdown_card( + box='3 2 4 2', + title='Store', + content=content, + ) + + q.client.initialized = True + + if q.args.change: + q.page['example'].items[0].checkbox.value = True + q.page['example'].items[0].checkbox.value = True + q.page['example'].items[1].dropdown.value = 'Bacon' + q.page['example'].items[2].dropdown.values = ['Spam', 'Bacon'] + q.page['example'].items[3].dropdown.value = 'Bacon' + q.page['example'].items[4].dropdown.values = ['Spam', 'Bacon'] + q.page['example'].items[5].checklist.values = ['Bacon'] + q.page['example'].items[6].choice_group.value = 'A' + q.page['example'].items[7].color_picker.value = '#FFFFF8' + q.page['example'].items[8].combobox.value = 'Yellow' + q.page['example'].items[9].copyable_text.value = 'bar' + q.page['example'].items[10].date_picker.value = '2022-03-22' + q.page['example'].items[11].picker.values = ['ham', 'toast'] + q.page['example'].items[12].progress.value = 0.75 + q.page['example'].items[13].slider.value = 50 + q.page['example'].items[14].spinbox.value = 50 + q.page['example'].items[15].tabs.value = 'events' + q.page['example'].items[16].toggle.value = True + + q.page['tab'].value = '#menu/spam' + q.page['nav'].value = '#menu/spam' + if location: + blurb = q.page['blurb'] + blurb.content = content + + await q.page.save() diff --git a/ui/src/checkbox.tsx b/ui/src/checkbox.tsx index 9238bf123e0..5a5ded8a4eb 100644 --- a/ui/src/checkbox.tsx +++ b/ui/src/checkbox.tsx @@ -15,6 +15,7 @@ import * as Fluent from '@fluentui/react' import { B, Id, S } from 'h2o-wave' import React from 'react' +import { useControlledComponent } from './hooks' import { wave } from './ui' /** @@ -55,24 +56,28 @@ export interface Checkbox { } export const - XCheckbox = ({ model: m }: { model: Checkbox }) => { - const onChange = (_e?: React.FormEvent, checked?: B) => { - wave.args[m.name] = checked === null ? null : !!checked - if (m.trigger) wave.push() - } + XCheckbox = (props: { model: Checkbox }) => { + const + { name, label, disabled, indeterminate, trigger } = props.model, + onChange = (_e?: React.FormEvent, checked?: B) => { + setValue(checked) + wave.args[name] = checked === null ? null : !!checked + if (trigger) wave.push() + }, + [value, setValue] = useControlledComponent(props, props.model.value) // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => { wave.args[m.name] = !!m.value }, []) + React.useEffect(() => { wave.args[name] = !!value }, []) return ( } - label={m.label} - defaultIndeterminate={m.indeterminate} - defaultChecked={m.value} + data-test={name} + inputProps={{ 'data-test': name } as React.ButtonHTMLAttributes} + label={label} + defaultIndeterminate={indeterminate} + checked={!!value} onChange={onChange} - disabled={m.disabled} + disabled={disabled} /> ) } \ No newline at end of file diff --git a/ui/src/checklist.tsx b/ui/src/checklist.tsx index d06923f7400..669042166aa 100644 --- a/ui/src/checklist.tsx +++ b/ui/src/checklist.tsx @@ -17,6 +17,7 @@ import { B, Id, S, U } from 'h2o-wave' import React from 'react' import { stylesheet } from 'typestyle' import { Choice } from './choice_group' +import { useControlledComponent } from './hooks' import { clas, margin } from './theme' import { wave } from './ui' @@ -74,13 +75,13 @@ export const const defaultSelection = React.useMemo(() => new Set(m.values), [m.values]), getMappedChoices = React.useCallback(() => m.choices?.map(c => ({ c, selected: defaultSelection.has(c.name) })) || [], [defaultSelection, m.choices]), - [choices, setChoices] = React.useState(getMappedChoices()), + [choices, setChoices] = useControlledComponent(m, getMappedChoices()), capture = (choices: { c: Choice, selected: B }[]) => { wave.args[m.name] = choices.filter(({ selected }) => selected).map(({ c }) => c.name) if (m.trigger) wave.push() }, select = (value: B) => { - const _choices = choices.map(({ c, selected }) => ({ c, selected: c.disabled ? selected : value })) + const _choices = choices.map(({ c, selected }: { c: Choice, selected: B}) => ({ c, selected: c.disabled ? selected : value })) setChoices(_choices) capture(_choices) }, @@ -92,7 +93,7 @@ export const setChoices(_choices) capture(_choices) }, - items = choices.map(({ c, selected }, i) => ( + items = choices.map(({ c, selected }: { c: Choice, selected: B}, i: number) => ( { wave.args[m.name] = m.values || [] }, []) - React.useEffect(() => { setChoices(getMappedChoices()) }, [getMappedChoices, m.choices]) + React.useEffect(() => { setChoices(getMappedChoices()) }, [getMappedChoices, m.choices, setChoices]) return (
{m.label} diff --git a/ui/src/choice_group.tsx b/ui/src/choice_group.tsx index a759da28ef3..8e27d5fd7a1 100644 --- a/ui/src/choice_group.tsx +++ b/ui/src/choice_group.tsx @@ -15,6 +15,7 @@ import * as Fluent from '@fluentui/react' import { B, Id, S } from 'h2o-wave' import React from 'react' +import { useControlledComponent } from './hooks' import { wave } from './ui' /** @@ -65,25 +66,28 @@ export interface ChoiceGroup { } export const - XChoiceGroup = ({ model: m }: { model: ChoiceGroup }) => { + XChoiceGroup = (props: { model: ChoiceGroup }) => { const + { name, label, required, trigger } = props.model, + [value, setValue] = useControlledComponent(props, props.model.value), optionStyles = { choiceFieldWrapper: { marginRight: 15 } }, - options = (m.choices || []).map(({ name, label, disabled }): Fluent.IChoiceGroupOption => ({ key: name, text: label || name, disabled, styles: optionStyles })), + options = (props.model.choices || []).map(({ name, label, disabled }): Fluent.IChoiceGroupOption => ({ key: name, text: label || name, disabled, styles: optionStyles })), onChange = (_e?: React.FormEvent, option?: Fluent.IChoiceGroupOption) => { - if (option) wave.args[m.name] = option.key - if (m.trigger) wave.push() + setValue(option?.key) + if (option) wave.args[name] = option.key + if (trigger) wave.push() } // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => { wave.args[m.name] = m.value || null }, []) + React.useEffect(() => { wave.args[name] = value || null }, []) return ( diff --git a/ui/src/combobox.tsx b/ui/src/combobox.tsx index 95dcc0fbfc4..1a2e3072660 100644 --- a/ui/src/combobox.tsx +++ b/ui/src/combobox.tsx @@ -15,6 +15,7 @@ import * as Fluent from '@fluentui/react' import { B, Id, S } from 'h2o-wave' import React from 'react' +import { useControlledComponent } from './hooks' import { wave } from './ui' /** @@ -57,9 +58,10 @@ export interface Combobox { export const - XCombobox = ({ model: m }: { model: Combobox }) => { + XCombobox = (props: { model: Combobox }) => { const - [text, setText] = React.useState(m.value), + m = props.model, + [text, setText] = useControlledComponent(props, m.value), options = (m.choices || []).map((text, i): Fluent.IComboBoxOption => ({ key: `${i}`, text })), onChange = (_e: React.FormEvent, option?: Fluent.IComboBoxOption, _index?: number, value?: string) => { const v = option?.text || value || '' diff --git a/ui/src/copyable_text.tsx b/ui/src/copyable_text.tsx index 935158af083..33db18b1f2b 100644 --- a/ui/src/copyable_text.tsx +++ b/ui/src/copyable_text.tsx @@ -80,7 +80,7 @@ export const XCopyableText = ({ model }: { model: CopyableText }) => { return (
- + parseInt(s, 10)).filter(n => !isNaN(n)) if (ymd.length !== 3) return undefined - return new Date(ymd[0], ymd[1] - 1, ymd[2]) + return new Date(ymd[0], ymd[1] - 1, ymd[2], 0, 0, 0) } export const - XDatePicker = ({ model: m }: { model: DatePicker }) => { + XDatePicker = (props: { model: DatePicker }) => { const + m = props.model, defaultVal = m.value || null, - parsedVal = defaultVal ? parseDate(defaultVal) : null, - [value, setValue] = React.useState(parsedVal ? new Date(parsedVal) : undefined), + [value, setValue] = useControlledComponent(props, m.value), onSelectDate = (d: Date | null | undefined) => { - const val = (d === null || d === undefined) ? defaultVal : formatDate(d) + const val = !d ? defaultVal : formatDate(d) wave.args[m.name] = val - setValue(val ? new Date(`${val} 00:00:00`) : undefined) + setValue(val) if (m.trigger) wave.push() } @@ -79,7 +80,7 @@ export const { + BaseDropdown = (props: Dropdown) => { const + { name, label, required, disabled, value, values, choices, trigger, placeholder } = props, isMultivalued = !!values, selection = React.useMemo(() => isMultivalued ? new Set(values) : null, [isMultivalued, values]), - [selectedOptions, setSelectedOptions] = React.useState(Array.from(selection || [])), + [selected, setSelected] = useControlledComponent(props, value), + [selectedOptions, setSelectedOptions] = useControlledComponent(props, values), options = (choices || []).map(({ name, label, disabled }): Fluent.IDropdownOption => ({ key: name, text: label || name, disabled })), onChange = (_e?: React.FormEvent, option?: Fluent.IDropdownOption) => { if (option) { @@ -97,6 +100,7 @@ const wave.args[name] = selectedOpts setSelectedOptions(selectedOpts) } else { + setSelected(String(option.key)) wave.args[name] = optionKey } } @@ -134,7 +138,7 @@ const required={required} disabled={disabled} multiSelect={isMultivalued || undefined} - defaultSelectedKey={!isMultivalued ? value : undefined} + selectedKey={!isMultivalued ? selected : undefined} selectedKeys={isMultivalued ? selectedOptions : undefined} onChange={onChange} /> @@ -151,8 +155,9 @@ const }, ROW_HEIGHT = 44, PAGE_SIZE = 40, - DialogDropdown = ({ name, choices, values, value, disabled, required, trigger, placeholder, label }: Dropdown) => { + DialogDropdown = (props: Dropdown) => { const + { name, choices, values, value, disabled, required, trigger, placeholder, label } = props, isMultivalued = !!values, [isDialogHidden, setIsDialogHidden] = React.useState(true), initialSelectedMap = React.useMemo(() => { @@ -162,14 +167,15 @@ const }, [value, values]), items = React.useMemo(() => choices?.map(({ name, label }, idx) => ({ name, text: label || name, idx, checked: initialSelectedMap.has(name) })) || [], [initialSelectedMap, choices]), [filteredItems, setFilteredItems] = React.useState(items), - [textValue, setTextValue] = React.useState(() => { + getInitialTextValue = () => { if (!values?.length && !value) return const itemsMap = new Map(items.map(({ name, text }) => [name, text])) if (values?.length) return values.map(v => itemsMap.get(v) || '').filter(Boolean).join(', ') if (value) return itemsMap.get(value) - }), + }, + [textValue, setTextValue] = useControlledComponent(props, getInitialTextValue()), toggleDialog = React.useCallback(() => setIsDialogHidden(!isDialogHidden), [isDialogHidden]), cancelDialog = React.useCallback(() => { toggleDialog() @@ -184,7 +190,7 @@ const initialSelectedMap.clear() result.forEach(({ name }) => initialSelectedMap.set(name, true)) cancelDialog() - }, [cancelDialog, initialSelectedMap, items, name, trigger]), + }, [cancelDialog, initialSelectedMap, items, name, trigger, setTextValue]), selectAll = (checked = true) => () => setFilteredItems(filteredItems.map(i => { i.checked = checked; return i })), onSearchChange = (_e?: React.ChangeEvent, newVal = '') => setFilteredItems(newVal ? items.filter(({ text }) => fuzzysearch(text, newVal)) : items), onChecked = React.useCallback((idx: U) => (_ev?: React.FormEvent, checked = false) => { @@ -203,6 +209,18 @@ const checked={item.checked} /> : null, [onChecked]), getPageSpecification = React.useCallback(() => ({ itemCount: PAGE_SIZE, height: ROW_HEIGHT * PAGE_SIZE, } as Fluent.IPageSpecification), []) + + React.useEffect(() => { + setFilteredItems(items => { + return items.map(i => { + if (isMultivalued ? props?.values?.includes(i.name) : i.name === props.value) { + return ({ ...i, checked: true}) + } else { + return ({ ...i, checked: false }) + } + }) + }) + }, [props, initialSelectedMap, isMultivalued]) return ( <> diff --git a/ui/src/hooks.ts b/ui/src/hooks.ts new file mode 100644 index 00000000000..a3b760e1ac4 --- /dev/null +++ b/ui/src/hooks.ts @@ -0,0 +1,7 @@ +import { useEffect, useState } from "react" + +export function useControlledComponent(props: any, val: any) { + const [value, setValue] = useState(val) + useEffect(() => setValue(val), [props]) + return [value, setValue] +} \ No newline at end of file diff --git a/ui/src/nav.tsx b/ui/src/nav.tsx index 5e299f59b11..99b81af276d 100644 --- a/ui/src/nav.tsx +++ b/ui/src/nav.tsx @@ -15,11 +15,8 @@ import * as Fluent from '@fluentui/react' import { B, Id, Model, S } from 'h2o-wave' import React from 'react' -import { stylesheet } from 'typestyle' -import { Component, XComponents } from './form' -import { CardEffect, cards, getEffectClass, toCardEffect } from './layout' -import { XPersona } from './persona' -import { clas, cssVar, padding } from './theme' +import { Component, } from './form' +import { CardEffect, cards } from './layout' import { bond, wave } from './ui' /** Create a navigation item. */ @@ -70,93 +67,49 @@ export interface State { color?: 'card' | 'primary' } -const css = stylesheet({ - card: { - display: 'flex', - flexDirection: 'column' - }, - title: { - color: cssVar('$themePrimary') - }, - icon: { - fontSize: 56 - }, - header: { - padding: padding(24, 24, 0), - textAlign: 'center' - }, - img: { - maxHeight: 100 - }, - brand: { - marginBottom: 10 - }, - secondaryItems: { - padding: 24, - }, - persona: { - $nest: { - '.ms-Persona': { - flexDirection: 'column', - height: 'auto', - }, - '.ms-Persona-details': { - alignItems: 'center', - padding: 0 - }, - '.ms-Persona-primaryText': { - fontWeight: 500, - marginTop: 12, - } - }, - }, -}) - export const - XNav = ({ items, value, hideNav }: State & { hideNav?: () => void }) => { - const groups = items.map((g): Fluent.INavLinkGroup => ({ - name: g.label, - collapseByDefault: g.collapsed, - links: g.items.map(({ name, label, icon, disabled, tooltip }): Fluent.INavLink => ({ - key: name, - name: label, - icon, - disabled, - title: tooltip, - style: disabled ? { opacity: 0.7 } : undefined, - url: '', - onClick: () => { - if (hideNav) hideNav() - if (name.startsWith('#')) { - window.location.hash = name.substr(1) - return + XNav = (props: State & { hideNav?: () => void }) => { + const + { items, hideNav } = props, + [value, setValue] = React.useState(props.value), + ref = React.useRef(false), + groups = items.map((g): Fluent.INavLinkGroup => ({ + name: g.label, + collapseByDefault: g.collapsed, + links: g.items.map(({ name, label, icon, disabled, tooltip }): Fluent.INavLink => ({ + key: name, + name: label, + icon, + disabled, + title: tooltip, + style: disabled ? { opacity: 0.7 } : undefined, + url: '', + onClick: () => { + setValue(name) + ref.current = true + if (hideNav) hideNav() + if (name.startsWith('#')) { + window.location.hash = name.substr(1) + return + } + wave.args[name] = true + wave.push() } - wave.args[name] = true - wave.push() - } + })) })) - })) - return + + React.useEffect(() => { + if (!ref.current) setValue(props.value) + else ref.current = false + }, [props]) + + + + return }, - View = bond(({ name, state, changed }: Model) => { + View = bond(({ state, changed }: Model) => { const render = () => { - const { title, subtitle, icon, icon_color = '$text', image, persona, secondary_items, color = 'card' } = state - return ( -
-
- {(image || icon) && ( -
- {image && } - {icon && !image && } -
- )} - {title &&
{title}
} - {subtitle &&
{subtitle}
} - {!image && !icon && persona?.persona &&
} -
- - {secondary_items &&
} -
) + return } return { render, changed } }) diff --git a/ui/src/picker.tsx b/ui/src/picker.tsx index 0190ce992aa..86991c8933b 100644 --- a/ui/src/picker.tsx +++ b/ui/src/picker.tsx @@ -16,6 +16,7 @@ import * as Fluent from '@fluentui/react' import { B, Id, S, U } from 'h2o-wave' import React from 'react' import { Choice } from './choice_group' +import { useControlledComponent } from './hooks' import { wave } from './ui' /** @@ -53,10 +54,12 @@ const pickerSuggestionsProps: Fluent.IBasePickerSuggestionsProps = { noResultsFoundText: 'No results found', } -export const XPicker = ({ model: m }: { model: Picker }) => { +export const XPicker = (props: { model: Picker }) => { const + { model: m } = props, tags: Fluent.ITag[] = React.useMemo(() => m.choices.map(({ name, label }) => ({ key: name, name: label || name })), [m.choices]), - [selectedTags, setSelectedTags] = React.useState(tags.filter(({ key }) => m.values?.includes(key as S))), + + [selectedTags, setSelectedTags] = useControlledComponent(props, tags.filter(({ key }) => m.values?.includes(key as S))), filterSuggestedTags = (filterText: S, selectedTags?: Fluent.ITag[]) => { if (!filterText) return [] const isStringMatch = (name: S) => name.toLowerCase().includes(filterText.toLowerCase()) diff --git a/ui/src/slider.tsx b/ui/src/slider.tsx index d60292c530e..32e196c06b1 100644 --- a/ui/src/slider.tsx +++ b/ui/src/slider.tsx @@ -15,6 +15,7 @@ import * as Fluent from '@fluentui/react' import { B, F, Id, S, U } from 'h2o-wave' import React from 'react' +import { useControlledComponent } from './hooks' import { wave } from './ui' /** @@ -59,11 +60,16 @@ export interface Slider { } export const - XSlider = ({ model: m }: { model: Slider }) => { + XSlider = (props: { model: Slider }) => { const + m = props.model, { min = 0, max = 100, step = 1, value = 0 } = m, defaultValue = (value < min) ? min : ((value > max) ? max : value), - onChange = (v: U) => wave.args[m.name] = v, + [val, setVal] = useControlledComponent(props, value), + onChange = (v: U) => { + setVal(v) + wave.args[m.name] = v + }, onChanged = React.useCallback((_e: MouseEvent | KeyboardEvent | TouchEvent, _value: U) => { if (m.trigger) wave.push() }, [m.trigger]) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -77,7 +83,7 @@ export const min={min} max={max} step={step} - defaultValue={defaultValue} + value={val} showValue originFromZero={min < 0 && max >= 0} onChange={onChange} diff --git a/ui/src/spinbox.tsx b/ui/src/spinbox.tsx index 75099a5de57..36d8ee40cfb 100644 --- a/ui/src/spinbox.tsx +++ b/ui/src/spinbox.tsx @@ -15,6 +15,7 @@ import * as Fluent from '@fluentui/react' import { B, F, Id, S, U } from 'h2o-wave' import React from 'react' +import { useControlledComponent } from './hooks' import { wave } from './ui' /** @@ -65,9 +66,10 @@ const return -groups[1]?.length || groups[2]?.length || 0 } export const - XSpinbox = ({ model: { name, trigger, label, disabled, min = 0, max = 100, step = 1, value = 0 } }: { model: Spinbox }) => { + XSpinbox = (props: { model: Spinbox }) => { const - [val, setVal] = React.useState(String(value)), + { name, trigger, label, disabled, min = 0, max = 100, step = 1, value = 0 } = props.model, + [val, setVal] = useControlledComponent(props, String(value)), precision = Math.max(calculatePrecision(step), 0), parseValue = React.useCallback((v: F) => { const x = precisionRound(v, precision) diff --git a/ui/src/tab.tsx b/ui/src/tab.tsx index 5651a1c6eca..afd65386be0 100644 --- a/ui/src/tab.tsx +++ b/ui/src/tab.tsx @@ -43,34 +43,51 @@ const }) export const - View = bond(({ name, state, changed }: Model) => { + XTab = (props: State) => { const + [value, setValue] = React.useState(props.value), + ref = React.useRef(false), onLinkClick = (item?: PivotItem) => { const name = item?.props.itemKey if (!name) return + setValue(name) + ref.current = true if (name.startsWith('#')) { window.location.hash = name.substr(1) return } - if (state.name) { - wave.args[state.name] = name + if (name) { + wave.args[String(props.name)] = name } else { - wave.args[name] = true + wave.args[String(props.name)] = true } wave.push() }, - render = () => { - const - linkFormat = state.link ? 'links' : 'tabs', - items = state.items.map(({ name, label, icon }) => ( - - )) - return ( -
- {items} -
- ) + linkFormat = props.link ? 'links' : 'tabs', + items = props.items.map(({ name, label, icon }) => ( + + )) + + React.useEffect(() => console.log('created'), []) + React.useLayoutEffect(() => { + if (!ref.current) { + setValue(props.value) + } else { + ref.current = false } + }, [props]) + + return ( + {items} + ) + }, + + View = bond(({ name, state, changed }: Model) => { + const render = () => ( +
+ +
+ ) return { render, changed } }) diff --git a/ui/src/tabs.tsx b/ui/src/tabs.tsx index d9af5ff14ba..d46b1d0e89c 100644 --- a/ui/src/tabs.tsx +++ b/ui/src/tabs.tsx @@ -16,6 +16,7 @@ import * as Fluent from '@fluentui/react' import { B, Id, S } from 'h2o-wave' import React from 'react' import { stylesheet } from 'typestyle' +import { useControlledComponent } from './hooks' import { wave } from './ui' /** @@ -61,18 +62,21 @@ const }) export const - XTabs = ({ model: m }: { model: Tabs }) => { + XTabs = (props: { model: Tabs }) => { const + { name, items, link, value } = props.model, + [val, setVal] = useControlledComponent(props, value), onLinkClick = (item?: Fluent.PivotItem) => { const name = item?.props.itemKey if (!name) return + setVal(name) if (name.startsWith('#')) { window.location.hash = name.substr(1) return } - if (m.name) { - if (name !== wave.args[m.name]) { - wave.args[m.name] = name + if (name) { + if (name !== wave.args[name]) { + wave.args[name] = name wave.push() } } else { @@ -80,14 +84,14 @@ export const wave.push() } }, - tabs = m.items?.map(t => ) + tabs = items?.map(t => ) return (
{tabs}
) diff --git a/ui/src/textbox.tsx b/ui/src/textbox.tsx index 88dd7e0c35e..ef1088f67e1 100644 --- a/ui/src/textbox.tsx +++ b/ui/src/textbox.tsx @@ -15,6 +15,7 @@ import * as Fluent from '@fluentui/react' import { B, Id, S } from 'h2o-wave' import React from 'react' +import { useControlledComponent } from './hooks' import { debounce, wave } from './ui' /** @@ -69,13 +70,17 @@ export interface Textbox { const DEBOUNCE_TIMEOUT = 500 export const - XTextbox = ({ model: m }: { model: Textbox }) => { - const onChange = ({ target }: React.FormEvent, v?: S) => { - v = v || (target as HTMLInputElement).value + XTextbox = (props: { model: Textbox }) => { + const + m = props.model, + onChange = ({ target }: React.FormEvent, v?: S) => { + v = v || (target as HTMLInputElement).value - wave.args[m.name] = v ?? (m.value || '') - if (m.trigger) wave.push() - } + wave.args[m.name] = v ?? (m.value || '') + setValue(v) + if (m.trigger) wave.push() + }, + [value, setValue] = useControlledComponent(props, props.model.value) // eslint-disable-next-line react-hooks/exhaustive-deps React.useEffect(() => { wave.args[m.name] = m.value || '' }, []) @@ -85,7 +90,7 @@ export const { - const onChange = React.useCallback((_e?: React.FormEvent, checked?: B) => { - wave.args[m.name] = !!checked - if (m.trigger) wave.push() - }, [m.name, m.trigger]) + XToggle = (props: { model: Toggle }) => { + const + { name, value, label, disabled, trigger } = props.model, + [val, setVal] = useControlledComponent(props, value), + onChange = React.useCallback((_e?: React.FormEvent, checked?: B) => { + setVal(!!checked) + wave.args[name] = !!checked + if (trigger) wave.push() + }, [name, trigger, setVal]) // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => { wave.args[m.name] = !!m.value }, []) + React.useEffect(() => { wave.args[name] = !!value }, []) return (