Compare commits

...

2 Commits

Author SHA1 Message Date
Test
6015f95ded fix: surface status in note list chip picker 2026-04-08 20:56:12 +02:00
Test
f8112e9385 fix: improve note icon property editing 2026-04-08 20:40:14 +02:00
9 changed files with 759 additions and 78 deletions

View File

@@ -202,17 +202,31 @@
flex-shrink: 0;
}
.title-section__add-icon {
/* "Add icon" button above the title when no emoji — Notion-style */
padding-top: 24px;
margin-left: 8px;
min-height: 28px;
.title-section__heading {
position: relative;
}
.title-section__inline-add-icon {
position: absolute;
left: 8px;
top: 0;
z-index: 1;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.title-section:hover .title-section__inline-add-icon,
.title-section__inline-add-icon:focus-within {
opacity: 1;
pointer-events: auto;
}
.title-section__row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
padding-top: 8px;
margin-left: 8px;
}
@@ -235,6 +249,8 @@
/* --- Note Icon Area --- */
.note-icon-area {
display: flex;
align-items: flex-start;
flex-shrink: 0;
}
@@ -251,16 +267,24 @@
}
.note-icon-button--active {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 36px;
line-height: 1;
transition: transform 0.1s;
margin-right: 10px;
}
.note-icon-button--active:hover:not(:disabled) {
transform: scale(1.1);
}
.note-icon-button--active :is(img, svg) {
display: block;
}
.note-icon-button--add {
display: flex;
align-items: center;

View File

@@ -0,0 +1,76 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { IconEditableValue } from './IconEditableValue'
function renderIconValue(overrides: Partial<React.ComponentProps<typeof IconEditableValue>> = {}) {
const onSave = vi.fn()
const onCancel = vi.fn()
const onStartEdit = vi.fn()
render(
<IconEditableValue
value=""
onSave={onSave}
onCancel={onCancel}
isEditing
onStartEdit={onStartEdit}
{...overrides}
/>,
)
return { onSave, onCancel, onStartEdit }
}
describe('IconEditableValue', () => {
it('shows searchable icon results with previews while editing', () => {
renderIconValue()
expect(screen.getByTestId('icon-editable-input')).toHaveAttribute('role', 'combobox')
expect(screen.getByTestId('icon-picker-results')).toBeInTheDocument()
expect(screen.getAllByRole('option').length).toBeGreaterThan(0)
})
it('selects the first matching icon on Enter for icon-name queries', () => {
const { onSave } = renderIconValue()
const input = screen.getByTestId('icon-editable-input')
fireEvent.change(input, { target: { value: 'rocket' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('rocket')
})
it('supports keyboard navigation before selecting an icon', () => {
const { onSave } = renderIconValue()
const input = screen.getByTestId('icon-editable-input')
fireEvent.change(input, { target: { value: 'file' } })
const options = screen.getAllByRole('option')
expect(options.length).toBeGreaterThan(1)
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith(options[1].textContent ?? '')
})
it('keeps manual URL values instead of forcing an icon suggestion', () => {
const { onSave } = renderIconValue()
const input = screen.getByTestId('icon-editable-input')
fireEvent.change(input, { target: { value: 'https://example.com/icon.png' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('https://example.com/icon.png')
})
it('shows an empty state when no icon matches the query', () => {
renderIconValue()
const input = screen.getByTestId('icon-editable-input')
fireEvent.change(input, { target: { value: 'totally-not-a-real-icon' } })
expect(screen.getByTestId('icon-picker-empty')).toHaveTextContent('No icons found')
})
})

View File

@@ -0,0 +1,181 @@
import { useId, useMemo, useState } from 'react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { isEmoji } from '../utils/emoji'
import { ICON_OPTIONS } from '../utils/iconRegistry'
const MAX_ICON_RESULTS = 24
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function normalizeIconQuery(query: string): string {
return query.trim().toLowerCase()
}
function matchesIconQuery(name: string, query: string): boolean {
if (!query) return true
const normalized = normalizeIconQuery(query)
const spacedName = name.replace(/-/g, ' ')
const dashedQuery = normalized.replace(/\s+/g, '-')
return name.includes(dashedQuery) || spacedName.includes(normalized)
}
function filterIconOptions(query: string) {
return ICON_OPTIONS
.filter((option) => matchesIconQuery(option.name, query))
.slice(0, MAX_ICON_RESULTS)
}
function shouldSelectIconSuggestion(value: string, suggestionCount: number): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (isEmoji(trimmed) || isHttpUrl(trimmed)) return false
return suggestionCount > 0
}
interface IconEditableValueProps {
value: string
onSave: (newValue: string) => void
onCancel: () => void
isEditing: boolean
onStartEdit: () => void
}
function IconEditableInput({
value,
onSave,
onCancel,
}: Pick<IconEditableValueProps, 'value' | 'onSave' | 'onCancel'>) {
const [editValue, setEditValue] = useState(value)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const listboxId = useId()
const filteredIcons = useMemo(() => filterIconOptions(editValue), [editValue])
const commitTypedValue = () => onSave(editValue)
const selectIcon = (iconName: string) => {
setEditValue(iconName)
onSave(iconName)
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
if (filteredIcons.length === 0) return
setHighlightedIndex((current) => (current + 1) % filteredIcons.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
if (filteredIcons.length === 0) return
setHighlightedIndex((current) => (current - 1 + filteredIcons.length) % filteredIcons.length)
return
}
if (event.key === 'Enter') {
event.preventDefault()
if (shouldSelectIconSuggestion(editValue, filteredIcons.length)) {
selectIcon(filteredIcons[highlightedIndex]?.name ?? editValue)
return
}
commitTypedValue()
return
}
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}
return (
<div className="relative flex flex-col gap-2" data-testid="icon-editable-value">
<Input
type="text"
value={editValue}
onChange={(event) => {
setEditValue(event.target.value)
setHighlightedIndex(0)
}}
onKeyDown={handleKeyDown}
onBlur={commitTypedValue}
autoFocus
role="combobox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded
aria-activedescendant={filteredIcons[highlightedIndex] ? `${listboxId}-option-${highlightedIndex}` : undefined}
placeholder="Emoji, icon name, or URL"
className="h-8 text-[12px]"
data-testid="icon-editable-input"
/>
<div
id={listboxId}
role="listbox"
className="max-h-44 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-sm"
data-testid="icon-picker-results"
>
{filteredIcons.length === 0 ? (
<div className="px-2 py-6 text-center text-[12px] text-muted-foreground" data-testid="icon-picker-empty">
No icons found
</div>
) : (
filteredIcons.map(({ name, Icon }, index) => (
<button
key={name}
id={`${listboxId}-option-${index}`}
type="button"
role="option"
aria-selected={index === highlightedIndex}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] transition-colors',
index === highlightedIndex
? 'bg-primary/10 text-primary'
: 'text-foreground hover:bg-accent hover:text-foreground',
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => selectIcon(name)}
data-testid={`icon-picker-option-${name}`}
>
<Icon size={14} aria-hidden="true" className="shrink-0" />
<span className="truncate">{name}</span>
</button>
))
)}
</div>
</div>
)
}
export function IconEditableValue({
value,
onSave,
onCancel,
isEditing,
onStartEdit,
}: IconEditableValueProps) {
if (isEditing) {
return <IconEditableInput key={value} value={value} onSave={onSave} onCancel={onCancel} />
}
return (
<span
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
data-testid="icon-editable-display"
>
{value || '\u2014'}
</span>
)
}

View File

@@ -82,6 +82,10 @@ function resolveChipValues(
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] {
if (propName.toLowerCase() === 'status') {
const formatted = formatChipValue(entry.status)
return formatted ? [{ label: formatted, noteIcon: null, typeIcon: null }] : []
}
// Check relationships first (wikilink values)
const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === propName.toLowerCase())
if (relKey) {

View File

@@ -193,6 +193,83 @@ describe('NoteList rendering', () => {
expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
})
it('shows status in the type column picker when at least one note has it set', () => {
const entries = [
makeTypeDefinition('Book'),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
status: 'Active',
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
selection: { kind: 'sectionGroup', type: 'Book' },
onUpdateTypeSort: () => undefined,
})
act(() => {
openNoteListPropertiesPicker('type')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'status' })).toBeInTheDocument()
})
it('keeps blank statuses out of the type column picker', () => {
const entries = [
makeTypeDefinition('Book'),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
status: '',
properties: { Owner: 'Luca' },
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
selection: { kind: 'sectionGroup', type: 'Book' },
onUpdateTypeSort: () => undefined,
})
act(() => {
openNoteListPropertiesPicker('type')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'Owner' })).toBeInTheDocument()
expect(screen.queryByRole('checkbox', { name: 'status' })).not.toBeInTheDocument()
})
it('renders status as a note-list chip when a type displays it', () => {
const entries = [
makeTypeDefinition('Book', ['status']),
makeEntry({
path: '/vault/book.md',
filename: 'book.md',
title: 'Book Note',
isA: 'Book',
status: 'Active',
createdAt: 1700000000,
}),
]
renderNoteList({
entries,
selection: { kind: 'sectionGroup', type: 'Book' },
})
expect(screen.getByTestId('property-chips')).toHaveTextContent('Active')
})
it('uses inbox overrides when configured', () => {
const entries = [
makeTypeDefinition('Book', ['Priority']),

View File

@@ -19,6 +19,7 @@ import { getStatusStyle } from '../utils/statusStyles'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
import { IconEditableValue } from './IconEditableValue'
function parseDateValue(value: string): Date | undefined {
const iso = toISODate(value)
@@ -301,9 +302,52 @@ type SmartCellProps = {
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
}
function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate }: SmartCellProps) {
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(propKey, value) : displayMode
interface ScalarEditProps {
value: string
isEditing: boolean
onStartEdit: () => void
onSave: (nextValue: string) => void
onCancel: () => void
}
function createScalarEditProps({
propKey,
value,
isEditing,
onStartEdit,
onSave,
}: {
propKey: string
value: FrontmatterValue
isEditing: boolean
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
}): ScalarEditProps {
return {
value: String(value ?? ''),
isEditing,
onStartEdit: () => onStartEdit(propKey),
onSave: (nextValue: string) => onSave(propKey, nextValue),
onCancel: () => onStartEdit(null),
}
}
function renderScalarDisplayMode({
propKey,
value,
isEditing,
resolvedMode,
vaultStatuses,
vaultTags,
onSave,
onSaveList,
onStartEdit,
onUpdate,
editProps,
}: SmartCellProps & {
resolvedMode: PropertyDisplayMode
editProps: ScalarEditProps
}) {
switch (resolvedMode) {
case 'status':
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
@@ -332,6 +376,24 @@ function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses
}
}
function ScalarValueCell(props: SmartCellProps) {
const { propKey, value, displayMode, isEditing, onStartEdit, onSave } = props
const editProps = createScalarEditProps({
propKey,
value,
isEditing,
onStartEdit,
onSave,
})
if (propKey.toLowerCase() === 'icon') {
return <IconEditableValue {...editProps} />
}
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(propKey, value) : displayMode
return renderScalarDisplayMode({ ...props, resolvedMode, editProps })
}
export function SmartPropertyValueCell(props: SmartCellProps) {
const { propKey, value, displayMode, isEditing, vaultTags, onSaveList, onStartEdit } = props
if (Array.isArray(value)) {

View File

@@ -0,0 +1,120 @@
import { createRef } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { EditorContentLayout } from './EditorContentLayout'
vi.mock('../BreadcrumbBar', () => ({
BreadcrumbBar: () => <div data-testid="breadcrumb-bar" />,
}))
vi.mock('../TitleField', () => ({
TitleField: () => <div data-testid="title-field-input" />,
}))
vi.mock('../NoteIcon', () => ({
NoteIcon: ({ icon }: { icon: string | null }) => (
icon
? <button type="button" data-testid="note-icon-display" />
: <button type="button" data-testid="note-icon-add" />
),
}))
vi.mock('../ArchivedNoteBanner', () => ({
ArchivedNoteBanner: () => <div data-testid="archived-banner" />,
}))
vi.mock('../ConflictNoteBanner', () => ({
ConflictNoteBanner: () => <div data-testid="conflict-banner" />,
}))
vi.mock('../RawEditorView', () => ({
RawEditorView: () => <div data-testid="raw-editor-view" />,
}))
vi.mock('../SingleEditorView', () => ({
SingleEditorView: () => <div data-testid="single-editor-view" />,
}))
vi.mock('../DiffView', () => ({
DiffView: () => <div data-testid="diff-view" />,
}))
function createModel(overrides: Record<string, unknown> = {}) {
return {
activeTab: {
entry: {
path: '/vault/project/demo.md',
filename: 'demo.md',
title: 'Demo Note',
},
content: 'Body',
},
isLoadingNewTab: false,
entries: [],
editor: {},
diffMode: false,
diffContent: null,
diffLoading: false,
onToggleDiff: vi.fn(),
effectiveRawMode: false,
onToggleRaw: vi.fn(),
onRawContentChange: vi.fn(),
onSave: vi.fn(),
showEditor: true,
isArchived: false,
onUnarchiveNote: undefined,
path: '/vault/project/demo.md',
isConflicted: false,
onKeepMine: vi.fn(),
onKeepTheirs: vi.fn(),
breadcrumbBarRef: createRef<HTMLDivElement>(),
wordCount: 12,
titleSectionRef: createRef<HTMLDivElement>(),
showTitleSection: true,
hasDisplayIcon: false,
entryIcon: null,
vaultPath: '/vault',
onTitleChange: vi.fn(),
cssVars: {},
onNavigateWikilink: vi.fn(),
onEditorChange: vi.fn(),
isDeletedPreview: false,
rawLatestContentRef: { current: null },
forceRawMode: false,
showAIChat: false,
onToggleAIChat: vi.fn(),
inspectorCollapsed: true,
onToggleInspector: vi.fn(),
showDiffToggle: false,
onToggleFavorite: vi.fn(),
onToggleOrganized: vi.fn(),
onDeleteNote: vi.fn(),
onArchiveNote: vi.fn(),
...overrides,
} as never
}
describe('EditorContentLayout', () => {
it('does not render a standalone add-icon row when the note has no icon', () => {
const { container } = render(<EditorContentLayout {...createModel()} />)
expect(container.querySelector('.title-section__add-icon')).toBeNull()
expect(container.querySelector('.title-section__inline-add-icon')).not.toBeNull()
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
})
it('keeps the existing icon and title inside the same title row', () => {
const { container } = render(
<EditorContentLayout
{...createModel({
hasDisplayIcon: true,
entryIcon: 'rocket',
})}
/>,
)
const titleRow = container.querySelector('.title-section__row')
expect(titleRow?.querySelector('[data-testid="note-icon-display"]')).not.toBeNull()
expect(titleRow?.querySelector('[data-testid="title-field-input"]')).not.toBeNull()
})
})

View File

@@ -150,21 +150,23 @@ function TitleSection({
<div ref={titleSectionRef} className="title-section" data-title-ui-visible={showTitleSection || undefined}>
{showTitleSection && (
<>
{!hasDisplayIcon && (
<div className="title-section__add-icon">
<NoteIcon icon={null} editable />
<div className="title-section__heading">
{!hasDisplayIcon && (
<div className="title-section__inline-add-icon">
<NoteIcon icon={null} editable />
</div>
)}
<div className={`title-section__row${hasDisplayIcon ? '' : ' title-section__row--no-icon'}`}>
{hasDisplayIcon && <NoteIcon icon={entryIcon} editable />}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
/>
</div>
)}
<div className={`title-section__row${hasDisplayIcon ? '' : ' title-section__row--no-icon'}`}>
{hasDisplayIcon && <NoteIcon icon={entryIcon} editable />}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
/>
</div>
<div className="title-section__separator" />
</>
@@ -173,6 +175,98 @@ function TitleSection({
)
}
function EditorChrome({
isArchived,
onUnarchiveNote,
path,
isConflicted,
onKeepMine,
onKeepTheirs,
diffMode,
diffContent,
onToggleDiff,
}: Pick<
EditorContentModel,
'isArchived' | 'onUnarchiveNote' | 'path' | 'isConflicted' | 'onKeepMine' | 'onKeepTheirs' | 'diffMode' | 'diffContent' | 'onToggleDiff'
>) {
return (
<>
{isArchived && onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => onUnarchiveNote(path)} />
)}
{isConflicted && (
<ConflictNoteBanner
onKeepMine={() => onKeepMine?.(path)}
onKeepTheirs={() => onKeepTheirs?.(path)}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
</>
)
}
function EditorCanvas({
showEditor,
cssVars,
activeTab,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
vaultPath,
onTitleChange,
editor,
entries,
onNavigateWikilink,
onEditorChange,
isDeletedPreview,
}: Pick<
EditorContentModel,
| 'showEditor'
| 'cssVars'
| 'activeTab'
| 'entryIcon'
| 'hasDisplayIcon'
| 'path'
| 'showTitleSection'
| 'titleSectionRef'
| 'vaultPath'
| 'onTitleChange'
| 'editor'
| 'entries'
| 'onNavigateWikilink'
| 'onEditorChange'
| 'isDeletedPreview'
>) {
if (!showEditor) return null
return (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<TitleSection
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
/>
<SingleEditorView
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}
onChange={onEditorChange}
vaultPath={vaultPath}
editable={!isDeletedPreview}
/>
</div>
</div>
)
}
export function EditorContentLayout(model: EditorContentModel) {
const {
activeTab,
@@ -237,16 +331,17 @@ export function EditorContentLayout(model: EditorContentModel) {
onUnarchiveNote: model.onUnarchiveNote,
}}
/>
{isArchived && onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => onUnarchiveNote(path)} />
)}
{isConflicted && (
<ConflictNoteBanner
onKeepMine={() => onKeepMine?.(path)}
onKeepTheirs={() => onKeepTheirs?.(path)}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<EditorChrome
isArchived={isArchived}
onUnarchiveNote={onUnarchiveNote}
path={path}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
diffMode={diffMode}
diffContent={diffContent}
onToggleDiff={onToggleDiff}
/>
<RawModeEditorSection
activeTab={activeTab}
entries={entries}
@@ -256,30 +351,23 @@ export function EditorContentLayout(model: EditorContentModel) {
rawLatestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
/>
{showEditor && (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<TitleSection
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
/>
<SingleEditorView
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}
onChange={onEditorChange}
vaultPath={vaultPath}
editable={!isDeletedPreview}
/>
</div>
</div>
)}
<EditorCanvas
showEditor={showEditor}
cssVars={cssVars}
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}
onEditorChange={onEditorChange}
isDeletedPreview={isDeletedPreview}
/>
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)

View File

@@ -288,9 +288,14 @@ export function useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, s
// --- useListPropertyPicker ---
function hasScalarListPropertyValue(value: string | null): boolean {
return value !== null && value.trim() !== ''
}
function collectAvailableProperties(entries: VaultEntry[]): string[] {
const keys = new Set<string>()
for (const entry of entries) {
if (hasScalarListPropertyValue(entry.status)) keys.add('status')
for (const key of Object.keys(entry.properties ?? {})) keys.add(key)
for (const key of Object.keys(entry.relationships ?? {})) keys.add(key)
}
@@ -324,6 +329,58 @@ export interface NoteListPropertyPicker {
triggerTitle: string
}
interface BuildInboxPropertyPickerParams {
isInboxView: boolean
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
inboxAvailableProperties: string[]
hasCustomInboxProperties: boolean
inboxNoteListProperties?: string[] | null
inboxDefaultDisplay: string[]
}
function buildInboxPropertyPicker({
isInboxView,
onUpdateInboxNoteListProperties,
inboxAvailableProperties,
hasCustomInboxProperties,
inboxNoteListProperties,
inboxDefaultDisplay,
}: BuildInboxPropertyPickerParams): NoteListPropertyPicker | null {
if (!isInboxView || !onUpdateInboxNoteListProperties) return null
return {
scope: 'inbox',
availableProperties: inboxAvailableProperties,
currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay,
onSave: onUpdateInboxNoteListProperties,
triggerTitle: 'Customize Inbox columns',
}
}
interface BuildTypePropertyPickerParams {
isSectionGroup: boolean
typeDocument: VaultEntry | null
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
typeAvailableProperties: string[]
}
function buildTypePropertyPicker({
isSectionGroup,
typeDocument,
onUpdateTypeSort,
typeAvailableProperties,
}: BuildTypePropertyPickerParams): NoteListPropertyPicker | null {
if (!isSectionGroup || !typeDocument || !onUpdateTypeSort) return null
return {
scope: 'type',
availableProperties: typeAvailableProperties,
currentDisplay: typeDocument.listPropertiesDisplay ?? [],
onSave: (value: string[] | null) => onUpdateTypeSort(typeDocument.path, '_list_properties_display', value),
triggerTitle: 'Customize columns',
}
}
interface UseListPropertyPickerParams {
entries: VaultEntry[]
selection: SidebarSelection
@@ -368,27 +425,19 @@ export function useListPropertyPicker({
const inboxDisplayOverride = isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
if (isInboxView && onUpdateInboxNoteListProperties) {
return {
scope: 'inbox',
availableProperties: inboxAvailableProperties,
currentDisplay: hasCustomInboxProperties ? inboxNoteListProperties ?? [] : inboxDefaultDisplay,
onSave: onUpdateInboxNoteListProperties,
triggerTitle: 'Customize Inbox columns',
}
}
if (isSectionGroup && typeDocument && onUpdateTypeSort) {
return {
scope: 'type',
availableProperties: typeAvailableProperties,
currentDisplay: typeDocument.listPropertiesDisplay ?? [],
onSave: (value: string[] | null) => onUpdateTypeSort(typeDocument.path, '_list_properties_display', value),
triggerTitle: 'Customize columns',
}
}
return null
return buildInboxPropertyPicker({
isInboxView,
onUpdateInboxNoteListProperties,
inboxAvailableProperties,
hasCustomInboxProperties,
inboxNoteListProperties,
inboxDefaultDisplay,
}) ?? buildTypePropertyPicker({
isSectionGroup,
typeDocument,
onUpdateTypeSort,
typeAvailableProperties,
})
}, [
hasCustomInboxProperties,
inboxAvailableProperties,