feat: add mappings page with inline CRUD table
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,288 @@
|
||||
import { useState } from 'react'
|
||||
import { Pencil, Trash2, Check, X, Plus } from 'lucide-react'
|
||||
import { useMappings, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../api/mappings'
|
||||
import { CATEGORY_COLORS } from '../lib/constants'
|
||||
import type { AppMapping } from '../types'
|
||||
|
||||
const MATCH_TYPES = ['ProcessName', 'TitleContains', 'UrlContains'] as const
|
||||
|
||||
const MATCH_TYPE_COLORS: Record<string, string> = {
|
||||
ProcessName: '#6366f1',
|
||||
TitleContains: '#06b6d4',
|
||||
UrlContains: '#f97316',
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
pattern: string
|
||||
matchType: string
|
||||
category: string
|
||||
friendlyName: string
|
||||
}
|
||||
|
||||
const emptyForm: FormData = { pattern: '', matchType: 'ProcessName', category: '', friendlyName: '' }
|
||||
|
||||
function formFromMapping(m: AppMapping): FormData {
|
||||
return {
|
||||
pattern: m.pattern,
|
||||
matchType: m.matchType,
|
||||
category: m.category,
|
||||
friendlyName: m.friendlyName ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export default function Mappings() {
|
||||
const { data: mappings, isLoading } = useMappings()
|
||||
const createMapping = useCreateMapping()
|
||||
const updateMapping = useUpdateMapping()
|
||||
const deleteMapping = useDeleteMapping()
|
||||
|
||||
const [addingNew, setAddingNew] = useState(false)
|
||||
const [newForm, setNewForm] = useState<FormData>(emptyForm)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editForm, setEditForm] = useState<FormData>(emptyForm)
|
||||
|
||||
function handleAddSave() {
|
||||
if (!newForm.pattern.trim() || !newForm.category.trim()) return
|
||||
createMapping.mutate(
|
||||
{
|
||||
pattern: newForm.pattern.trim(),
|
||||
matchType: newForm.matchType,
|
||||
category: newForm.category.trim(),
|
||||
friendlyName: newForm.friendlyName.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setAddingNew(false)
|
||||
setNewForm(emptyForm)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function handleAddCancel() {
|
||||
setAddingNew(false)
|
||||
setNewForm(emptyForm)
|
||||
}
|
||||
|
||||
function handleEditStart(mapping: AppMapping) {
|
||||
setEditingId(mapping.id)
|
||||
setEditForm(formFromMapping(mapping))
|
||||
// Cancel any add-new row when starting an edit
|
||||
setAddingNew(false)
|
||||
}
|
||||
|
||||
function handleEditSave() {
|
||||
if (editingId === null) return
|
||||
if (!editForm.pattern.trim() || !editForm.category.trim()) return
|
||||
updateMapping.mutate(
|
||||
{
|
||||
id: editingId,
|
||||
pattern: editForm.pattern.trim(),
|
||||
matchType: editForm.matchType,
|
||||
category: editForm.category.trim(),
|
||||
friendlyName: editForm.friendlyName.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditingId(null)
|
||||
setEditForm(emptyForm)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function handleEditCancel() {
|
||||
setEditingId(null)
|
||||
setEditForm(emptyForm)
|
||||
}
|
||||
|
||||
function handleDelete(id: number) {
|
||||
if (!window.confirm('Delete this mapping rule?')) return
|
||||
deleteMapping.mutate(id)
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'bg-[#0f1117] text-white text-sm rounded border border-white/10 px-2 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors w-full'
|
||||
const selectClass =
|
||||
'bg-[#0f1117] text-white text-sm rounded border border-white/10 px-2 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer w-full'
|
||||
|
||||
function renderFormRow(form: FormData, setForm: (f: FormData) => void, onSave: () => void, onCancel: () => void, isSaving: boolean) {
|
||||
return (
|
||||
<tr className="bg-[#1a1d27]">
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pattern..."
|
||||
value={form.pattern}
|
||||
onChange={(e) => setForm({ ...form, pattern: e.target.value })}
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={form.matchType}
|
||||
onChange={(e) => setForm({ ...form, matchType: e.target.value })}
|
||||
className={selectClass}
|
||||
>
|
||||
{MATCH_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Category..."
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Friendly name (optional)"
|
||||
value={form.friendlyName}
|
||||
onChange={(e) => setForm({ ...form, friendlyName: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-400/10 transition-colors disabled:opacity-50"
|
||||
title="Save"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 rounded text-[#94a3b8] hover:bg-white/5 transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">Mappings</h1>
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-white">App Mappings</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddingNew(true)
|
||||
setEditingId(null)
|
||||
setNewForm(emptyForm)
|
||||
}}
|
||||
disabled={addingNew}
|
||||
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-[#94a3b8] text-sm py-12 text-center">Loading mappings...</div>
|
||||
) : !mappings?.length && !addingNew ? (
|
||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-12 text-center">
|
||||
<p className="text-[#94a3b8] text-sm mb-3">No mappings configured</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddingNew(true)
|
||||
setNewForm(emptyForm)
|
||||
}}
|
||||
className="text-indigo-400 hover:text-indigo-300 text-sm font-medium transition-colors"
|
||||
>
|
||||
+ Add your first mapping rule
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#161922] rounded-xl border border-white/5 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#161922] border-b border-white/5">
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Pattern</th>
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Match Type</th>
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Category</th>
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium">Friendly Name</th>
|
||||
<th className="text-left px-4 py-3 text-[#94a3b8] font-medium w-24">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{/* Add-new row */}
|
||||
{addingNew &&
|
||||
renderFormRow(newForm, setNewForm, handleAddSave, handleAddCancel, createMapping.isPending)}
|
||||
|
||||
{/* Data rows */}
|
||||
{mappings?.map((m) =>
|
||||
editingId === m.id ? (
|
||||
renderFormRow(editForm, setEditForm, handleEditSave, handleEditCancel, updateMapping.isPending)
|
||||
) : (
|
||||
<tr
|
||||
key={m.id}
|
||||
className="bg-[#1a1d27] hover:bg-[#1e2230] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-white font-mono text-xs">{m.pattern}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-block text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${MATCH_TYPE_COLORS[m.matchType] ?? '#64748b'}20`,
|
||||
color: MATCH_TYPE_COLORS[m.matchType] ?? '#64748b',
|
||||
}}
|
||||
>
|
||||
{m.matchType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-white text-xs">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: CATEGORY_COLORS[m.category] ?? '#64748b' }}
|
||||
/>
|
||||
{m.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">
|
||||
{m.friendlyName ?? '\u2014'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleEditStart(m)}
|
||||
className="p-1.5 rounded text-[#94a3b8] hover:text-white hover:bg-white/5 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(m.id)}
|
||||
className="p-1.5 rounded text-[#94a3b8] hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user