Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions web/app/components/app/log/__tests__/filter.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ it('combines text search, sort field and direction, then clears only the keyword
const user = userEvent.setup()
render(<ControlledFilter />)
await user.type(screen.getByRole('searchbox'), 'failure')
await user.click(screen.getByRole('button', { name: /filter.sortBy/ }))
await user.click(screen.getByRole('button', { name: /sorting.sortBy/ }))
await user.click(await screen.findByRole('menuitemradio', { name: /header.updatedTime/ }))
await user.click(screen.getByRole('button', { name: /filter.descending/ }))
await user.click(screen.getByRole('button', { name: /sorting.descending/ }))
expect(onChange).toHaveBeenLastCalledWith({
...initialQuery,
keyword: 'failure',
Expand All @@ -166,7 +166,7 @@ it('combines text search, sort field and direction, then clears only the keyword

it('omits chat sorting for completion logs', () => {
render(<ControlledFilter isChatMode={false} />)
expect(screen.queryByRole('button', { name: /filter.sortBy/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /sorting.sortBy/ })).not.toBeInTheDocument()
})

it.each(['loading', 'unavailable'] as const)(
Expand Down
12 changes: 7 additions & 5 deletions web/app/components/app/log/filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Chip from '@/app/components/base/chip'
import { SearchInput } from '@/app/components/base/search-input'
import Sort from '@/app/components/base/sort'
import { Sort } from '@/app/components/base/sort'
import { consoleQuery } from '@/service/console'
import {
CLOUD_SANDBOX_CLEARED_TIME_PERIOD,
Expand Down Expand Up @@ -124,17 +124,19 @@ const Filter: FC<IFilterProps> = ({
<>
<div className="h-3.5 w-px bg-divider-regular"></div>
<Sort
order={queryParams.sort_by?.startsWith('-') ? '-' : ''}
value={queryParams.sort_by?.replace('-', '') || 'created_at'}
value={{
field: queryParams.sort_by?.endsWith('updated_at') ? 'updated_at' : 'created_at',
order: queryParams.sort_by?.startsWith('-') ? 'desc' : 'asc',
}}
items={[
{ value: 'created_at', name: t(($) => $['table.header.time'], { ns: 'appLog' }) },
{
value: 'updated_at',
name: t(($) => $['table.header.updatedTime'], { ns: 'appLog' }),
},
]}
onSelect={(value) => {
setQueryParams({ ...queryParams, sort_by: value as QueryParam['sort_by'] })
onValueChange={({ field, order }) => {
setQueryParams({ ...queryParams, sort_by: order === 'desc' ? `-${field}` : field })
}}
/>
</>
Expand Down
53 changes: 53 additions & 0 deletions web/app/components/base/sort/__tests__/index.browser.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { page } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { Sort } from '../index'

vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'common.sorting.sortBy': 'Sort by:',
'common.sorting.ascending': 'Sort ascending',
'common.sorting.descending': 'Sort descending',
})
})

it('keeps long field names readable and the direction action reachable in a narrow RTL layout', async () => {
await page.viewport(320, 600)
const label = 'Zeitpunkt der letzten Aktualisierung des Dokuments'
const onValueChange = vi.fn()
const screen = await render(
<div dir="rtl" style={{ display: 'flex', flexDirection: 'column', width: 240 }}>
<Sort
items={[{ value: 'updated_at', name: label }]}
value={{ field: 'updated_at', order: 'desc' }}
onValueChange={onValueChange}
/>
</div>,
)
const trigger = screen.getByRole('button', { name: `Sort by: ${label}` })
const direction = screen.getByRole('button', { name: 'Sort ascending' })
await expect.element(trigger).toBeVisible()
await expect.element(direction).toBeVisible()
const triggerBounds = trigger.element().getBoundingClientRect()
const directionBounds = direction.element().getBoundingClientRect()
expect(directionBounds.right).toBeLessThanOrEqual(triggerBounds.left)
expect(triggerBounds.right - directionBounds.left).toBeLessThanOrEqual(240)

await trigger.click()
const option = page.getByRole('menuitemradio', { name: label })
await expect.element(option).toBeVisible()
const text = page.getByRole('menu').getByText(label, { exact: true }).element()
const range = document.createRange()
range.selectNodeContents(text)
const optionBounds = option.element().getBoundingClientRect()
for (const line of range.getClientRects()) {
expect(line.left).toBeGreaterThanOrEqual(optionBounds.left)
expect(line.right).toBeLessThanOrEqual(optionBounds.right)
expect(line.top).toBeGreaterThanOrEqual(optionBounds.top)
expect(line.bottom).toBeLessThanOrEqual(optionBounds.bottom)
}
await option.click()
await expect.element(page.getByRole('menu')).not.toBeInTheDocument()
await direction.click()
expect(onValueChange).toHaveBeenLastCalledWith({ field: 'updated_at', order: 'asc' })
})
79 changes: 60 additions & 19 deletions web/app/components/base/sort/__tests__/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,56 @@
import type { SortValue } from '../index'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import Sort from '../index'
import { Sort } from '../index'

const items = [
{ value: 'created_at', name: 'Date Created' },
{ value: 'name', name: 'Name' },
]
] as const

it('selects a valid ascending sort key when no direction is supplied', async () => {
it('requests a new field without changing the controlled value or its direction', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(<Sort items={items} value="created_at" onSelect={onSelect} />)
await user.click(screen.getByRole('button', { name: /appLog.filter.sortBy/ }))
const onValueChange = vi.fn()
render(
<Sort
items={items}
value={{ field: 'created_at', order: 'asc' }}
onValueChange={onValueChange}
/>,
)
await user.click(screen.getByRole('button', { name: /common.sorting.sortBy/ }))
await user.click(screen.getByRole('menuitemradio', { name: 'Name' }))
expect(onSelect).toHaveBeenCalledExactlyOnceWith('name')
expect(onValueChange).toHaveBeenCalledExactlyOnceWith({ field: 'name', order: 'asc' })
expect(screen.getByRole('button', { name: /common.sorting.sortBy/ })).toHaveTextContent(
'Date Created',
)
await user.click(screen.getByRole('button', { name: 'common.sorting.descending' }))
expect(onValueChange).toHaveBeenLastCalledWith({ field: 'created_at', order: 'desc' })
expect(screen.getByRole('button', { name: 'common.sorting.descending' })).toBeInTheDocument()
})

it('preserves direction when choosing a field and toggles it without losing the field', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onValueChange = vi.fn()
function Harness() {
const [sort, setSort] = useState('-created_at')
const [sort, setSort] = useState<SortValue<(typeof items)[number]['value']>>({
field: 'created_at',
order: 'desc',
})
return (
<Sort
items={items}
value={sort.replace(/^-/, '')}
order={sort.startsWith('-') ? '-' : ''}
onSelect={(next) => {
onSelect(next)
value={sort}
onValueChange={(next) => {
onValueChange(next)
setSort(next)
}}
/>
)
}
render(<Harness />)
const trigger = screen.getByRole('button', { name: /appLog.filter.sortBy/ })
const trigger = screen.getByRole('button', { name: /common.sorting.sortBy/ })
await user.click(trigger)
expect(screen.getByRole('menuitemradio', { name: 'Date Created' })).toHaveAttribute(
'aria-checked',
Expand All @@ -46,11 +61,37 @@ it('preserves direction when choosing a field and toggles it without losing the
'false',
)
await user.click(screen.getByRole('menuitemradio', { name: 'Name' }))
expect(onSelect).toHaveBeenLastCalledWith('-name')
expect(onValueChange).toHaveBeenLastCalledWith({ field: 'name', order: 'desc' })
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument())
expect(trigger).toHaveTextContent('Name')
await user.click(screen.getByRole('button', { name: 'appLog.filter.ascending' }))
expect(onSelect).toHaveBeenLastCalledWith('name')
await user.click(screen.getByRole('button', { name: 'appLog.filter.descending' }))
expect(onSelect).toHaveBeenLastCalledWith('-name')
const directionButton = screen.getByRole('button', { name: 'common.sorting.ascending' })
expect(directionButton).not.toHaveAttribute('aria-pressed')
await user.hover(directionButton)
expect(await screen.findByText('common.sorting.ascending')).toBeVisible()
await user.click(directionButton)
expect(onValueChange).toHaveBeenLastCalledWith({ field: 'name', order: 'asc' })
await waitFor(() =>
expect(screen.queryByText('common.sorting.descending')).not.toBeInTheDocument(),
)
await user.click(screen.getByRole('button', { name: 'common.sorting.descending' }))
expect(onValueChange).toHaveBeenLastCalledWith({ field: 'name', order: 'desc' })
})
it('preserves numeric field identifiers when selecting a field or changing direction', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()
render(
<Sort
items={[
{ value: 0, name: 'First field' },
{ value: 1, name: 'Second field' },
]}
value={{ field: 0, order: 'desc' }}
onValueChange={onValueChange}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.sorting.ascending' }))
expect(onValueChange).toHaveBeenLastCalledWith({ field: 0, order: 'asc' })
await user.click(screen.getByRole('button', { name: /common.sorting.sortBy/ }))
await user.click(screen.getByRole('menuitemradio', { name: 'Second field' }))
expect(onValueChange).toHaveBeenLastCalledWith({ field: 1, order: 'desc' })
})
70 changes: 70 additions & 0 deletions web/app/components/base/sort/__tests__/types.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { SortValue } from '../index'
import { expectTypeOf } from 'vite-plus/test'
import { Sort } from '../index'

const items = [
{ value: 'created_at', name: 'Created time' },
{ value: 'updated_at', name: 'Updated time' },
] as const

export function sortValueTypeContract() {
return (
<Sort
items={items}
value={{ field: 'created_at', order: 'asc' }}
onValueChange={(value) => {
expectTypeOf(value.field).toEqualTypeOf<'created_at' | 'updated_at'>()
expectTypeOf(value.order).toEqualTypeOf<'asc' | 'desc'>()
}}
/>
)
}

export function invalidSortFieldTypeContract() {
const onValueChange = (_value: SortValue<string>) => {}
return (
<Sort
items={items}
// @ts-expect-error Neither the value nor a broad callback can widen the fields from items.
value={{ field: 'unknown', order: 'asc' }}
onValueChange={onValueChange}
/>
)
}

export function narrowSortCallbackTypeContract() {
return (
<Sort
items={items}
value={{ field: 'created_at', order: 'asc' }}
// @ts-expect-error The callback must handle every field in items.
onValueChange={(_value: SortValue<'created_at'>) => {}}
/>
)
}

export function numericSortFieldTypeContract() {
return (
<Sort
items={[
{ value: 0, name: 'First field' },
{ value: 1, name: 'Second field' },
]}
value={{ field: 0, order: 'desc' }}
onValueChange={(value) => {
expectTypeOf(value.field).toEqualTypeOf<0 | 1>()
}}
/>
)
}

export function invalidSortOrderTypeContract() {
return (
<Sort
items={items}
// @ts-expect-error Transport prefixes are not sort directions.
value={{ field: 'created_at', order: '-' }}
onValueChange={() => {}}
/>
)
}
32 changes: 10 additions & 22 deletions web/app/components/base/sort/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,29 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useMemo, useState } from 'react'
import Sort from '.'
import type { SortValue } from '.'
import { useState } from 'react'
import { Sort } from '.'

const SORT_ITEMS = [
{ value: 'created_at', name: 'Created time' },
{ value: 'updated_at', name: 'Updated time' },
{ value: 'latency', name: 'Latency' },
]
] as const

const SortPlayground = () => {
const [sortBy, setSortBy] = useState('-created_at')

const { order, value } = useMemo(() => {
const isDesc = sortBy.startsWith('-')
return {
order: isDesc ? '-' : '',
value: sortBy.replace('-', '') || 'created_at',
}
}, [sortBy])
const [value, setValue] = useState<SortValue<(typeof SORT_ITEMS)[number]['value']>>({
field: 'created_at',
order: 'desc',
})

return (
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
<span>Sort control</span>
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
sort_by="
{sortBy}"
{JSON.stringify(value)}
</code>
</div>
<Sort
order={order}
value={value}
items={SORT_ITEMS}
onSelect={(next) => {
setSortBy(next)
}}
/>
<Sort value={value} items={SORT_ITEMS} onValueChange={setValue} />
</div>
)
}
Expand Down
Loading
Loading