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:
Luca Rossi
2026-02-24 14:55:37 +01:00
committed by GitHub
7 changed files with 444 additions and 3 deletions

View 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": {}
}

View File

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

View File

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

View File

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

View File

@@ -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"
>
&#9998;
</button>
</span>
)
}
export function EditableValue({
value,

View File

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