Merge branch 'main' into main
This commit is contained in:
@@ -11,6 +11,17 @@ describe('CreateViewDialog', () => {
|
||||
availableFields: ['type', 'status', 'title'],
|
||||
}
|
||||
|
||||
function makeEditingView(overrides: Partial<ViewDefinition> = {}): ViewDefinition {
|
||||
return {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('shows "Create View" title in create mode', () => {
|
||||
render(<CreateViewDialog {...defaultProps} />)
|
||||
expect(screen.getByText('Create View')).toBeInTheDocument()
|
||||
@@ -18,46 +29,28 @@ describe('CreateViewDialog', () => {
|
||||
})
|
||||
|
||||
it('shows "Edit View" title when editingView is provided', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
render(<CreateViewDialog {...defaultProps} editingView={makeEditingView()} />)
|
||||
expect(screen.getByText('Edit View')).toBeInTheDocument()
|
||||
expect(screen.getByText('Save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('pre-populates name field in edit mode', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
render(<CreateViewDialog {...defaultProps} editingView={makeEditingView()} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
expect(input).toHaveValue('Active Projects')
|
||||
})
|
||||
|
||||
it('preserves emoji icon when editing a view', async () => {
|
||||
it('preserves existing icon and markdown-defined color when editing a view', async () => {
|
||||
const onCreate = vi.fn()
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Monday',
|
||||
icon: '🗂️',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
const editingView = makeEditingView({ name: 'Monday', icon: '🗂️', color: 'blue' })
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} editingView={editingView} />)
|
||||
|
||||
// Submit the form without changing anything
|
||||
fireEvent.submit(screen.getByText('Save').closest('form')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: '🗂️' })
|
||||
expect.objectContaining({ icon: '🗂️', color: 'blue' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { FilterGroup, ViewDefinition } from '../types'
|
||||
|
||||
type SaveViewResult = boolean | void
|
||||
type SaveViewHandler = (definition: ViewDefinition) => SaveViewResult | Promise<SaveViewResult>
|
||||
type InitialViewFormValues = Pick<ViewDefinition, 'name' | 'icon' | 'color' | 'filters'>
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
@@ -22,6 +23,7 @@ interface CreateViewDialogFormProps {
|
||||
availableFields: string[]
|
||||
initialName: string
|
||||
initialIcon: string
|
||||
initialColor: string | null
|
||||
initialFilters: FilterGroup
|
||||
isEditing: boolean
|
||||
onClose: () => void
|
||||
@@ -32,6 +34,7 @@ function CreateViewDialogForm({
|
||||
availableFields,
|
||||
initialName,
|
||||
initialIcon,
|
||||
initialColor,
|
||||
initialFilters,
|
||||
isEditing,
|
||||
onClose,
|
||||
@@ -58,7 +61,7 @@ function CreateViewDialogForm({
|
||||
const definition: ViewDefinition = {
|
||||
name: trimmed,
|
||||
icon: icon || null,
|
||||
color: null,
|
||||
color: initialColor,
|
||||
sort: null,
|
||||
filters,
|
||||
}
|
||||
@@ -142,9 +145,27 @@ function CreateViewDialogForm({
|
||||
)
|
||||
}
|
||||
|
||||
function getInitialViewFormValues(
|
||||
editingView: ViewDefinition | null | undefined,
|
||||
availableFields: string[],
|
||||
): InitialViewFormValues {
|
||||
return {
|
||||
name: editingView?.name ?? '',
|
||||
icon: editingView?.icon ?? '',
|
||||
color: editingView?.color ?? null,
|
||||
filters: editingView?.filters ?? { all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] },
|
||||
}
|
||||
}
|
||||
|
||||
function getDialogDescription(isEditing: boolean) {
|
||||
return isEditing
|
||||
? 'Update the name, icon, and filters for this saved view.'
|
||||
: 'Create a saved view by choosing a name, icon, and filter rules.'
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, editingView }: CreateViewDialogProps) {
|
||||
const isEditing = !!editingView
|
||||
const initialFilters = editingView?.filters ?? { all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }
|
||||
const initialValues = getInitialViewFormValues(editingView, availableFields)
|
||||
const formKey = editingView ? `edit:${editingView.name}` : `create:${availableFields[0] ?? 'type'}`
|
||||
|
||||
return (
|
||||
@@ -153,16 +174,17 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, edi
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'Edit View' : 'Create View'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{isEditing ? 'Update the name, icon, and filters for this saved view.' : 'Create a saved view by choosing a name, icon, and filter rules.'}
|
||||
{getDialogDescription(isEditing)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{open && (
|
||||
<CreateViewDialogForm
|
||||
key={formKey}
|
||||
availableFields={availableFields}
|
||||
initialName={editingView?.name ?? ''}
|
||||
initialIcon={editingView?.icon ?? ''}
|
||||
initialFilters={initialFilters}
|
||||
initialName={initialValues.name}
|
||||
initialIcon={initialValues.icon ?? ''}
|
||||
initialColor={initialValues.color}
|
||||
initialFilters={initialValues.filters}
|
||||
isEditing={isEditing}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
|
||||
@@ -1492,6 +1492,69 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByText('rocket')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses saved view color metadata for sidebar accents', () => {
|
||||
const coloredViews = [{
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: 'rocket',
|
||||
color: 'blue',
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
}]
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={{ kind: 'view', filename: 'active-projects.yml' }}
|
||||
onSelect={() => {}}
|
||||
views={coloredViews}
|
||||
/>
|
||||
)
|
||||
|
||||
const navItem = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
const icon = navItem.querySelector('svg')
|
||||
const countChip = within(navItem).getByTestId('view-count-chip')
|
||||
|
||||
expect(navItem).toHaveStyle({
|
||||
background: 'var(--accent-blue-light)',
|
||||
color: 'var(--accent-blue)',
|
||||
})
|
||||
expect(icon).toHaveStyle({ color: 'var(--accent-blue)' })
|
||||
expect(countChip).toHaveStyle({
|
||||
background: 'var(--accent-blue)',
|
||||
color: 'var(--text-inverse)',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the default active styling when a saved view color is invalid', () => {
|
||||
const invalidColorViews = [{
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: 'rocket',
|
||||
color: 'not-a-token',
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
}]
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={{ kind: 'view', filename: 'active-projects.yml' }}
|
||||
onSelect={() => {}}
|
||||
views={invalidColorViews}
|
||||
/>
|
||||
)
|
||||
|
||||
const navItem = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
|
||||
expect(navItem.className).toContain('text-primary')
|
||||
expect(navItem.getAttribute('style')).not.toContain('--accent-')
|
||||
})
|
||||
|
||||
it('does not show count chip for views with 0 matching notes', () => {
|
||||
const emptyView = [{
|
||||
filename: 'empty.yml',
|
||||
|
||||
@@ -9,6 +9,12 @@ import { SidebarCountPill } from '../SidebarParts'
|
||||
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
|
||||
import { translate, type AppLocale } from '../../lib/i18n'
|
||||
import type { ViewMoveDirection } from '../../utils/viewOrdering'
|
||||
import { ACCENT_COLORS } from '../../utils/typeColors'
|
||||
|
||||
interface ViewAccent {
|
||||
color: string
|
||||
background: string
|
||||
}
|
||||
|
||||
interface SidebarViewItemProps {
|
||||
view: ViewFile
|
||||
@@ -24,6 +30,58 @@ interface SidebarViewItemProps {
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
function resolveViewAccent(color: string | null): ViewAccent | null {
|
||||
const colorKey = color?.trim().toLowerCase()
|
||||
if (!colorKey) return null
|
||||
const accent = ACCENT_COLORS.find((candidate) => candidate.key === colorKey)
|
||||
if (!accent) return null
|
||||
return {
|
||||
color: accent.css,
|
||||
background: accent.cssLight,
|
||||
}
|
||||
}
|
||||
|
||||
function getViewRowStyle(showCount: boolean, isActive: boolean, accent: ViewAccent | null) {
|
||||
return {
|
||||
padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular,
|
||||
borderRadius: 4,
|
||||
...(isActive && accent ? { background: accent.background, color: accent.color } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function ViewIcon({
|
||||
icon,
|
||||
isActive,
|
||||
accent,
|
||||
}: {
|
||||
icon: string | null
|
||||
isActive: boolean
|
||||
accent: ViewAccent | null
|
||||
}) {
|
||||
if (icon) return <NoteTitleIcon icon={icon} size={16} color={accent?.color} />
|
||||
return <Funnel size={16} weight={isActive ? 'fill' : 'regular'} style={accent ? { color: accent.color } : undefined} />
|
||||
}
|
||||
|
||||
function ViewCountChip({
|
||||
count,
|
||||
isActive,
|
||||
accent,
|
||||
}: {
|
||||
count: number
|
||||
isActive: boolean
|
||||
accent: ViewAccent | null
|
||||
}) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<SidebarCountPill
|
||||
count={count}
|
||||
className="text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
|
||||
style={isActive && accent ? { background: accent.color, color: 'var(--text-inverse)' } : { background: 'var(--muted)' }}
|
||||
testId="view-count-chip"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarViewItem({
|
||||
view,
|
||||
isActive,
|
||||
@@ -39,15 +97,13 @@ export function SidebarViewItem({
|
||||
}: SidebarViewItemProps) {
|
||||
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
|
||||
const showCount = count > 0
|
||||
const icon = view.definition.icon
|
||||
? <NoteTitleIcon icon={view.definition.icon} size={16} />
|
||||
: <Funnel size={16} weight={isActive ? 'fill' : 'regular'} />
|
||||
const accent = resolveViewAccent(view.definition.color)
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div
|
||||
className={`flex cursor-pointer select-none items-center gap-2 rounded transition-colors ${isActive ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-accent'}`}
|
||||
style={{ padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular, borderRadius: 4 }}
|
||||
style={getViewRowStyle(showCount, isActive, accent)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{dragHandleProps && (
|
||||
@@ -64,16 +120,9 @@ export function SidebarViewItem({
|
||||
<GripVertical size={12} />
|
||||
</Button>
|
||||
)}
|
||||
{icon}
|
||||
<ViewIcon icon={view.definition.icon} isActive={isActive} accent={accent} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span>
|
||||
{showCount && (
|
||||
<SidebarCountPill
|
||||
count={count}
|
||||
className="text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
|
||||
style={{ background: 'var(--muted)' }}
|
||||
testId="view-count-chip"
|
||||
/>
|
||||
)}
|
||||
<ViewCountChip count={count} isActive={isActive} accent={accent} />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
|
||||
{onMoveView && (
|
||||
|
||||
@@ -14455,6 +14455,101 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "view_color_sidebar_row",
|
||||
"x": 960,
|
||||
"y": 13980,
|
||||
"name": "Saved View color metadata - sidebar row",
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"width": 420,
|
||||
"height": 128,
|
||||
"fill": "$--sidebar",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "$--sidebar-border"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 10,
|
||||
"padding": 16,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "view_color_sidebar_header",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "VIEWS",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "700",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "view_color_sidebar_active_row",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"fill": "$--accent-blue-light",
|
||||
"cornerRadius": 4,
|
||||
"padding": [
|
||||
6,
|
||||
8,
|
||||
6,
|
||||
16
|
||||
],
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "view_color_sidebar_icon",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "rocket",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "view_color_sidebar_label",
|
||||
"fill": "$--accent-blue",
|
||||
"width": "fill_container",
|
||||
"content": "Active Projects",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "view_color_sidebar_count",
|
||||
"fill": "$--text-inverse",
|
||||
"content": "4",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "view_color_sidebar_note",
|
||||
"fill": "$--muted-foreground",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Saved Views with color metadata use the accent for the icon, active label, active row tint, and active count pill; invalid or missing colors keep the default active row treatment.",
|
||||
"lineHeight": 1.45,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal",
|
||||
"letterSpacing": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"themes": {
|
||||
|
||||
Reference in New Issue
Block a user