Skip to content

Commit

Permalink
feat: Allow dynamic value change for nav card #1154 (#2116)
Browse files Browse the repository at this point in the history
  • Loading branch information
marek-mihok authored Aug 16, 2023
1 parent 3b9c895 commit 31eeb25
Show file tree
Hide file tree
Showing 2 changed files with 145 additions and 45 deletions.
130 changes: 108 additions & 22 deletions ui/src/nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,36 @@ import { wave } from './ui'

const
name = 'nav',
hashName = `#${name}`,
label = 'label',
items = [{
label: 'group1',
items: [
{ name: 'nav1', label: 'Nav 1' },
{ name: 'nav2', label: 'Nav 2' }
]
}],
hashItems = [{
label: 'group1',
items: [
{ name: '#nav1', label: 'Nav 1' },
{ name: '#nav2', label: 'Nav 2' }
]
}],
navProps: T.Model<State> = {
name,
state: {
items: [
{ label: 'group1', items: [{ name, label }] }
]
},
state: { items },
changed: T.box(false)
},
navPropsHash: T.Model<State> = {
name,
state: {
items: [
{ label: 'group1', items: [{ name: hashName, label }] }
]
},
state: { items: hashItems },
changed: T.box(false)
}
describe('Nav.tsx', () => {
beforeEach(() => { wave.args[name] = null })
beforeEach(() => {
wave.args = [] as any
window.location.hash = ''
})

it('Renders data-test attr', () => {
const { queryByTestId } = render(<View {...navProps} />)
Expand All @@ -50,13 +58,13 @@ describe('Nav.tsx', () => {

it('Sets args - init', () => {
render(<View {...navProps} />)
expect(wave.args[name]).toBeNull()
expect(wave.args[name]).toBeUndefined()
})

it('Makes link active when value specified', () => {
const props: T.Model<State> = { ...navProps, state: { ...navProps.state, value: name } }
const props: T.Model<State> = { ...navProps, state: { ...navProps.state, value: 'nav1' } }
const { getByTitle } = render(<View {...props} />)
expect(getByTitle(label).parentElement).toHaveClass('is-selected')
expect(getByTitle('Nav 1').parentElement).toHaveClass('is-selected')
})

it('Makes link inactive when disabled is true', () => {
Expand All @@ -77,9 +85,9 @@ describe('Nav.tsx', () => {
wave.push = pushMock

const { getByTitle } = render(<View {...navProps} />)
fireEvent.click(getByTitle(label))
fireEvent.click(getByTitle('Nav 1'))

expect(wave.args[name]).toBe(true)
expect(wave.args['nav1']).toBe(true)
expect(pushMock).toHaveBeenCalled()
})

Expand All @@ -88,17 +96,17 @@ describe('Nav.tsx', () => {
wave.push = pushMock

const { getByTitle } = render(<View {...navPropsHash} />)
fireEvent.click(getByTitle(label))
fireEvent.click(getByTitle('Nav 1'))

expect(wave.args[name]).toBeNull()
expect(wave.args['nav1']).toBeUndefined()
expect(pushMock).toHaveBeenCalledTimes(0)
})

it('Does set window window location hash when name starts with hash', () => {
it('Sets window location hash when name starts with hash', () => {
const { getByTitle } = render(<View {...navPropsHash} />)
fireEvent.click(getByTitle(label))
fireEvent.click(getByTitle('Nav 1'))

expect(window.location.hash).toBe(hashName)
expect(window.location.hash).toBe('#nav1')
})

it('Collapses a group when collapse is specified', () => {
Expand Down Expand Up @@ -131,4 +139,82 @@ describe('Nav.tsx', () => {
fireEvent.click(getByTitle(label))
expect(windowOpenMock).toHaveBeenCalled()
})

describe('Value update', () => {
it('Sets args on value update', () => {
const props: T.Model<State> = { ...navProps, state: { items } }
const { rerender } = render(<View {...props} />)
expect(wave.args['nav2']).toBeUndefined()

props.state.value = 'nav2'
rerender(<View {...props} />)

expect(wave.args['nav2']).toBe(true)
})

it('Selects nav item on value update', () => {
const props: T.Model<State> = { ...navProps, state: { items } }
const { rerender, getByTitle } = render(<View {...props} />)
expect(getByTitle('Nav 1').parentElement).toHaveClass('is-selected')
expect(getByTitle('Nav 2').parentElement).not.toHaveClass('is-selected')

props.state.value = 'nav2'
rerender(<View {...props} />)

expect(getByTitle('Nav 1').parentElement).not.toHaveClass('is-selected')
expect(getByTitle('Nav 2').parentElement).toHaveClass('is-selected')
})

it('Selects nav item when value is updated to the same value twice', () => {
const
props: T.Model<State> = { ...navProps, state: { items } },
expectFirstSelected = () => {
expect(getByTitle('Nav 1').parentElement).toHaveClass('is-selected')
expect(getByTitle('Nav 2').parentElement).not.toHaveClass('is-selected')
},
expectSecondSelected = () => {
expect(getByTitle('Nav 1').parentElement).not.toHaveClass('is-selected')
expect(getByTitle('Nav 2').parentElement).toHaveClass('is-selected')
},
{ rerender, getByTitle } = render(<View {...props} />)

expectFirstSelected()

props.state.value = 'nav2'
rerender(<View {...props} />)
expect(wave.args['nav2']).toBe(true)
expectSecondSelected()

fireEvent.click(getByTitle('Nav 1'))
expect(wave.args['nav1']).toBe(true)
expectFirstSelected()

props.state.value = 'nav2'
rerender(<View {...props} />)
expect(wave.args['nav2']).toBe(true)
expectSecondSelected()
})

it('Does not set args on value update when name starts with hash', () => {
const props: T.Model<State> = { ...navProps, state: { items: hashItems } }
const { rerender } = render(<View {...props} />)
expect(wave.args['nav2']).toBeUndefined()

props.state.value = '#nav2'
rerender(<View {...props} />)

expect(wave.args['nav2']).toBeUndefined()
})

it('Set window location hash when updated value starts with hash', () => {
const props: T.Model<State> = { ...navProps, state: { items: hashItems } }
const { rerender } = render(<View {...props} />)
expect(window.location.hash).toBe('')

props.state.value = '#nav2'
rerender(<View {...props} />)

expect(window.location.hash).toBe('#nav2')
})
})
})
60 changes: 37 additions & 23 deletions ui/src/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

import * as Fluent from '@fluentui/react'
import { B, Id, Model, S } from 'h2o-wave'
import { B, Box, Id, Model, S, box, on } from 'h2o-wave'
import React from 'react'
import { stylesheet } from 'typestyle'
import { Component, XComponents } from './form'
Expand Down Expand Up @@ -117,7 +117,7 @@ const css = stylesheet({
})

export const
XNav = ({ items, value, hideNav, linksOnly = false }: State & { hideNav?: () => void, linksOnly?: B }) => {
XNav = ({ items, hideNav, linksOnly = false, valueB }: State & { hideNav?: () => void, linksOnly?: B, valueB: Box<S | undefined> }) => {
const groups = items.map((g): Fluent.INavLinkGroup => ({
name: g.label,
collapseByDefault: g.collapsed,
Expand All @@ -133,6 +133,7 @@ export const
},
url: '',
onClick: () => {
valueB(name)
if (hideNav) hideNav()
if (path) window.open(path, "_blank")
else if (name.startsWith('#')) window.location.hash = name.substring(1)
Expand All @@ -143,29 +144,42 @@ export const
}
}))
}))
return <Fluent.Nav groups={groups} selectedKey={value} styles={{ groupContent: { marginBottom: 0 } }} />
return <Fluent.Nav groups={groups} selectedKey={valueB() || groups[0].links[0].key} styles={{ groupContent: { marginBottom: 0 } }} />
},
View = bond(({ name, state, changed }: Model<State>) => {
const render = () => {
const { title, subtitle, icon, color = 'card', icon_color = color === 'primary' ? '$card' : '$text', image, persona, secondary_items } = state
return (
<div data-test={name} className={clas(getEffectClass(toCardEffect(color)), css.card)} style={{ background: cssVar(`$${color}`) }}>
<div className={css.header}>
{(image || icon) && (
<div className={css.brand}>
{image && <img src={image} className={css.img} />}
{icon && !image && <Fluent.Icon iconName={icon} className={css.icon} style={{ color: cssVar(icon_color) }} />}
</div>
)}
{title && <div className={clas('wave-s24 wave-w6', color === 'card' ? 'wave-p9' : 'wave-c9')}>{title}</div>}
{subtitle && <div className={clas('wave-s13', color === 'card' ? 'wave-t8' : 'wave-c8')}>{subtitle}</div>}
{!image && !icon && persona?.persona && <div className={css.persona}><XPersona model={persona.persona} /></div>}
</div>
<XNav {...state} linksOnly={!image && !icon && !title && !subtitle && !persona} />
{secondary_items && <div className={css.secondaryItems} style={{ marginTop: state.items.length ? 'auto' : 'initial' }}><XComponents items={secondary_items} /></div>}
</div>)
}
return { render, changed }
const
valueB = box<S | undefined>(state.value),
valueWatcher = on(valueB, val => state.value = val),
render = () => {
const { title, subtitle, icon, color = 'card', icon_color = color === 'primary' ? '$card' : '$text', image, persona, secondary_items } = state
return (
<div data-test={name} className={clas(getEffectClass(toCardEffect(color)), css.card)} style={{ background: cssVar(`$${color}`) }}>
<div className={css.header}>
{(image || icon) && (
<div className={css.brand}>
{image && <img src={image} className={css.img} />}
{icon && !image && <Fluent.Icon iconName={icon} className={css.icon} style={{ color: cssVar(icon_color) }} />}
</div>
)}
{title && <div className={clas('wave-s24 wave-w6', color === 'card' ? 'wave-p9' : 'wave-c9')}>{title}</div>}
{subtitle && <div className={clas('wave-s13', color === 'card' ? 'wave-t8' : 'wave-c8')}>{subtitle}</div>}
{!image && !icon && persona?.persona && <div className={css.persona}><XPersona model={persona.persona} /></div>}
</div>
<XNav {...state} linksOnly={!image && !icon && !title && !subtitle && !persona} valueB={valueB} />
{secondary_items && <div className={css.secondaryItems} style={{ marginTop: state.items.length ? 'auto' : 'initial' }}><XComponents items={secondary_items} /></div>}
</div>)
},
update = (prevProps: Model<State>) => {
if (prevProps.state.value === valueB()) return
valueB(prevProps.state.value)
const name = prevProps.state.value || prevProps.state.items[0].items[0].name

if (name.startsWith('#')) window.location.hash = name.substring(1)
else wave.args[name] = true
},
dispose = () => valueWatcher.dispose()

return { render, changed, update, valueB, dispose }
})

cards.register('nav', View, { effect: CardEffect.Flat, marginless: true })

0 comments on commit 31eeb25

Please sign in to comment.