Merge branch 'main' into main

This commit is contained in:
github-actions[bot]
2026-04-28 23:54:31 +00:00
committed by GitHub
5 changed files with 266 additions and 44 deletions

View File

@@ -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' })
)
})
})

View File

@@ -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}

View File

@@ -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',

View File

@@ -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 && (

View File

@@ -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": {