Merge pull request #44 from refactoringhq/task/url-property-click
feat: URL properties open in browser on click, underline on hover
This commit is contained in:
173
design/url-property-click.pen
Normal file
173
design/url-property-click.pen
Normal file
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "urlDefault",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "URL Property — default state (no hover)",
|
||||
"width": 320,
|
||||
"height": 40,
|
||||
"fill": "#ffffff",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [6, 6],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "urlDefaultLabel",
|
||||
"name": "label",
|
||||
"content": "URL",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"textTransform": "uppercase",
|
||||
"fill": "#6b7280"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "urlDefaultValueWrap",
|
||||
"name": "value-wrapper",
|
||||
"width": "fill_container",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "end",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "urlDefaultValue",
|
||||
"name": "url-value",
|
||||
"content": "https://example.com/article",
|
||||
"fontSize": 13,
|
||||
"fill": "#374151",
|
||||
"textAlign": "right"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "urlDefaultEditIcon",
|
||||
"name": "edit-icon",
|
||||
"content": "pencil",
|
||||
"fontSize": 12,
|
||||
"fill": "#9ca3af",
|
||||
"opacity": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "urlHover",
|
||||
"x": 0,
|
||||
"y": 60,
|
||||
"name": "URL Property — hover state (underline + pointer cursor)",
|
||||
"width": 320,
|
||||
"height": 40,
|
||||
"fill": "#f9fafb",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [6, 6],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "urlHoverLabel",
|
||||
"name": "label",
|
||||
"content": "URL",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"textTransform": "uppercase",
|
||||
"fill": "#6b7280"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "urlHoverValueWrap",
|
||||
"name": "value-wrapper",
|
||||
"width": "fill_container",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "end",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "urlHoverValue",
|
||||
"name": "url-value-hovered",
|
||||
"content": "https://example.com/article",
|
||||
"fontSize": 13,
|
||||
"fill": "#2563eb",
|
||||
"textDecoration": "underline",
|
||||
"textAlign": "right"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "urlHoverEditIcon",
|
||||
"name": "edit-icon-visible",
|
||||
"content": "pencil",
|
||||
"fontSize": 12,
|
||||
"fill": "#6b7280",
|
||||
"opacity": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "urlEdit",
|
||||
"x": 0,
|
||||
"y": 120,
|
||||
"name": "URL Property — edit mode (double-click or edit icon)",
|
||||
"width": 320,
|
||||
"height": 40,
|
||||
"fill": "#ffffff",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [6, 6],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "urlEditLabel",
|
||||
"name": "label",
|
||||
"content": "URL",
|
||||
"fontSize": 10,
|
||||
"fontWeight": 600,
|
||||
"letterSpacing": 1.2,
|
||||
"textTransform": "uppercase",
|
||||
"fill": "#6b7280"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "urlEditInputWrap",
|
||||
"name": "input-wrapper",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"fill": "#f3f4f6",
|
||||
"cornerRadius": 4,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#6366f1"
|
||||
},
|
||||
"padding": [4, 8],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "urlEditInput",
|
||||
"name": "edit-input",
|
||||
"content": "https://example.com/article",
|
||||
"fontSize": 13,
|
||||
"fill": "#111827"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
@@ -502,4 +502,68 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('1.0 MB')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL property rendering', () => {
|
||||
it('renders URL values with link styling instead of plain EditableValue', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ url: 'https://example.com' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('url-link')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com')
|
||||
})
|
||||
|
||||
it('renders bare domain values as URL links', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ website: 'example.com' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('url-link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render plain text as URL link', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ cadence: 'Weekly' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('url-link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows edit button on URL property', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ url: 'https://example.com' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('url-edit-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enters edit mode when edit button clicked on URL property', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ url: 'https://example.com' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('url-edit-btn'))
|
||||
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useMemo, useState, useCallback } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import { EditableValue, TagPillList } from './EditableValue'
|
||||
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
|
||||
import { isUrlValue } from '../utils/url'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
@@ -191,6 +192,7 @@ function PropertyValueCell({ propKey, value, isEditing, onStartEdit, onSave, onS
|
||||
if (Array.isArray(value)) return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
|
||||
if (isDateKey(propKey)) return <EditableValue {...editProps} />
|
||||
if (typeof value === 'boolean') return <BooleanToggle value={value} onToggle={() => onUpdate?.(propKey, !value)} />
|
||||
if (typeof value === 'string' && isUrlValue(value)) return <UrlValue {...editProps} />
|
||||
return <EditableValue {...editProps} />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { EditableValue, TagPillList } from './EditableValue'
|
||||
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
|
||||
import { isUrlValue, normalizeUrl } from '../utils/url'
|
||||
|
||||
describe('EditableValue', () => {
|
||||
const onSave = vi.fn()
|
||||
@@ -161,3 +162,120 @@ describe('TagPillList', () => {
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUrlValue', () => {
|
||||
it('detects http URLs', () => {
|
||||
expect(isUrlValue('http://example.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects https URLs', () => {
|
||||
expect(isUrlValue('https://example.com/path?q=1')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects bare domain names', () => {
|
||||
expect(isUrlValue('example.com')).toBe(true)
|
||||
expect(isUrlValue('docs.google.com/spreadsheets')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects plain text', () => {
|
||||
expect(isUrlValue('just some text')).toBe(false)
|
||||
expect(isUrlValue('Weekly')).toBe(false)
|
||||
expect(isUrlValue('Luca Rossi')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty strings', () => {
|
||||
expect(isUrlValue('')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects single words without TLD', () => {
|
||||
expect(isUrlValue('localhost')).toBe(false)
|
||||
expect(isUrlValue('active')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeUrl', () => {
|
||||
it('returns http/https URLs unchanged', () => {
|
||||
expect(normalizeUrl('https://example.com')).toBe('https://example.com')
|
||||
expect(normalizeUrl('http://example.com')).toBe('http://example.com')
|
||||
})
|
||||
|
||||
it('prepends https:// to bare domains', () => {
|
||||
expect(normalizeUrl('example.com')).toBe('https://example.com')
|
||||
expect(normalizeUrl('docs.google.com/sheet')).toBe('https://docs.google.com/sheet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UrlValue', () => {
|
||||
const onSave = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
const onStartEdit = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('displays URL text in view mode', () => {
|
||||
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com')
|
||||
})
|
||||
|
||||
it('opens URL in new tab on click', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
fireEvent.click(screen.getByTestId('url-link'))
|
||||
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('normalizes bare domain before opening', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
render(<UrlValue value="example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
fireEvent.click(screen.getByTestId('url-link'))
|
||||
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not open malformed URL', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
render(<UrlValue value="://broken" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
fireEvent.click(screen.getByTestId('url-link'))
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows edit button that calls onStartEdit', () => {
|
||||
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
|
||||
fireEvent.click(screen.getByTestId('url-edit-btn'))
|
||||
expect(onStartEdit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows input in editing mode', () => {
|
||||
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={true} onStartEdit={onStartEdit} />)
|
||||
const input = screen.getByDisplayValue('https://example.com')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input.tagName).toBe('INPUT')
|
||||
})
|
||||
|
||||
it('calls onSave when Enter is pressed in edit mode', () => {
|
||||
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={true} onStartEdit={onStartEdit} />)
|
||||
const input = screen.getByDisplayValue('https://example.com')
|
||||
fireEvent.change(input, { target: { value: 'https://new.com' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSave).toHaveBeenCalledWith('https://new.com')
|
||||
})
|
||||
|
||||
it('calls onCancel when Escape is pressed in edit mode', () => {
|
||||
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={true} onStartEdit={onStartEdit} />)
|
||||
const input = screen.getByDisplayValue('https://example.com')
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSave on blur in edit mode', () => {
|
||||
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={true} onStartEdit={onStartEdit} />)
|
||||
const input = screen.getByDisplayValue('https://example.com')
|
||||
fireEvent.change(input, { target: { value: 'https://updated.com' } })
|
||||
fireEvent.blur(input)
|
||||
expect(onSave).toHaveBeenCalledWith('https://updated.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,75 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { normalizeUrl } from '../utils/url'
|
||||
|
||||
export function UrlValue({
|
||||
value,
|
||||
onSave,
|
||||
onCancel,
|
||||
isEditing,
|
||||
onStartEdit,
|
||||
}: {
|
||||
value: string
|
||||
onSave: (newValue: string) => void
|
||||
onCancel: () => void
|
||||
isEditing: boolean
|
||||
onStartEdit: () => void
|
||||
}) {
|
||||
const [editValue, setEditValue] = useState(value)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSave(editValue)
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditValue(value)
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
const normalized = normalizeUrl(value)
|
||||
try {
|
||||
new URL(normalized)
|
||||
window.open(normalized, '_blank')
|
||||
} catch {
|
||||
// malformed URL — do nothing
|
||||
}
|
||||
}, [value])
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSave(editValue)}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="group/url inline-flex min-w-0 items-center gap-1">
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-secondary-foreground transition-colors hover:text-primary hover:underline"
|
||||
onClick={handleOpen}
|
||||
title={value}
|
||||
data-testid="url-link"
|
||||
>
|
||||
{value || '\u2014'}
|
||||
</span>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-0 text-[12px] leading-none text-muted-foreground opacity-0 transition-all hover:text-foreground group-hover/url:opacity-100"
|
||||
onClick={onStartEdit}
|
||||
title="Edit URL"
|
||||
data-testid="url-edit-btn"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditableValue({
|
||||
value,
|
||||
|
||||
@@ -29,6 +29,7 @@ status: Active
|
||||
owner: Luca Rossi
|
||||
tags: [Tauri, React, TypeScript, CodeMirror]
|
||||
tools: [Vite, Vitest, Playwright]
|
||||
url: https://github.com/lucaong/laputa-app
|
||||
belongs_to:
|
||||
- "[[quarter/q1-2026]]"
|
||||
related_to:
|
||||
|
||||
12
src/utils/url.ts
Normal file
12
src/utils/url.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
const URL_PATTERN = /^https?:\/\//i
|
||||
const BARE_DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+([/?#]|$)/i
|
||||
|
||||
export function isUrlValue(value: string): boolean {
|
||||
if (!value) return false
|
||||
return URL_PATTERN.test(value) || BARE_DOMAIN_PATTERN.test(value)
|
||||
}
|
||||
|
||||
export function normalizeUrl(url: string): string {
|
||||
if (URL_PATTERN.test(url)) return url
|
||||
return `https://${url}`
|
||||
}
|
||||
Reference in New Issue
Block a user