Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 7 additions & 27 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,47 +6,27 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test": "vitest run --coverage",
"test:watch": "vitest",
"lint": "eslint src --ext .ts,.tsx"
},
"dependencies": {
"@stellar/stellar-sdk": "^12.3.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.4.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^16.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"jsdom": "^24.1.1",
"typescript": "^5.5.2",
"vite": "^5.3.1",
"vitest": "^1.6.0"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"next": "14.2.14",
"chart.js": "^4.4.1",
"react-chartjs-2": "^5.2.0",
"date-fns": "^3.6.0"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "14.2.14"
"vitest": "^1.6.0",
"@types/jest": "^29.5.4"
}
}
108 changes: 108 additions & 0 deletions frontend/src/components/SavedFiltersPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React, { useState } from 'react'
import useSavedFilters, { NotificationFilter } from '../stores/useSavedFilters'

type Props = {
currentFilter: Record<string, any>
onApply: (filter: NotificationFilter) => void
}

// A compact component to let users save, delete, rename and select saved filters.
// It uses the `useSavedFilters` store so multiple components stay in sync.
export const SavedFiltersPanel: React.FC<Props> = ({ currentFilter, onApply }) => {
const filters = useSavedFilters((s) => s.filters)
const saveFilter = useSavedFilters((s) => s.saveFilter)
const deleteFilter = useSavedFilters((s) => s.deleteFilter)
const renameFilter = useSavedFilters((s) => s.renameFilter)

const [name, setName] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')

const handleSave = () => {
const saved = saveFilter({ name: name || `Filter ${new Date().toLocaleString()}`, query: currentFilter })
setName('')
// apply newly saved filter immediately
onApply(saved)
}

return (
<div className="p-4 border rounded bg-white shadow-sm">
<h3 className="font-semibold mb-2">Saved Filters</h3>

<div className="flex gap-2 mb-3">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Save current filter as..."
className="flex-1 border px-2 py-1 rounded"
data-testid="save-input"
/>
<button onClick={handleSave} className="px-3 py-1 bg-blue-600 text-white rounded" data-testid="save-button">
Save
</button>
</div>

<ul className="space-y-2" data-testid="filters-list">
{filters.length === 0 && <li className="text-sm text-gray-500">No saved filters</li>}
{filters.map((f) => (
<li key={f.id} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => onApply(f)}
className="text-left hover:underline"
data-testid={`apply-${f.id}`}
>
{f.name}
</button>
<span className="text-xs text-gray-400">{new Date(f.createdAt).toLocaleString()}</span>
</div>
<div className="flex items-center gap-2">
{editingId === f.id ? (
<>
<input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="border px-2 py-1 rounded"
data-testid={`rename-input-${f.id}`}
/>
<button
onClick={() => {
renameFilter(f.id, editingName)
setEditingId(null)
}}
className="px-2 py-1 bg-green-600 text-white rounded"
data-testid={`rename-save-${f.id}`}
>
OK
</button>
</>
) : (
<>
<button
onClick={() => {
setEditingId(f.id)
setEditingName(f.name)
}}
className="px-2 py-1 bg-yellow-300 rounded"
data-testid={`rename-${f.id}`}
>
Rename
</button>
<button
onClick={() => deleteFilter(f.id)}
className="px-2 py-1 bg-red-600 text-white rounded"
data-testid={`delete-${f.id}`}
>
Delete
</button>
</>
)}
</div>
</li>
))}
</ul>
</div>
)
}

export default SavedFiltersPanel
51 changes: 51 additions & 0 deletions frontend/src/components/__tests__/SavedFiltersPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SavedFiltersPanel from '../SavedFiltersPanel'
import useSavedFilters from '../../stores/useSavedFilters'

describe('SavedFiltersPanel', () => {
beforeEach(() => {
localStorage.removeItem('notify-chain-saved-filters')
const { setState } = useSavedFilters as any
if (setState) setState({ filters: [] })
})

it('saves a filter and applies it', async () => {
const mockApply = vi.fn()
render(<SavedFiltersPanel currentFilter={{ q: 'x' }} onApply={mockApply} />)

const input = screen.getByTestId('save-input') as HTMLInputElement
const saveBtn = screen.getByTestId('save-button')

fireEvent.change(input, { target: { value: 'My Filter' } })
fireEvent.click(saveBtn)

await waitFor(() => expect(mockApply).toHaveBeenCalled())

const list = screen.getByTestId('filters-list')
expect(list.textContent).toContain('My Filter')
})

it('renames and deletes a filter', async () => {
const saved = useSavedFilters.getState().saveFilter({ name: 'ToRename', query: {} })
const mockApply = vi.fn()
render(<SavedFiltersPanel currentFilter={{}} onApply={mockApply} />)

const renameBtn = screen.getByTestId(`rename-${saved.id}`)
fireEvent.click(renameBtn)

const renameInput = screen.getByTestId(`rename-input-${saved.id}`) as HTMLInputElement
fireEvent.change(renameInput, { target: { value: 'Renamed' } })

const saveRename = screen.getByTestId(`rename-save-${saved.id}`)
fireEvent.click(saveRename)

await waitFor(() => expect(screen.getByTestId('filters-list').textContent).toContain('Renamed'))

const del = screen.getByTestId(`delete-${saved.id}`)
fireEvent.click(del)

await waitFor(() => expect(screen.getByTestId('filters-list').textContent).not.toContain('Renamed'))
})
})
32 changes: 32 additions & 0 deletions frontend/src/stores/__tests__/useSavedFilters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest'
import useSavedFilters from '../useSavedFilters'

describe('useSavedFilters store', () => {
beforeEach(() => {
// reset the store state by clearing localStorage key used by the persist middleware
localStorage.removeItem('notify-chain-saved-filters')
const { setState } = useSavedFilters as any
if (setState) setState({ filters: [] })
})

it('saves a filter and retrieves it', () => {
const filter = useSavedFilters.getState().saveFilter({ name: 'Test', query: { a: 1 } })
expect(filter).toHaveProperty('id')
const fetched = useSavedFilters.getState().getFilter(filter.id)
expect(fetched).toBeDefined()
expect(fetched?.name).toBe('Test')
})

it('renames a filter', () => {
const f = useSavedFilters.getState().saveFilter({ name: 'Old', query: {} })
useSavedFilters.getState().renameFilter(f.id, 'New')
const updated = useSavedFilters.getState().getFilter(f.id)
expect(updated?.name).toBe('New')
})

it('deletes a filter', () => {
const f = useSavedFilters.getState().saveFilter({ name: 'ToDelete', query: {} })
useSavedFilters.getState().deleteFilter(f.id)
expect(useSavedFilters.getState().getFilter(f.id)).toBeUndefined()
})
})
65 changes: 65 additions & 0 deletions frontend/src/stores/useSavedFilters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import create from 'zustand'
import { persist } from 'zustand/middleware'

export type NotificationFilter = {
id: string
name: string
query: Record<string, any>
createdAt: string
updatedAt?: string
}

type State = {
filters: NotificationFilter[]
saveFilter: (filter: Omit<NotificationFilter, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => NotificationFilter
deleteFilter: (id: string) => void
renameFilter: (id: string, newName: string) => void
getFilter: (id: string) => NotificationFilter | undefined
}

// Use Zustand persist middleware to persist to localStorage. This ensures filters
// survive browser sessions. We keep updates optimistic by returning the saved
// filter immediately.
export const useSavedFilters = create<State>(
persist(
(set, get) => ({
filters: [],

saveFilter: (incoming) => {
const now = new Date().toISOString()
const id = incoming.id || `f_${Math.random().toString(36).slice(2, 9)}`
const filter: NotificationFilter = {
id,
name: incoming.name,
query: incoming.query,
createdAt: now,
updatedAt: now,
}
// optimistic update
set((s) => ({ filters: [filter, ...s.filters.filter((f) => f.id !== id)] }))
return filter
},

deleteFilter: (id) => {
set((s) => ({ filters: s.filters.filter((f) => f.id !== id) }))
},

renameFilter: (id, newName) => {
set((s) => ({
filters: s.filters.map((f) => (f.id === id ? { ...f, name: newName, updatedAt: new Date().toISOString() } : f)),
}))
},

getFilter: (id) => {
return get().filters.find((f) => f.id === id)
},
}),
{
name: 'notify-chain-saved-filters',
// selective serialization to avoid issues with circular refs
serialize: (state) => JSON.stringify(state),
}
)
)

export default useSavedFilters
40 changes: 40 additions & 0 deletions tools/filters-cli/__tests__/cli.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')
const os = require('os')

function run(cmd, env = {}) {
return execSync(cmd, { encoding: 'utf8', env: { ...process.env, ...env } }).trim()
}

const CLI = path.resolve(__dirname, '..', 'index.js')

;(function () {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'notify-cli-'))
const env = { HOME: tmpHome }

// list should show no saved filters
const out1 = run(`node ${CLI} list`, env)
if (!out1.includes('No saved filters')) throw new Error('Expected no saved filters')

// save a filter
const query = JSON.stringify({ a: 1 })
const saveOut = run(`node ${CLI} save -n TestFilter -q '${query}'`, env)
if (!saveOut.startsWith('Saved')) throw new Error('Save failed')
const id = saveOut.split(' ')[1].trim()

// list should show the saved filter id
const listOut = run(`node ${CLI} list`, env)
if (!listOut.includes(id)) throw new Error('List did not include saved id')

// get should return JSON with the name
const getOut = run(`node ${CLI} get ${id}`, env)
const obj = JSON.parse(getOut)
if (obj.name !== 'TestFilter') throw new Error('Get returned wrong object')

// delete
const delOut = run(`node ${CLI} delete ${id}`, env)
if (!delOut.includes('Deleted')) throw new Error('Delete failed')

console.log('OK')
})()
Loading
Loading