diff --git a/frontend/package.json b/frontend/package.json index c24da0c..8966034 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,18 +6,19 @@ "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", @@ -25,28 +26,7 @@ "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" } } diff --git a/frontend/src/components/SavedFiltersPanel.tsx b/frontend/src/components/SavedFiltersPanel.tsx new file mode 100644 index 0000000..657bb9e --- /dev/null +++ b/frontend/src/components/SavedFiltersPanel.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react' +import useSavedFilters, { NotificationFilter } from '../stores/useSavedFilters' + +type Props = { + currentFilter: Record + 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 = ({ 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(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 ( +
+

Saved Filters

+ +
+ setName(e.target.value)} + placeholder="Save current filter as..." + className="flex-1 border px-2 py-1 rounded" + data-testid="save-input" + /> + +
+ +
    + {filters.length === 0 &&
  • No saved filters
  • } + {filters.map((f) => ( +
  • +
    + + {new Date(f.createdAt).toLocaleString()} +
    +
    + {editingId === f.id ? ( + <> + setEditingName(e.target.value)} + className="border px-2 py-1 rounded" + data-testid={`rename-input-${f.id}`} + /> + + + ) : ( + <> + + + + )} +
    +
  • + ))} +
+
+ ) +} + +export default SavedFiltersPanel diff --git a/frontend/src/components/__tests__/SavedFiltersPanel.test.tsx b/frontend/src/components/__tests__/SavedFiltersPanel.test.tsx new file mode 100644 index 0000000..4d89dea --- /dev/null +++ b/frontend/src/components/__tests__/SavedFiltersPanel.test.tsx @@ -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() + + 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() + + 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')) + }) +}) diff --git a/frontend/src/stores/__tests__/useSavedFilters.test.ts b/frontend/src/stores/__tests__/useSavedFilters.test.ts new file mode 100644 index 0000000..72f718f --- /dev/null +++ b/frontend/src/stores/__tests__/useSavedFilters.test.ts @@ -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() + }) +}) diff --git a/frontend/src/stores/useSavedFilters.ts b/frontend/src/stores/useSavedFilters.ts new file mode 100644 index 0000000..6718cd4 --- /dev/null +++ b/frontend/src/stores/useSavedFilters.ts @@ -0,0 +1,65 @@ +import create from 'zustand' +import { persist } from 'zustand/middleware' + +export type NotificationFilter = { + id: string + name: string + query: Record + createdAt: string + updatedAt?: string +} + +type State = { + filters: NotificationFilter[] + saveFilter: (filter: Omit & { 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( + 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 diff --git a/tools/filters-cli/__tests__/cli.test.js b/tools/filters-cli/__tests__/cli.test.js new file mode 100644 index 0000000..2cf8482 --- /dev/null +++ b/tools/filters-cli/__tests__/cli.test.js @@ -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') +})() diff --git a/tools/filters-cli/index.js b/tools/filters-cli/index.js new file mode 100644 index 0000000..3b5413b --- /dev/null +++ b/tools/filters-cli/index.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +const { program } = require('commander') +const fs = require('fs') +const path = require('path') + +const CONFIG_DIR = path.join(require('os').homedir(), '.notify-chain') +const FILE = path.join(CONFIG_DIR, 'filters.json') + +function ensureDir() { + if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true }) +} + +function readFilters() { + try { + if (!fs.existsSync(FILE)) return [] + const raw = fs.readFileSync(FILE, 'utf8') + return JSON.parse(raw || '[]') + } catch (e) { + console.error('Failed to read filters:', e.message) + return [] + } +} + +function writeFilters(filters) { + ensureDir() + fs.writeFileSync(FILE, JSON.stringify(filters, null, 2), 'utf8') +} + +program.name('notify-filters').description('Manage saved notification filters').version('0.1.0') + +program + .command('list') + .description('List saved filters') + .action(() => { + const filters = readFilters() + if (filters.length === 0) { + console.log('No saved filters') + return + } + filters.forEach((f) => console.log(`${f.id}\t${f.name}`)) + }) + +program + .command('save') + .description('Save a filter (pass JSON query via --query)') + .requiredOption('-n, --name ') + .requiredOption('-q, --query ') + .action((opts) => { + const filters = readFilters() + const now = new Date().toISOString() + const id = `f_${Math.random().toString(36).slice(2, 9)}` + let query = {} + try { + query = JSON.parse(opts.query) + } catch (e) { + console.error('Invalid JSON for --query') + process.exit(2) + } + const filter = { id, name: opts.name, query, createdAt: now, updatedAt: now } + filters.unshift(filter) + writeFilters(filters) + console.log('Saved', id) + }) + +program + .command('delete') + .description('Delete a saved filter by id') + .argument('') + .action((id) => { + const filters = readFilters() + const next = filters.filter((f) => f.id !== id) + writeFilters(next) + console.log('Deleted', id) + }) + +program + .command('get') + .description('Get a saved filter as JSON') + .argument('') + .action((id) => { + const filters = readFilters() + const f = filters.find((x) => x.id === id) + if (!f) { + console.error('Not found') + process.exit(1) + } + console.log(JSON.stringify(f, null, 2)) + }) + +program.parse(process.argv) diff --git a/tools/filters-cli/package.json b/tools/filters-cli/package.json new file mode 100644 index 0000000..99f7f64 --- /dev/null +++ b/tools/filters-cli/package.json @@ -0,0 +1,14 @@ +{ + "name": "notify-chain-filters-cli", + "version": "0.1.0", + "private": true, + "bin": { + "notify-filters": "index.js" + }, + "scripts": { + "test": "node ./__tests__/cli.test.js" + }, + "dependencies": { + "commander": "^11.0.0" + } +}