feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
@@ -23,6 +23,7 @@ export { BadgeListCell } from './core/badge-list-cell'
|
||||
export { TruncatedCell } from './core/truncated-cell'
|
||||
export { DataTableViewOptions } from './toolbar/view-options'
|
||||
export { DataTableToolbar } from './toolbar/toolbar'
|
||||
export { DataTableMobileFilterPanel } from './toolbar/mobile-filter-panel'
|
||||
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
||||
export {
|
||||
StaticDataTable,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnFiltersState,
|
||||
} from '@tanstack/react-table'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { DataTableToolbar } from '../toolbar'
|
||||
|
||||
const rows = [{ name: 'Channel', model: 'model', status: 'enabled' }]
|
||||
const filters = [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: 'Status',
|
||||
options: [{ value: 'enabled', label: 'Enabled' }],
|
||||
},
|
||||
]
|
||||
|
||||
function Fixture(props: { collapsibleOnMobile?: boolean }) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([
|
||||
{ id: 'status', value: ['enabled'] },
|
||||
])
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns: [
|
||||
{ accessorKey: 'name' },
|
||||
{ accessorKey: 'model' },
|
||||
{ accessorKey: 'status' },
|
||||
],
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
state: { columnFilters },
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
})
|
||||
return (
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
collapsibleOnMobile={props.collapsibleOnMobile}
|
||||
searchPlaceholder='Search channels'
|
||||
searchDebounceMs={50}
|
||||
filters={filters}
|
||||
additionalSearch={
|
||||
<Input
|
||||
aria-label='Model'
|
||||
value={String(table.getColumn('model')?.getFilterValue() ?? '')}
|
||||
onChange={(event) =>
|
||||
table.getColumn('model')?.setFilterValue(event.target.value)
|
||||
}
|
||||
/>
|
||||
}
|
||||
preActions={<Button>Refresh</Button>}
|
||||
viewToggle={<Button>Card view</Button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function setMobileViewport(mobile: boolean) {
|
||||
const original = window.matchMedia
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
...original(query),
|
||||
matches: mobile && query === '(max-width: 640px)',
|
||||
}))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('hides the mobile filter group while keeping reset and quick actions accessible', async () => {
|
||||
setMobileViewport(true)
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture collapsibleOnMobile />)
|
||||
|
||||
const collapse = screen.getByRole('button', { name: 'Collapse' })
|
||||
expect(collapse).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByPlaceholderText('Search channels')).toBeVisible()
|
||||
expect(screen.getByRole('textbox', { name: 'Model' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: /^Status/ })).toBeVisible()
|
||||
collapse.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^Status/ })
|
||||
).not.toBeInTheDocument()
|
||||
for (const name of ['Reset', 'Refresh', 'Card view', 'View']) {
|
||||
expect(screen.getByRole('button', { name })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name })).toBeEnabled()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains mobile search and filter values after expanding and resets them while collapsed', async () => {
|
||||
setMobileViewport(true)
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture collapsibleOnMobile />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Search channels'), 'production')
|
||||
await user.type(screen.getByRole('textbox', { name: 'Model' }), 'gpt')
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
expect(screen.getByPlaceholderText('Search channels')).toHaveValue(
|
||||
'production'
|
||||
)
|
||||
expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('gpt')
|
||||
expect(screen.getByRole('button', { name: /^Status/ })).toHaveTextContent(
|
||||
'Enabled'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
|
||||
expect(screen.getByPlaceholderText('Search channels')).toHaveValue('')
|
||||
expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Status' })).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Reset' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ mobile: false, collapsibleOnMobile: true, condition: 'on desktop' },
|
||||
{ mobile: true, collapsibleOnMobile: undefined, condition: 'without opt-in' },
|
||||
])('keeps filters visible without a panel toggle $condition', (testCase) => {
|
||||
setMobileViewport(testCase.mobile)
|
||||
render(<Fixture collapsibleOnMobile={testCase.collapsibleOnMobile} />)
|
||||
|
||||
expect(screen.getByPlaceholderText('Search channels')).toBeVisible()
|
||||
expect(screen.getByRole('textbox', { name: 'Model' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: /^Status/ })).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^(Collapse|Expand)$/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type DataTableMobileFilterPanelProps = {
|
||||
children: ReactNode
|
||||
actions: ReactNode
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataTableMobileFilterPanel(
|
||||
props: DataTableMobileFilterPanelProps
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className={cn(
|
||||
'bg-card/50 flex min-w-0 flex-col rounded-lg border p-2.5',
|
||||
open && (props.compact ? 'gap-2.5' : 'gap-2'),
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<CollapsibleContent>{props.children}</CollapsibleContent>
|
||||
<div
|
||||
className={cn(
|
||||
props.compact
|
||||
? 'grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2'
|
||||
: 'flex items-center justify-end gap-1.5'
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
aria-label={open ? t('Collapse') : t('Expand')}
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
props.compact ? 'size-9' : 'mr-auto size-7'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
props.compact ? 'size-4' : 'size-3.5',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<div
|
||||
role='group'
|
||||
aria-label={t('Actions')}
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-1.5',
|
||||
props.compact &&
|
||||
'min-w-0 flex-wrap [&>button]:h-auto [&>button]:min-h-9 [&>button]:max-w-full [&>button]:[overflow-wrap:anywhere] [&>button]:whitespace-normal'
|
||||
)}
|
||||
>
|
||||
{props.actions}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -24,10 +24,11 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { useDebounce, useMediaQuery } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { DataTableFacetedFilter } from './faceted-filter'
|
||||
import { DataTableMobileFilterPanel } from './mobile-filter-panel'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
|
||||
type FilterDef = {
|
||||
@@ -78,6 +79,10 @@ export type DataTableToolbarProps<TData> = {
|
||||
* search input and filter chips.
|
||||
*/
|
||||
additionalSearch?: ReactNode
|
||||
/**
|
||||
* Collapse the complete filter panel on mobile while keeping actions visible.
|
||||
*/
|
||||
collapsibleOnMobile?: boolean
|
||||
/**
|
||||
* Whether non-table filters (e.g. `additionalSearch` or `expandable`
|
||||
* inputs) are currently active. Controls Reset button visibility
|
||||
@@ -155,6 +160,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isSearchComposing, setIsSearchComposing] = useState(false)
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
const filters = props.filters ?? []
|
||||
const hasExpandable = props.expandable != null
|
||||
@@ -341,6 +347,33 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
|
||||
const hasLeftActions = props.leftActions != null
|
||||
|
||||
if (isMobile && props.collapsibleOnMobile) {
|
||||
return (
|
||||
<DataTableMobileFilterPanel
|
||||
compact
|
||||
className={props.className}
|
||||
actions={
|
||||
<>
|
||||
{props.leftActions}
|
||||
{props.preActions}
|
||||
{resetButton}
|
||||
{searchButton}
|
||||
{viewToggleNode}
|
||||
{viewOptionsNode}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2'>
|
||||
{props.customSearch !== undefined ? props.customSearch : searchInput}
|
||||
{props.additionalSearch}
|
||||
{filterChips}
|
||||
{expanded && hasExpandable && props.expandable}
|
||||
{expandToggle}
|
||||
</div>
|
||||
</DataTableMobileFilterPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (hasLeftActions) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', props.className)}>
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ChevronsUpDown, GripHorizontal, X } from 'lucide-react'
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type FloatingWindowPosition = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type FloatingWindowSize = {
|
||||
width: number
|
||||
/** Unset until the user resizes vertically; content decides until then. */
|
||||
height?: number
|
||||
}
|
||||
|
||||
type FloatingWindowProps = {
|
||||
title: ReactNode
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
/** Viewport coordinates used until the user moves the window. */
|
||||
defaultPosition: FloatingWindowPosition
|
||||
defaultWidth: number
|
||||
defaultHeight?: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
/** Small element rendered beside the title, e.g. a count badge. */
|
||||
badge?: ReactNode
|
||||
footer?: ReactNode
|
||||
/**
|
||||
* Persists position, size and collapsed state in `localStorage` under this
|
||||
* key so the window reopens where and how the user left it.
|
||||
*/
|
||||
storageKey?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
type FloatingWindowState = {
|
||||
position: FloatingWindowPosition
|
||||
size: FloatingWindowSize
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||
|
||||
const RESIZE_DIRECTIONS: ResizeDirection[] = [
|
||||
'n',
|
||||
's',
|
||||
'e',
|
||||
'w',
|
||||
'ne',
|
||||
'nw',
|
||||
'se',
|
||||
'sw',
|
||||
]
|
||||
const RESIZE_HANDLE_CLASS: Record<ResizeDirection, string> = {
|
||||
n: 'top-0 left-2 right-2 h-1.5 cursor-ns-resize',
|
||||
s: 'bottom-0 left-2 right-2 h-1.5 cursor-ns-resize',
|
||||
e: 'top-2 bottom-2 right-0 w-1.5 cursor-ew-resize',
|
||||
w: 'top-2 bottom-2 left-0 w-1.5 cursor-ew-resize',
|
||||
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
|
||||
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
|
||||
se: 'bottom-0 right-0 size-3 cursor-nwse-resize',
|
||||
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 8
|
||||
/** Keeps at least the header reachable when the window is dragged downward. */
|
||||
const HEADER_HEIGHT = 48
|
||||
const DEFAULT_MIN_WIDTH = 280
|
||||
const DEFAULT_MIN_HEIGHT = 200
|
||||
|
||||
function storageKeyFor(key: string) {
|
||||
return `floating-window:${key}`
|
||||
}
|
||||
|
||||
function readStoredState(key?: string): FloatingWindowState | null {
|
||||
if (!key) return null
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKeyFor(key))
|
||||
if (!raw) return null
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
const stored = parsed as Record<string, unknown>
|
||||
if (typeof stored.x !== 'number' || typeof stored.y !== 'number') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
position: { x: stored.x, y: stored.y },
|
||||
size: {
|
||||
width: typeof stored.width === 'number' ? stored.width : Number.NaN,
|
||||
height: typeof stored.height === 'number' ? stored.height : undefined,
|
||||
},
|
||||
collapsed: stored.collapsed === true,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clampPosition(
|
||||
position: FloatingWindowPosition,
|
||||
width: number
|
||||
): FloatingWindowPosition {
|
||||
const maxX = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
window.innerWidth - width - VIEWPORT_MARGIN
|
||||
)
|
||||
const maxY = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
window.innerHeight - HEADER_HEIGHT - VIEWPORT_MARGIN
|
||||
)
|
||||
return {
|
||||
x: Math.min(Math.max(VIEWPORT_MARGIN, position.x), maxX),
|
||||
y: Math.min(Math.max(VIEWPORT_MARGIN, position.y), maxY),
|
||||
}
|
||||
}
|
||||
|
||||
function clampSize(
|
||||
size: FloatingWindowSize,
|
||||
limits: { minWidth: number; minHeight: number }
|
||||
): FloatingWindowSize {
|
||||
const maxWidth = Math.max(
|
||||
limits.minWidth,
|
||||
window.innerWidth - VIEWPORT_MARGIN * 2
|
||||
)
|
||||
const maxHeight = Math.max(
|
||||
limits.minHeight,
|
||||
window.innerHeight - VIEWPORT_MARGIN * 2
|
||||
)
|
||||
return {
|
||||
width: Math.min(Math.max(limits.minWidth, size.width), maxWidth),
|
||||
height:
|
||||
size.height === undefined
|
||||
? undefined
|
||||
: Math.min(Math.max(limits.minHeight, size.height), maxHeight),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-modal window that floats above the page: dragged by its header, resized
|
||||
* from any edge or corner, collapsible to a pill. It renders where it is
|
||||
* mounted (no portal) with `position: fixed`, so a parent modal keeps
|
||||
* treating it as its own content.
|
||||
*/
|
||||
export function FloatingWindow(props: FloatingWindowProps) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const limits = useMemo(
|
||||
() => ({
|
||||
minWidth: props.minWidth ?? DEFAULT_MIN_WIDTH,
|
||||
minHeight: props.minHeight ?? DEFAULT_MIN_HEIGHT,
|
||||
}),
|
||||
[props.minWidth, props.minHeight]
|
||||
)
|
||||
const [state, setState] = useState<FloatingWindowState>(() => {
|
||||
const stored = readStoredState(props.storageKey)
|
||||
const size = clampSize(
|
||||
{
|
||||
width:
|
||||
stored && !Number.isNaN(stored.size.width)
|
||||
? stored.size.width
|
||||
: props.defaultWidth,
|
||||
height: stored ? stored.size.height : props.defaultHeight,
|
||||
},
|
||||
limits
|
||||
)
|
||||
return {
|
||||
position: clampPosition(
|
||||
stored?.position ?? props.defaultPosition,
|
||||
size.width
|
||||
),
|
||||
size,
|
||||
collapsed: stored?.collapsed ?? false,
|
||||
}
|
||||
})
|
||||
const windowRef = useRef<HTMLElement>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
} | null>(null)
|
||||
const resizeRef = useRef<{
|
||||
pointerId: number
|
||||
direction: ResizeDirection
|
||||
startX: number
|
||||
startY: number
|
||||
position: FloatingWindowPosition
|
||||
size: { width: number; height: number }
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.storageKey) return
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
storageKeyFor(props.storageKey),
|
||||
JSON.stringify({
|
||||
x: state.position.x,
|
||||
y: state.position.y,
|
||||
width: state.size.width,
|
||||
height: state.size.height,
|
||||
collapsed: state.collapsed,
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
// Storage may be unavailable; the window still works for this session.
|
||||
}
|
||||
}, [props.storageKey, state])
|
||||
|
||||
useEffect(() => {
|
||||
const keepInViewport = () =>
|
||||
setState((previous) => {
|
||||
const size = clampSize(previous.size, limits)
|
||||
return {
|
||||
...previous,
|
||||
size,
|
||||
position: clampPosition(previous.position, size.width),
|
||||
}
|
||||
})
|
||||
window.addEventListener('resize', keepInViewport)
|
||||
return () => window.removeEventListener('resize', keepInViewport)
|
||||
}, [limits])
|
||||
|
||||
const capturePointer = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
capture: boolean
|
||||
) => {
|
||||
const method = capture ? 'setPointerCapture' : 'releasePointerCapture'
|
||||
if (typeof event.currentTarget[method] === 'function') {
|
||||
event.currentTarget[method](event.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0) return
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('button, a, input, select, textarea')
|
||||
) {
|
||||
return
|
||||
}
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
offsetX: event.clientX - state.position.x,
|
||||
offsetY: event.clientY - state.position.y,
|
||||
}
|
||||
capturePointer(event, true)
|
||||
}
|
||||
|
||||
const handleDragMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) return
|
||||
const next = clampPosition(
|
||||
{ x: event.clientX - drag.offsetX, y: event.clientY - drag.offsetY },
|
||||
state.size.width
|
||||
)
|
||||
setState((previous) => ({ ...previous, position: next }))
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
dragRef.current = null
|
||||
capturePointer(event, false)
|
||||
}
|
||||
|
||||
const handleResizeStart = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
direction: ResizeDirection
|
||||
) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
// Until the user has set a height the window is content-sized, so the
|
||||
// rendered height becomes the starting point of a vertical resize.
|
||||
const renderedHeight = windowRef.current?.getBoundingClientRect().height
|
||||
resizeRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
direction,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
position: state.position,
|
||||
size: {
|
||||
width: state.size.width,
|
||||
height:
|
||||
state.size.height ?? Math.max(renderedHeight ?? 0, limits.minHeight),
|
||||
},
|
||||
}
|
||||
capturePointer(event, true)
|
||||
}
|
||||
|
||||
const handleResizeMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const resize = resizeRef.current
|
||||
if (!resize || resize.pointerId !== event.pointerId) return
|
||||
const dx = event.clientX - resize.startX
|
||||
const dy = event.clientY - resize.startY
|
||||
let { width, height } = resize.size
|
||||
let { x, y } = resize.position
|
||||
if (resize.direction.includes('e')) width = resize.size.width + dx
|
||||
if (resize.direction.includes('s')) height = resize.size.height + dy
|
||||
if (resize.direction.includes('w')) width = resize.size.width - dx
|
||||
if (resize.direction.includes('n')) height = resize.size.height - dy
|
||||
const size = clampSize({ width, height }, limits)
|
||||
if (resize.direction.includes('w')) {
|
||||
x = resize.position.x + (resize.size.width - size.width)
|
||||
}
|
||||
if (resize.direction.includes('n')) {
|
||||
y = resize.position.y + (resize.size.height - (size.height ?? height))
|
||||
}
|
||||
const position = clampPosition({ x, y }, size.width)
|
||||
setState((previous) => ({ ...previous, size, position }))
|
||||
}
|
||||
|
||||
const handleResizeEnd = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (resizeRef.current?.pointerId !== event.pointerId) return
|
||||
resizeRef.current = null
|
||||
capturePointer(event, false)
|
||||
}
|
||||
|
||||
const dragHandleProps = {
|
||||
title: t('Drag to move'),
|
||||
onPointerDown: handleDragStart,
|
||||
onPointerMove: handleDragMove,
|
||||
onPointerUp: handleDragEnd,
|
||||
onPointerCancel: handleDragEnd,
|
||||
}
|
||||
const style = {
|
||||
position: 'fixed' as const,
|
||||
left: state.position.x,
|
||||
top: state.position.y,
|
||||
}
|
||||
|
||||
if (state.collapsed) {
|
||||
return (
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
style={style}
|
||||
className={cn(
|
||||
'bg-background border-border z-50 flex cursor-move touch-none items-center gap-2 rounded-full border py-1 pr-1 pl-3 shadow-lg select-none',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<GripHorizontal
|
||||
className='text-muted-foreground size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-sm font-semibold'>{props.title}</span>
|
||||
{props.badge}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
aria-label={t('Expand panel')}
|
||||
onClick={() =>
|
||||
setState((previous) => ({ ...previous, collapsed: false }))
|
||||
}
|
||||
>
|
||||
<ChevronsUpDown aria-hidden='true' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
aria-label={t('Close panel')}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X aria-hidden='true' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={windowRef}
|
||||
role='complementary'
|
||||
aria-labelledby={`${id}-title`}
|
||||
style={{
|
||||
...style,
|
||||
width: state.size.width,
|
||||
height: state.size.height,
|
||||
maxHeight: `calc(100vh - ${VIEWPORT_MARGIN * 2}px)`,
|
||||
}}
|
||||
className={cn(
|
||||
'bg-background border-border z-50 flex flex-col overflow-hidden rounded-xl border shadow-xl',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<header
|
||||
{...dragHandleProps}
|
||||
className='border-border/70 flex cursor-move touch-none items-center gap-2 border-b px-4 py-3 select-none'
|
||||
>
|
||||
<GripHorizontal
|
||||
className='text-muted-foreground size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<h3 id={`${id}-title`} className='text-sm font-semibold'>
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.badge}
|
||||
<div className='ml-auto flex items-center gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
aria-label={t('Collapse panel')}
|
||||
onClick={() =>
|
||||
setState((previous) => ({ ...previous, collapsed: true }))
|
||||
}
|
||||
>
|
||||
<ChevronsUpDown aria-hidden='true' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
aria-label={t('Close panel')}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X aria-hidden='true' />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-4 py-3'>
|
||||
{props.children}
|
||||
</div>
|
||||
{props.footer && (
|
||||
<footer className='border-border/70 border-t px-4 py-3'>
|
||||
{props.footer}
|
||||
</footer>
|
||||
)}
|
||||
{RESIZE_DIRECTIONS.map((direction) => (
|
||||
<div
|
||||
key={direction}
|
||||
data-slot='floating-window-resize'
|
||||
data-direction={direction}
|
||||
title={t('Drag to resize')}
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'absolute z-10 touch-none',
|
||||
RESIZE_HANDLE_CLASS[direction]
|
||||
)}
|
||||
onPointerDown={(event) => handleResizeStart(event, direction)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
onPointerCancel={handleResizeEnd}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
|
||||
import { FloatingWindow } from '@/components/floating-window'
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
function renderWindow(props?: { storageKey?: string; onClose?: () => void }) {
|
||||
return render(
|
||||
<FloatingWindow
|
||||
title='Scratch pad'
|
||||
defaultPosition={{ x: 100, y: 20 }}
|
||||
defaultWidth={300}
|
||||
storageKey={props?.storageKey}
|
||||
onClose={props?.onClose ?? vi.fn()}
|
||||
footer={<span>footer</span>}
|
||||
>
|
||||
<p>body</p>
|
||||
</FloatingWindow>
|
||||
)
|
||||
}
|
||||
|
||||
test('renders fixed at the default position and follows a header drag, remembering where it was dropped', () => {
|
||||
renderWindow({ storageKey: 'scratch' })
|
||||
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
expect(window_).toHaveStyle({
|
||||
position: 'fixed',
|
||||
left: '100px',
|
||||
top: '20px',
|
||||
width: '300px',
|
||||
})
|
||||
expect(screen.getByText('body')).toBeVisible()
|
||||
expect(screen.getByText('footer')).toBeVisible()
|
||||
|
||||
const handle = screen.getByTitle('Drag to move')
|
||||
fireEvent.pointerDown(handle, {
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX: 120,
|
||||
clientY: 40,
|
||||
})
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 220, clientY: 90 })
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 220, clientY: 90 })
|
||||
expect(window_).toHaveStyle({ left: '200px', top: '70px' })
|
||||
expect(
|
||||
JSON.parse(window.localStorage.getItem('floating-window:scratch') ?? '{}')
|
||||
).toEqual({ x: 200, y: 70, width: 300, collapsed: false })
|
||||
})
|
||||
|
||||
function resizeHandle(direction: string) {
|
||||
const handle = document.querySelector<HTMLElement>(
|
||||
`[data-slot="floating-window-resize"][data-direction="${direction}"]`
|
||||
)
|
||||
if (!handle) throw new Error(`missing ${direction} resize handle`)
|
||||
return handle
|
||||
}
|
||||
|
||||
test('dragging the bottom-right corner grows both dimensions from the rendered size and persists them', () => {
|
||||
renderWindow({ storageKey: 'scratch' })
|
||||
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
const handle = resizeHandle('se')
|
||||
fireEvent.pointerDown(handle, {
|
||||
button: 0,
|
||||
pointerId: 3,
|
||||
clientX: 400,
|
||||
clientY: 300,
|
||||
})
|
||||
fireEvent.pointerMove(handle, { pointerId: 3, clientX: 450, clientY: 380 })
|
||||
fireEvent.pointerUp(handle, { pointerId: 3, clientX: 450, clientY: 380 })
|
||||
// jsdom renders no height, so the vertical start is the minimum height.
|
||||
expect(window_).toHaveStyle({ width: '350px', height: '280px' })
|
||||
expect(
|
||||
JSON.parse(window.localStorage.getItem('floating-window:scratch') ?? '{}')
|
||||
).toEqual({ x: 100, y: 20, width: 350, height: 280, collapsed: false })
|
||||
})
|
||||
|
||||
test('dragging the left edge keeps the right edge in place and never shrinks below the minimum width', () => {
|
||||
renderWindow()
|
||||
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
const handle = resizeHandle('w')
|
||||
fireEvent.pointerDown(handle, {
|
||||
button: 0,
|
||||
pointerId: 4,
|
||||
clientX: 100,
|
||||
clientY: 200,
|
||||
})
|
||||
fireEvent.pointerMove(handle, { pointerId: 4, clientX: 60, clientY: 200 })
|
||||
expect(window_).toHaveStyle({ left: '60px', width: '340px' })
|
||||
|
||||
fireEvent.pointerMove(handle, { pointerId: 4, clientX: 300, clientY: 200 })
|
||||
fireEvent.pointerUp(handle, { pointerId: 4, clientX: 300, clientY: 200 })
|
||||
expect(window_).toHaveStyle({ left: '120px', width: '280px' })
|
||||
})
|
||||
|
||||
test('a stored size is restored and clamped to the viewport', () => {
|
||||
window.localStorage.setItem(
|
||||
'floating-window:scratch',
|
||||
JSON.stringify({ x: 10, y: 10, width: 5000, height: 120 })
|
||||
)
|
||||
renderWindow({ storageKey: 'scratch' })
|
||||
expect(
|
||||
screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
).toHaveStyle({ width: `${window.innerWidth - 16}px`, height: '200px' })
|
||||
})
|
||||
|
||||
test('a stored position outside the viewport is clamped back inside on mount', () => {
|
||||
window.localStorage.setItem(
|
||||
'floating-window:scratch',
|
||||
JSON.stringify({ x: 5000, y: -50 })
|
||||
)
|
||||
renderWindow({ storageKey: 'scratch' })
|
||||
expect(
|
||||
screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
).toHaveStyle({ left: `${window.innerWidth - 300 - 8}px`, top: '8px' })
|
||||
})
|
||||
|
||||
test('collapses to a pill that keeps the title and reopens, and the close control reports to the caller', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
renderWindow({ onClose })
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse panel' }))
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: 'Scratch pad' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Scratch pad')).toBeVisible()
|
||||
expect(screen.queryByText('body')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand panel' }))
|
||||
expect(
|
||||
screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'Close panel' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('pressing a header button does not start a drag', () => {
|
||||
const onClose = vi.fn()
|
||||
renderWindow({ onClose })
|
||||
const close = screen.getByRole('button', { name: 'Close panel' })
|
||||
fireEvent.pointerDown(close, {
|
||||
button: 0,
|
||||
pointerId: 2,
|
||||
clientX: 380,
|
||||
clientY: 30,
|
||||
})
|
||||
fireEvent.pointerMove(screen.getByTitle('Drag to move'), {
|
||||
pointerId: 2,
|
||||
clientX: 100,
|
||||
clientY: 300,
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('complementary', { name: 'Scratch pad' })
|
||||
).toHaveStyle({ left: '100px', top: '20px' })
|
||||
})
|
||||
@@ -21,11 +21,12 @@ import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationPopover } from '@/components/notification-popover'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { Search } from '@/components/search'
|
||||
import { SystemUpdateAction } from '@/features/system-update/system-update-action'
|
||||
import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
import { type TopNavLink } from '../types'
|
||||
import type { TopNavLink } from '../types'
|
||||
import { Header } from './header'
|
||||
import { SystemBrand } from './system-brand'
|
||||
import { TopNav } from './top-nav'
|
||||
@@ -111,40 +112,43 @@ export function AppHeader({
|
||||
const notifications = useNotifications()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Header>
|
||||
<div className='@container/system-brand flex min-w-0 flex-1 items-center gap-1'>
|
||||
<SystemBrand variant='inline' />
|
||||
<SystemUpdateAction presentation='version' />
|
||||
</div>
|
||||
|
||||
{leftContent ? (
|
||||
<div className='ms-2 flex items-center'>{leftContent}</div>
|
||||
) : null}
|
||||
{leftContent ? (
|
||||
<div className='ms-2 flex items-center'>{leftContent}</div>
|
||||
) : null}
|
||||
|
||||
{rightContent ?? (
|
||||
<div className='ms-auto flex items-center gap-1 sm:gap-2'>
|
||||
{showTopNav && (
|
||||
<div className='me-1 hidden lg:block'>
|
||||
<TopNav links={links} />
|
||||
</div>
|
||||
)}
|
||||
{showSearch && <Search />}
|
||||
{showNotifications && (
|
||||
<NotificationPopover
|
||||
open={notifications.popoverOpen}
|
||||
onOpenChange={notifications.setPopoverOpen}
|
||||
unreadCount={notifications.unreadCount}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
/>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
{showConfigDrawer && <ConfigDrawer />}
|
||||
{showProfileDropdown && <ProfileDropdown />}
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
</>
|
||||
{rightContent ?? (
|
||||
<div className='ms-auto flex shrink-0 items-center gap-1 sm:gap-2'>
|
||||
{showTopNav && (
|
||||
<div className='me-1 hidden lg:block'>
|
||||
<TopNav links={links} />
|
||||
</div>
|
||||
)}
|
||||
{showSearch && (
|
||||
<Search className='w-8 flex-none [&>span]:hidden sm:[&>span]:inline' />
|
||||
)}
|
||||
{showNotifications && (
|
||||
<NotificationPopover
|
||||
open={notifications.popoverOpen}
|
||||
onOpenChange={notifications.setPopoverOpen}
|
||||
unreadCount={notifications.unreadCount}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
/>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
{showConfigDrawer && <ConfigDrawer />}
|
||||
{showProfileDropdown && <ProfileDropdown />}
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
resolveChatUrl,
|
||||
type ChatPreset,
|
||||
} from '@/features/chat/lib/chat-links'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
import { normalizeHref } from '../lib/url-utils'
|
||||
import type { NavChatPresets } from '../types'
|
||||
@@ -194,7 +195,7 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
|
||||
: t(
|
||||
'Unable to prepare chat link. Please ensure you have an enabled API key.'
|
||||
)
|
||||
toast.error(message)
|
||||
handleServerError(error, message)
|
||||
return
|
||||
} finally {
|
||||
loadingPresetIdRef.current = null
|
||||
|
||||
@@ -149,6 +149,22 @@ function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ServiceSupportAttribution() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<span className='text-muted-foreground/60 text-xs'>
|
||||
<a
|
||||
href='https://www.crash.work/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
|
||||
>
|
||||
{t('Service support provided by Crash Workshop Cloud Computing')}
|
||||
</a>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer(props: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -238,6 +254,7 @@ export function Footer(props: FooterProps) {
|
||||
/>
|
||||
<div className='border-border/60 text-muted-foreground/45 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
|
||||
<LegalLinks />
|
||||
<ServiceSupportAttribution />
|
||||
<ProjectAttribution currentYear={currentYear} inline />
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,14 +289,14 @@ export function Footer(props: FooterProps) {
|
||||
{/* Links columns */}
|
||||
{isDemoSiteMode && (
|
||||
<div className='grid grid-cols-3 gap-8 md:gap-16'>
|
||||
{displayColumns.map((column, index) => (
|
||||
<div key={index}>
|
||||
{displayColumns.map((column) => (
|
||||
<div key={column.title}>
|
||||
<p className='text-muted-foreground/50 mb-3 text-xs font-medium tracking-wider uppercase'>
|
||||
{t(column.title)}
|
||||
</p>
|
||||
<ul className='space-y-2.5'>
|
||||
{column.links.map((link, linkIndex) => (
|
||||
<li key={linkIndex}>
|
||||
{column.links.map((link) => (
|
||||
<li key={`${link.text}:${link.href}`}>
|
||||
<FooterLinkItem link={link} />
|
||||
</li>
|
||||
))}
|
||||
@@ -301,6 +318,7 @@ export function Footer(props: FooterProps) {
|
||||
<LegalLinks leadingSeparator />
|
||||
</div>
|
||||
<ProjectAttribution currentYear={currentYear} />
|
||||
<ServiceSupportAttribution />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
@@ -27,6 +27,7 @@ import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { SystemUpdateAction } from '@/features/system-update/system-update-action'
|
||||
import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
@@ -98,6 +99,29 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
const displaySiteName = customSiteName || systemName
|
||||
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
|
||||
|
||||
let logoContent: ReactNode = (
|
||||
<HeaderLogo
|
||||
src={systemLogo}
|
||||
loading={loading}
|
||||
logoLoaded={logoLoaded}
|
||||
className='size-full rounded-lg object-contain'
|
||||
/>
|
||||
)
|
||||
if (customLogo) logoContent = customLogo
|
||||
if (loading) logoContent = <Skeleton className='size-full rounded-lg' />
|
||||
|
||||
let authContent = (
|
||||
<Button
|
||||
size='sm'
|
||||
className='h-8 rounded-lg px-3.5 text-xs font-medium'
|
||||
render={<Link to='/sign-in' />}
|
||||
>
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
)
|
||||
if (isAuthenticated) authContent = <ProfileDropdown />
|
||||
if (loading) authContent = <Skeleton className='h-8 w-20 rounded-lg' />
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20)
|
||||
onScroll()
|
||||
@@ -184,52 +208,52 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
>
|
||||
<nav
|
||||
className={cn(
|
||||
'flex items-center justify-between transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
'flex items-center justify-between gap-2 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
scrolled
|
||||
? 'bg-background/60 ring-border/50 h-12 rounded-2xl pr-1.5 pl-4 shadow-[0_2px_16px_-6px_rgba(0,0,0,0.08),0_0_0_0.5px_rgba(0,0,0,0.02)] ring-[0.5px] backdrop-blur-2xl dark:shadow-[0_2px_16px_-6px_rgba(0,0,0,0.4)]'
|
||||
: 'h-16 px-2'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to={homeUrl}
|
||||
className='group flex shrink-0 items-center gap-2.5'
|
||||
>
|
||||
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
|
||||
{loading ? (
|
||||
<Skeleton className='size-full rounded-lg' />
|
||||
) : customLogo ? (
|
||||
customLogo
|
||||
) : (
|
||||
<HeaderLogo
|
||||
src={systemLogo}
|
||||
loading={loading}
|
||||
logoLoaded={logoLoaded}
|
||||
className='size-full rounded-lg object-contain'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className='text-sm font-semibold tracking-tight'>
|
||||
{loading ? <Skeleton className='h-4 w-16' /> : displaySiteName}
|
||||
</span>
|
||||
</Link>
|
||||
<div className='@container/system-brand flex min-w-0 flex-1 items-center gap-1 lg:min-w-36'>
|
||||
<Link
|
||||
to={homeUrl}
|
||||
className='group flex min-w-0 items-center gap-2.5'
|
||||
>
|
||||
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
|
||||
{logoContent}
|
||||
</div>
|
||||
<span
|
||||
className='max-w-48 truncate text-sm font-semibold tracking-tight'
|
||||
title={displaySiteName}
|
||||
>
|
||||
{loading ? (
|
||||
<Skeleton className='h-4 w-16' />
|
||||
) : (
|
||||
displaySiteName
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<SystemUpdateAction presentation='version' />
|
||||
</div>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className='hidden items-center gap-0.5 sm:flex'>
|
||||
{links.map((link, i) => {
|
||||
<div className='hidden min-w-0 items-center gap-0.5 lg:flex'>
|
||||
{links.map((link) => {
|
||||
const isActive = pathname === link.href
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
key={`${link.title}:${link.href}`}
|
||||
href={link.href}
|
||||
title={t(link.title)}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-disabled={link.disabled}
|
||||
tabIndex={link.disabled ? -1 : undefined}
|
||||
onClick={(event) => handleNavLinkClick(event, link)}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
|
||||
'text-muted-foreground hover:text-foreground min-w-0 truncate rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
|
||||
link.disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
@@ -239,12 +263,13 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
key={`${link.title}:${link.href}`}
|
||||
to={link.href}
|
||||
title={t(link.title)}
|
||||
disabled={link.disabled}
|
||||
onClick={(event) => handleNavLinkClick(event, link)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
|
||||
'min-w-0 truncate rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
@@ -280,25 +305,13 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
{showAuthButtons && (
|
||||
<>
|
||||
<div className='bg-border/40 mx-1 h-4 w-px' />
|
||||
{loading ? (
|
||||
<Skeleton className='h-8 w-20 rounded-lg' />
|
||||
) : isAuthenticated ? (
|
||||
<ProfileDropdown />
|
||||
) : (
|
||||
<Button
|
||||
size='sm'
|
||||
className='h-8 rounded-lg px-3.5 text-xs font-medium'
|
||||
render={<Link to='/sign-in' />}
|
||||
>
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
)}
|
||||
{authContent}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: compact actions + hamburger */}
|
||||
<div className='flex items-center gap-2 sm:hidden'>
|
||||
<div className='flex shrink-0 items-center gap-2 lg:hidden'>
|
||||
{showThemeSwitch && <ThemeSwitch />}
|
||||
{showAuthButtons && !loading && isAuthenticated && (
|
||||
<ProfileDropdown />
|
||||
@@ -340,7 +353,7 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
{/* Mobile full-screen overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] sm:pointer-events-none sm:hidden',
|
||||
'bg-background/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] lg:pointer-events-none lg:hidden',
|
||||
mobileOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0'
|
||||
@@ -364,7 +377,7 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
key={`${link.title}:${link.href}`}
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
@@ -380,7 +393,7 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
key={`${link.title}:${link.href}`}
|
||||
to={link.href}
|
||||
disabled={link.disabled}
|
||||
onClick={(event) => handleNavLinkClick(event, link, true)}
|
||||
|
||||
@@ -61,11 +61,11 @@ export function SystemBrand(props: SystemBrandProps) {
|
||||
to='/'
|
||||
aria-label={t('Go to home')}
|
||||
className={cn(
|
||||
'text-foreground inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium transition-colors outline-none select-none',
|
||||
'text-foreground inline-flex h-7 min-w-0 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium transition-colors outline-none select-none',
|
||||
'hover:bg-accent focus-visible:ring-ring/40 focus-visible:ring-2'
|
||||
)}
|
||||
>
|
||||
<div className='flex size-5 items-center justify-center overflow-hidden rounded-md'>
|
||||
<div className='flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-md'>
|
||||
<img
|
||||
src={logo}
|
||||
alt={t('Logo')}
|
||||
|
||||
@@ -16,9 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type TFunction } from 'i18next'
|
||||
import type { TFunction } from 'i18next'
|
||||
import {
|
||||
Box,
|
||||
Route,
|
||||
CreditCard,
|
||||
Layout,
|
||||
Settings,
|
||||
@@ -32,6 +33,7 @@ import { getBillingSectionNavItems } from '@/features/system-settings/billing/se
|
||||
import { getContentSectionNavItems } from '@/features/system-settings/content/section-registry.tsx'
|
||||
import { getModelsSectionNavItems } from '@/features/system-settings/models/section-registry.tsx'
|
||||
import { getOperationsSectionNavItems } from '@/features/system-settings/operations/section-registry.tsx'
|
||||
import { getPolicySectionNavItems } from '@/features/system-settings/request-policies/section-registry'
|
||||
import { getSecuritySectionNavItems } from '@/features/system-settings/security/section-registry.tsx'
|
||||
import { getSiteSectionNavItems } from '@/features/system-settings/site/section-registry.tsx'
|
||||
|
||||
@@ -66,10 +68,15 @@ function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
|
||||
items: getBillingSectionNavItems(t),
|
||||
},
|
||||
{
|
||||
title: t('Models & Routing'),
|
||||
title: t('Models'),
|
||||
icon: Box,
|
||||
items: getModelsSectionNavItems(t),
|
||||
},
|
||||
{
|
||||
title: t('Request policies'),
|
||||
icon: Route,
|
||||
items: getPolicySectionNavItems(t),
|
||||
},
|
||||
{
|
||||
title: t('Security & Limits'),
|
||||
icon: ShieldAlert,
|
||||
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ArrowRightLeft } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -41,6 +42,11 @@ import { cn } from '@/lib/utils'
|
||||
export type Option = {
|
||||
label: string
|
||||
value: string
|
||||
/**
|
||||
* Secondary text shown beside the option in the dropdown. Chips for hinted
|
||||
* values also carry a marker icon whose tooltip repeats the hint.
|
||||
*/
|
||||
hint?: string
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
@@ -139,6 +145,14 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
return map
|
||||
}, [props.options])
|
||||
|
||||
const hintMap = React.useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const option of props.options) {
|
||||
if (option.hint) map.set(option.value, option.hint)
|
||||
}
|
||||
return map
|
||||
}, [props.options])
|
||||
|
||||
const trimmedInput = inputValue.trim()
|
||||
const inputMatchesExisting =
|
||||
trimmedInput.length > 0 &&
|
||||
@@ -164,7 +178,7 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
if (canCreate) {
|
||||
set.add(trimmedInput)
|
||||
}
|
||||
return Array.from(set)
|
||||
return [...set]
|
||||
}, [props.options, props.selected, canCreate, trimmedInput])
|
||||
|
||||
const addValues = React.useCallback(
|
||||
@@ -198,6 +212,25 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
setInputValue(value)
|
||||
}
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>): void => {
|
||||
if (!props.allowCreate || props.disabled) return
|
||||
|
||||
const pasted = event.clipboardData.getData('text/plain')
|
||||
if (!COMMA_REGEX.test(pasted)) return
|
||||
|
||||
event.preventDefault()
|
||||
const input = event.currentTarget
|
||||
const value =
|
||||
input.value.slice(0, input.selectionStart ?? input.value.length) +
|
||||
pasted +
|
||||
input.value.slice(input.selectionEnd ?? input.value.length)
|
||||
|
||||
// A pasted batch is complete, including its final value. Read the clipboard
|
||||
// before the single-line input can strip newline separators.
|
||||
addValues(value.split(COMMA_REGEX))
|
||||
setInputValue('')
|
||||
}
|
||||
|
||||
const handleValueChange = (next: string[]) => {
|
||||
props.onChange(next)
|
||||
// When an item is picked (multiple mode), Base UI keeps the input but most
|
||||
@@ -282,6 +315,7 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
<>
|
||||
{visibleValues.map((value) => {
|
||||
const label = labelMap.get(value) ?? value
|
||||
const hint = hintMap.get(value)
|
||||
return (
|
||||
<ComboboxChip key={value}>
|
||||
{props.copyChipOnClick ? (
|
||||
@@ -299,6 +333,15 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
) : (
|
||||
<span className='max-w-[16rem] truncate'>{label}</span>
|
||||
)}
|
||||
{hint && (
|
||||
<span
|
||||
title={hint}
|
||||
aria-hidden='true'
|
||||
className='text-muted-foreground inline-flex shrink-0'
|
||||
>
|
||||
<ArrowRightLeft className='size-3' />
|
||||
</span>
|
||||
)}
|
||||
</ComboboxChip>
|
||||
)
|
||||
})}
|
||||
@@ -346,6 +389,7 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
@@ -356,11 +400,13 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
{(item: string) => {
|
||||
const isCreate = canCreate && item === trimmedInput
|
||||
const label = labelMap.get(item) ?? item
|
||||
const hint = hintMap.get(item)
|
||||
return (
|
||||
<ComboboxItem
|
||||
key={item}
|
||||
value={item}
|
||||
className={isCreate ? 'text-foreground' : undefined}
|
||||
aria-description={hint}
|
||||
>
|
||||
{isCreate ? (
|
||||
<>
|
||||
@@ -377,7 +423,17 @@ export function MultiSelect(props: MultiSelectProps) {
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className='truncate'>{label}</span>
|
||||
<>
|
||||
<span className='truncate'>{label}</span>
|
||||
{hint && (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='text-muted-foreground ml-auto max-w-40 shrink-0 truncate text-xs'
|
||||
>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ComboboxItem>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
|
||||
test('hinted options show the hint in the dropdown and mark their chips without changing the chip name', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<MultiSelect
|
||||
options={[
|
||||
{ value: 'alias', label: 'alias', hint: 'Redirects to upstream' },
|
||||
{ value: 'plain', label: 'plain' },
|
||||
]}
|
||||
selected={['alias', 'plain']}
|
||||
onChange={() => undefined}
|
||||
copyChipOnClick
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'alias' })).toBeVisible()
|
||||
expect(screen.getByTitle('Redirects to upstream')).toHaveAttribute(
|
||||
'aria-hidden',
|
||||
'true'
|
||||
)
|
||||
expect(screen.getAllByTitle('Redirects to upstream')).toHaveLength(1)
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
expect(screen.getByRole('option', { name: /^alias/ })).toHaveTextContent(
|
||||
'Redirects to upstream'
|
||||
)
|
||||
expect(screen.getByRole('option', { name: 'plain' })).not.toHaveTextContent(
|
||||
'Redirects to upstream'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
|
||||
const options = [
|
||||
{ value: 'a', label: 'a' },
|
||||
{ value: 'c', label: 'c' },
|
||||
]
|
||||
|
||||
function Fixture(props: {
|
||||
initialSelected?: string[]
|
||||
allowCreate?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [selected, setSelected] = useState(props.initialSelected ?? [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<MultiSelect
|
||||
options={options}
|
||||
selected={selected}
|
||||
onChange={setSelected}
|
||||
allowCreate={props.allowCreate ?? true}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
<output aria-label='Selected values'>{selected.join(',')}</output>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('multi-select batch paste', () => {
|
||||
it.each(['a,b,c', 'a,b,c', 'a\nb\nc', 'a\r\nb\r\nc'])(
|
||||
'adds every pasted value including the final item without a trailing separator: %j',
|
||||
async (text) => {
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture />)
|
||||
const input = screen.getByRole('combobox')
|
||||
|
||||
await user.click(input)
|
||||
await user.paste(text)
|
||||
|
||||
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
|
||||
/^a,b,c$/
|
||||
)
|
||||
expect(input).toHaveValue('')
|
||||
expect(input).toHaveFocus()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves existing selections and ignores duplicate, blank, and trailing entries', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture initialSelected={['existing', 'a']} />)
|
||||
const input = screen.getByRole('combobox')
|
||||
|
||||
await user.click(input)
|
||||
await user.paste(' a,, b,b\r\nc, ')
|
||||
|
||||
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
|
||||
/^existing,a,b,c$/
|
||||
)
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('replaces the selected draft text when pasting a batch and preserves surrounding text', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture />)
|
||||
const input = screen.getByRole<HTMLInputElement>('combobox')
|
||||
|
||||
await user.type(input, 'model-old-end')
|
||||
input.setSelectionRange(6, 9)
|
||||
await user.paste('a,b')
|
||||
|
||||
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
|
||||
/^model-a,b-end$/
|
||||
)
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('keeps the last manually typed value as a draft until a separator is typed', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture />)
|
||||
const input = screen.getByRole('combobox')
|
||||
|
||||
await user.type(input, 'a,b,c')
|
||||
|
||||
expect(screen.getByLabelText('Selected values')).toHaveTextContent(/^a,b$/)
|
||||
expect(input).toHaveValue('c')
|
||||
|
||||
await user.type(input, ',')
|
||||
|
||||
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
|
||||
/^a,b,c$/
|
||||
)
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('keeps a single pasted value available for search and explicit selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture />)
|
||||
const input = screen.getByRole('combobox')
|
||||
|
||||
await user.click(input)
|
||||
await user.paste('c')
|
||||
|
||||
expect(screen.getByLabelText('Selected values')).toBeEmptyDOMElement()
|
||||
expect(input).toHaveValue('c')
|
||||
|
||||
await user.click(screen.getByRole('option', { name: 'c' }))
|
||||
|
||||
expect(screen.getByLabelText('Selected values')).toHaveTextContent(/^c$/)
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('leaves pasted text as search input when custom creation is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture allowCreate={false} />)
|
||||
const input = screen.getByRole('combobox')
|
||||
|
||||
await user.click(input)
|
||||
await user.paste('a,b,c')
|
||||
|
||||
expect(screen.getByLabelText('Selected values')).toBeEmptyDOMElement()
|
||||
expect(input).toHaveValue('a,b,c')
|
||||
})
|
||||
|
||||
it('does not add pasted values when the control is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Fixture disabled />)
|
||||
const input = screen.getByRole('combobox')
|
||||
|
||||
await user.click(input)
|
||||
await user.paste('a,b,c')
|
||||
|
||||
expect(input).toBeDisabled()
|
||||
expect(screen.getByLabelText('Selected values')).toBeEmptyDOMElement()
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import { toast } from 'sonner'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { logout } from '@/features/auth/api'
|
||||
import { clearAuthenticatedClientState } from '@/lib/auth-session'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
|
||||
interface SignOutDialogProps {
|
||||
open: boolean
|
||||
@@ -42,7 +43,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
try {
|
||||
const response = await logout()
|
||||
if (!response.success) {
|
||||
toast.error(response.message || t('Failed to sign out session'))
|
||||
handleServerError(response, t('Failed to sign out session'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,9 +51,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
toast.success(t('Signed out'))
|
||||
void navigate({ to: '/sign-in', replace: true })
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to sign out session')
|
||||
)
|
||||
handleServerError(error, t('Failed to sign out session'))
|
||||
} finally {
|
||||
setIsSigningOut(false)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
|
||||
import { Combobox } from '../combobox'
|
||||
import { Sheet, SheetContent, SheetTitle } from '../sheet'
|
||||
|
||||
const options = [
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
@@ -46,6 +49,90 @@ function Fixture() {
|
||||
}
|
||||
|
||||
describe('searchable single selection', () => {
|
||||
it.each(['dialog', 'sheet'] as const)(
|
||||
'keeps the options closed when a %s autofocuses the input, then opens on click',
|
||||
async (container) => {
|
||||
const field = <Fixture />
|
||||
render(
|
||||
container === 'dialog' ? (
|
||||
<Dialog open title='Choose a provider'>
|
||||
{field}
|
||||
</Dialog>
|
||||
) : (
|
||||
<Sheet open>
|
||||
<SheetContent>
|
||||
<SheetTitle>Choose a provider</SheetTitle>
|
||||
{field}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByRole('combobox', { name: 'Provider' })
|
||||
await waitFor(() => expect(input).toHaveFocus())
|
||||
expect(input).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(input).toHaveValue('OpenAI')
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(input)
|
||||
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
|
||||
await user.click(screen.getByRole('option', { name: 'Google' }))
|
||||
await waitFor(() => expect(input).toHaveValue('Google'))
|
||||
expect(input).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByRole('dialog')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps options closed on Tab focus and opens them with the arrow key', async () => {
|
||||
render(<Fixture />)
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByRole('combobox', { name: 'Provider' })
|
||||
await user.tab()
|
||||
expect(input).toHaveFocus()
|
||||
expect(input).toHaveAttribute('aria-expanded', 'false')
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(input).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(input).toHaveValue('OpenAI')
|
||||
})
|
||||
|
||||
it('opens and filters when typing into a focused, closed input', async () => {
|
||||
render(<Fixture />)
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByRole('combobox', { name: 'Provider' })
|
||||
await user.tab()
|
||||
await user.keyboard('g')
|
||||
expect(input).toHaveValue('g')
|
||||
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'OpenAI' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'honors explicit openOnFocus=%s',
|
||||
async (openOnFocus) => {
|
||||
render(
|
||||
<Combobox
|
||||
options={options}
|
||||
value='openai'
|
||||
aria-label='Provider'
|
||||
openOnFocus={openOnFocus}
|
||||
/>
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByRole('combobox', { name: 'Provider' })
|
||||
await user.tab()
|
||||
expect(input).toHaveFocus()
|
||||
expect(input).toHaveAttribute('aria-expanded', String(openOnFocus))
|
||||
if (!openOnFocus) {
|
||||
await user.click(screen.getByRole('button', { name: 'Provider' }))
|
||||
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('searches labels and values without committing text, shows empty results, and restores the selection on Escape', async () => {
|
||||
render(<Fixture />)
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { FieldLabel } from '../field'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form'
|
||||
import { Input } from '../input'
|
||||
import { Label } from '../label'
|
||||
|
||||
function FormLabelFixture() {
|
||||
const form = useForm({ defaultValues: { name: '' } })
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
describe('required field labels', () => {
|
||||
it.each([Label, FieldLabel])(
|
||||
'updates the required marker while preserving the associated control',
|
||||
async (LabelComponent) => {
|
||||
const view = render(
|
||||
<>
|
||||
<LabelComponent htmlFor='model' required>
|
||||
Model
|
||||
</LabelComponent>
|
||||
<Input id='model' />
|
||||
</>
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByRole('textbox', { name: 'Model *' })
|
||||
await user.click(screen.getByText('*'))
|
||||
expect(input).toHaveFocus()
|
||||
|
||||
view.rerender(
|
||||
<>
|
||||
<LabelComponent htmlFor='model' required={false}>
|
||||
Model
|
||||
</LabelComponent>
|
||||
<Input id='model' />
|
||||
</>
|
||||
)
|
||||
expect(screen.queryByText('*')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'Model' })).toBe(input)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the required form label associated with its React Hook Form control', async () => {
|
||||
render(<FormLabelFixture />)
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByRole('textbox', { name: 'Name *' })
|
||||
await user.click(screen.getByText('*'))
|
||||
expect(input).toHaveFocus()
|
||||
await user.type(input, 'Example')
|
||||
expect(input).toHaveValue('Example')
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -38,12 +39,15 @@ interface ComboboxInputProps {
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
className?: string
|
||||
dropdownClassName?: string
|
||||
id?: string
|
||||
allowCustomValue?: boolean
|
||||
openOnFocus?: boolean
|
||||
disabled?: boolean
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
|
||||
'aria-label'?: string
|
||||
'aria-labelledby'?: string
|
||||
'aria-invalid'?: React.AriaAttributes['aria-invalid']
|
||||
}
|
||||
|
||||
export function ComboboxInput({
|
||||
@@ -53,12 +57,15 @@ export function ComboboxInput({
|
||||
placeholder = 'Select or type...',
|
||||
emptyText = 'No option found.',
|
||||
className,
|
||||
dropdownClassName,
|
||||
id,
|
||||
allowCustomValue = false,
|
||||
openOnFocus = true,
|
||||
disabled = false,
|
||||
onKeyDown,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
'aria-invalid': ariaInvalid,
|
||||
}: ComboboxInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const listId = React.useId()
|
||||
@@ -69,6 +76,16 @@ export function ComboboxInput({
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const listRef = React.useRef<HTMLUListElement>(null)
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null)
|
||||
// The dropdown is portaled so scrolling ancestors cannot clip it. It goes
|
||||
// into the enclosing dialog when there is one, which keeps a modal parent
|
||||
// from treating option clicks as outside presses.
|
||||
const [dropdown, setDropdown] = React.useState<{
|
||||
container: HTMLElement
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
} | null>(null)
|
||||
const pointerFocusRef = React.useRef(false)
|
||||
const selectedOption = React.useMemo(
|
||||
() => options.find((option) => option.value === value),
|
||||
@@ -96,9 +113,11 @@ export function ComboboxInput({
|
||||
if (!open) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
!containerRef.current.contains(target) &&
|
||||
!dropdownRef.current?.contains(target)
|
||||
) {
|
||||
setOpen(false)
|
||||
setSearchValue('')
|
||||
@@ -109,6 +128,34 @@ export function ComboboxInput({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [open])
|
||||
|
||||
// Position the portaled dropdown under the input and follow scroll/resize.
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setDropdown(null)
|
||||
return
|
||||
}
|
||||
const input = inputRef.current
|
||||
if (!input) return
|
||||
const container =
|
||||
input.closest<HTMLElement>('[role="dialog"]') ?? document.body
|
||||
const measure = () => {
|
||||
const rect = input.getBoundingClientRect()
|
||||
setDropdown({
|
||||
container,
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
})
|
||||
}
|
||||
measure()
|
||||
window.addEventListener('scroll', measure, true)
|
||||
window.addEventListener('resize', measure)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', measure, true)
|
||||
window.removeEventListener('resize', measure)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onValueChange(selectedValue)
|
||||
setOpen(false)
|
||||
@@ -172,6 +219,7 @@ export function ComboboxInput({
|
||||
|
||||
const showDropdown =
|
||||
open &&
|
||||
!disabled &&
|
||||
(filteredOptions.length > 0 || (allowCustomValue && searchValue.trim()))
|
||||
|
||||
return (
|
||||
@@ -181,8 +229,10 @@ export function ComboboxInput({
|
||||
id={id}
|
||||
type='text'
|
||||
role='combobox'
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-expanded={!!showDropdown}
|
||||
aria-controls={
|
||||
showDropdown && filteredOptions.length > 0 ? listId : undefined
|
||||
@@ -234,59 +284,74 @@ export function ComboboxInput({
|
||||
/>
|
||||
<ChevronsUpDown className='pointer-events-none absolute top-1/2 right-3 size-4 shrink-0 -translate-y-1/2 opacity-50' />
|
||||
|
||||
{showDropdown && (
|
||||
<div className='bg-popover text-popover-foreground absolute top-full z-100 mt-1 w-full rounded-md border shadow-md'>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<ul
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role='listbox'
|
||||
className='max-h-[200px] overflow-y-auto p-1'
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
<li
|
||||
key={option.value}
|
||||
id={`${listId}-${index}`}
|
||||
role='option'
|
||||
aria-selected={value === option.value}
|
||||
data-highlighted={index === highlightedIndex}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none',
|
||||
index === highlightedIndex &&
|
||||
'bg-accent text-accent-foreground',
|
||||
value === option.value && 'font-medium'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // Prevent blur
|
||||
handleSelect(option.value)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
{showDropdown &&
|
||||
dropdown &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: dropdown.top,
|
||||
left: dropdown.left,
|
||||
width: dropdown.width,
|
||||
}}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-100 rounded-md border shadow-md',
|
||||
dropdownClassName
|
||||
)}
|
||||
>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<ul
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role='listbox'
|
||||
className='max-h-[200px] overflow-y-auto p-1'
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
<li
|
||||
key={option.value}
|
||||
id={`${listId}-${index}`}
|
||||
role='option'
|
||||
aria-selected={value === option.value}
|
||||
data-highlighted={index === highlightedIndex}
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
value === option.value ? 'opacity-100' : 'opacity-0'
|
||||
'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none',
|
||||
index === highlightedIndex &&
|
||||
'bg-accent text-accent-foreground',
|
||||
value === option.value && 'font-medium'
|
||||
)}
|
||||
/>
|
||||
{option.icon && <span aria-hidden>{option.icon}</span>}
|
||||
<span className='truncate'>{option.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className='px-2 py-6 text-center text-sm'>
|
||||
{t(emptyText)}
|
||||
{allowCustomValue && searchValue.trim() && (
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Press Enter to use "{{value}}"', {
|
||||
value: searchValue.trim(),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // Prevent blur
|
||||
handleSelect(option.value)
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
value === option.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{option.icon && <span aria-hidden>{option.icon}</span>}
|
||||
<span className='truncate'>{option.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className='px-2 py-6 text-center text-sm'>
|
||||
{t(emptyText)}
|
||||
{allowCustomValue && searchValue.trim() && (
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Press Enter to use "{{value}}"', {
|
||||
value: searchValue.trim(),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
dropdown.container
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -50,6 +50,7 @@ type LegacyComboboxProps = {
|
||||
allowCustomValue?: boolean
|
||||
showSelectedIcon?: boolean
|
||||
className?: string
|
||||
dropdownClassName?: string
|
||||
id?: string
|
||||
openOnFocus?: boolean
|
||||
disabled?: boolean
|
||||
@@ -79,6 +80,7 @@ function Combobox(
|
||||
id={props.id}
|
||||
aria-label={props['aria-label']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
aria-invalid={props['aria-invalid']}
|
||||
onKeyDown={props.onKeyDown}
|
||||
options={props.options}
|
||||
value={props.value ?? ''}
|
||||
@@ -86,6 +88,7 @@ function Combobox(
|
||||
placeholder={props.searchPlaceholder ?? props.placeholder}
|
||||
emptyText={props.emptyText}
|
||||
className={props.className}
|
||||
dropdownClassName={props.dropdownClassName}
|
||||
allowCustomValue={props.allowCustomValue}
|
||||
openOnFocus={props.openOnFocus}
|
||||
/>
|
||||
@@ -113,9 +116,9 @@ function OptionCombobox(props: LegacyComboboxProps) {
|
||||
onInputValueChange={(value, details) => {
|
||||
if (details.reason === 'input-change') setSearch(value)
|
||||
}}
|
||||
onOpenChange={(nextOpen) => {
|
||||
onOpenChange={(nextOpen, details) => {
|
||||
setOpen(nextOpen)
|
||||
setSearch('')
|
||||
if (details.reason !== 'input-change') setSearch('')
|
||||
}}
|
||||
onValueChange={(option) => {
|
||||
if (option) props.onValueChange?.(option.value)
|
||||
@@ -137,7 +140,8 @@ function OptionCombobox(props: LegacyComboboxProps) {
|
||||
onBlur={props.onBlur}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onFocus={() => {
|
||||
if (props.openOnFocus !== false) setOpen(true)
|
||||
// Dialog autofocus should not expand a select-style combobox.
|
||||
if (props.openOnFocus) setOpen(true)
|
||||
}}
|
||||
aria-label={props['aria-label']}
|
||||
aria-labelledby={props['aria-labelledby']}
|
||||
|
||||
@@ -22,7 +22,12 @@ import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<'label'>) {
|
||||
function Label({
|
||||
className,
|
||||
children,
|
||||
required = false,
|
||||
...props
|
||||
}: React.ComponentProps<'label'> & { required?: boolean }) {
|
||||
return (
|
||||
<label
|
||||
data-slot='label'
|
||||
@@ -31,7 +36,18 @@ function Label({ className, ...props }: React.ComponentProps<'label'>) {
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{required ? (
|
||||
<span>
|
||||
{children}{' '}
|
||||
<span data-slot='required-indicator' className='text-destructive'>
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import { useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MarkdownProps {
|
||||
/** Resolve relative document links against their source, never the dashboard URL. */
|
||||
baseUrl?: string
|
||||
breaks?: boolean
|
||||
children: string
|
||||
className?: string
|
||||
@@ -718,7 +720,7 @@ const markdownParser = new Marked({
|
||||
|
||||
markdownParser.use(...markdownExtensions)
|
||||
|
||||
function addExternalLinkAttributes(html: string): string {
|
||||
function addExternalLinkAttributes(html: string, baseUrl?: string): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return html
|
||||
}
|
||||
@@ -727,6 +729,18 @@ function addExternalLinkAttributes(html: string): string {
|
||||
template.innerHTML = html
|
||||
|
||||
template.content.querySelectorAll('a[href]').forEach((link) => {
|
||||
if (baseUrl) {
|
||||
try {
|
||||
const url = new URL(link.getAttribute('href') ?? '', baseUrl)
|
||||
if (['http:', 'https:', 'mailto:'].includes(url.protocol)) {
|
||||
link.setAttribute('href', url.href)
|
||||
} else {
|
||||
link.removeAttribute('href')
|
||||
}
|
||||
} catch {
|
||||
link.removeAttribute('href')
|
||||
}
|
||||
}
|
||||
link.setAttribute('target', '_blank')
|
||||
link.setAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
@@ -734,20 +748,24 @@ function addExternalLinkAttributes(html: string): string {
|
||||
return template.innerHTML
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string, breaks = false): string {
|
||||
function renderMarkdown(
|
||||
markdown: string,
|
||||
breaks = false,
|
||||
baseUrl?: string
|
||||
): string {
|
||||
const parsedHtml = markdownParser.parse(markdown, {
|
||||
...markdownOptions,
|
||||
breaks,
|
||||
})
|
||||
const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions)
|
||||
|
||||
return addExternalLinkAttributes(html)
|
||||
return addExternalLinkAttributes(html, baseUrl)
|
||||
}
|
||||
|
||||
export function Markdown(props: MarkdownProps) {
|
||||
const html = useMemo(
|
||||
() => renderMarkdown(props.children, props.breaks),
|
||||
[props.breaks, props.children]
|
||||
() => renderMarkdown(props.children, props.breaks, props.baseUrl),
|
||||
[props.breaks, props.children, props.baseUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user