Skip to content
Merged
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
22 changes: 22 additions & 0 deletions admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,25 @@ const SettingsPanel = () => {
The admin endpoints are not yet present in the OpenAPI spec — this client is
in place to support upcoming work (see issue #7638 follow-up). For now, it is
exercised only by the smoke test.

## Socket.io: `padLoad` query shape

The admin `/settings` namespace's `padLoad` event accepts a `PadSearchQuery`
defined in `src/node/types/PadSearchQuery.ts`:

| field | type | required | notes |
| ------------ | ----------------------------------------------------------------- | -------- | ----- |
| `pattern` | `string` | yes | Substring match on pad name. |
| `offset` | `number` | yes | Pagination start, in items. Clamped server-side. |
| `limit` | `number` | yes | Page size. Capped at 12. |
| `ascending` | `boolean` | yes | Sort direction. |
| `sortBy` | `"padName" \| "lastEdited" \| "userCount" \| "revisionNumber"` | yes | Column to sort by. |
| `filter` | `"all" \| "active" \| "recent" \| "empty" \| "stale"` *(opt.)* | no | Filter chip; defaults to `"all"`. Applied **before** pagination so `total` and the page slice both reflect the filtered universe. Older clients that omit the field get the unchanged `"all"` behaviour. |

Filter semantics — applied after pattern matching, before sort + slice:

- `active`: `userCount > 0`
- `recent`: edited within the last 7 days
- `empty`: `revisionNumber === 0`
- `stale`: not edited in the last 365 days
- `all` / missing: no further filtering
81 changes: 43 additions & 38 deletions admin/src/pages/PadPage.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
import {Trans, useTranslation} from "react-i18next";
import {useEffect, useMemo, useState} from "react";
import {useStore} from "../store/store.ts";
import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
import {useDebounce} from "../utils/useDebounce.ts";
import * as Dialog from "@radix-ui/react-dialog";
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
import {useForm} from "react-hook-form";
import type {TFunction} from "i18next";

type PadCreateProps = { padName: string }
type FilterId = 'all' | 'active' | 'recent' | 'empty' | 'stale'

const PAD_FILTER_IDS: FilterId[] = ['all', 'active', 'recent', 'empty', 'stale']

const isRecent = (ts: number) => (Date.now() - ts) < 86_400_000 * 7
const isStale = (ts: number) => (Date.now() - ts) > 86_400_000 * 365
const PAD_FILTER_IDS: PadFilter[] = ['all', 'active', 'recent', 'empty', 'stale']

function relativeTime(t: TFunction, ts: number): string {
const d = (Date.now() - ts) / 1000
Expand Down Expand Up @@ -58,12 +54,23 @@ function sanitizeLocale(lng?: string): string {
export const PadPage = () => {
const settingsSocket = useStore(state => state.settingsSocket)
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false,
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false, filter: 'all',
})
const {t, i18n} = useTranslation()
const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
const [searchTerm, setSearchTerm] = useState('')
const [filter, setFilter] = useState<FilterId>('all')
// Read filter off searchParams so chip changes round-trip through
// the server (`filter` is applied before pagination there). Clicking
// a chip used to filter only the current 12-row page slice.
//
// All searchParams mutations go through functional updaters because the
// debounced pattern handler captures a render-time snapshot and would
// otherwise revert a faster chip click / sort change made in between.
const filter: PadFilter = searchParams.filter ?? 'all'
const setFilter = (f: PadFilter) => {
setCurrentPage(0)
setSearchParams((sp) => ({...sp, filter: f, offset: 0}))
}
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
const [selected, setSelected] = useState<Set<string>>(new Set())
const pads = useStore(state => state.pads)
const [currentPage, setCurrentPage] = useState(0)
Expand All @@ -78,28 +85,23 @@ export const PadPage = () => {
[pads, searchParams.limit]
)

const filteredResults = useMemo(() => {
const r = pads?.results ?? []
if (filter === 'active') return r.filter(p => p.userCount > 0)
if (filter === 'recent') return r.filter(p => isRecent(p.lastEdited))
if (filter === 'empty') return r.filter(p => p.revisionNumber === 0)
if (filter === 'stale') return r.filter(p => isStale(p.lastEdited))
return r
}, [pads, filter])

const totalUsers = useMemo(() => (pads?.results ?? []).reduce((s, p) => s + p.userCount, 0), [pads])
const activeCount = useMemo(() => (pads?.results ?? []).filter(p => p.userCount > 0).length, [pads])
const emptyCount = useMemo(() => (pads?.results ?? []).filter(p => p.revisionNumber === 0).length, [pads])
// The server applies `filter` before paginating; the page payload is
// already the filtered slice. The stats cards still reflect the
// current page (pre-existing behaviour) — making them global would
// require a separate aggregate query.
const visibleResults = pads?.results ?? []
const totalUsers = useMemo(() => visibleResults.reduce((s, p) => s + p.userCount, 0), [pads])
const activeCount = useMemo(() => visibleResults.filter(p => p.userCount > 0).length, [pads])
const emptyCount = useMemo(() => visibleResults.filter(p => p.revisionNumber === 0).length, [pads])
const lastActivity = useMemo(() => {
const r = pads?.results ?? []
return r.length ? Math.max(...r.map(p => p.lastEdited)) : null
return visibleResults.length ? Math.max(...visibleResults.map(p => p.lastEdited)) : null
}, [pads])

const allSelected = filteredResults.length > 0 && filteredResults.every(p => selected.has(p.padName))
const allSelected = visibleResults.length > 0 && visibleResults.every(p => selected.has(p.padName))
const toggleAll = () => {
const s = new Set(selected)
if (allSelected) filteredResults.forEach(p => s.delete(p.padName))
else filteredResults.forEach(p => s.add(p.padName))
if (allSelected) visibleResults.forEach(p => s.delete(p.padName))
else visibleResults.forEach(p => s.add(p.padName))
setSelected(s)
}
const toggleOne = (name: string) => {
Expand All @@ -109,7 +111,10 @@ export const PadPage = () => {
}

useDebounce(() => {
setSearchParams({...searchParams, pattern: searchTerm})
// Functional updater so this delayed callback can't clobber a faster
// user interaction (e.g. clicking a filter chip mid-typing).
setSearchParams((sp) => ({...sp, pattern: searchTerm, offset: 0}))
setCurrentPage(0)
}, 500, [searchTerm])

useEffect(() => {
Expand Down Expand Up @@ -250,7 +255,7 @@ export const PadPage = () => {
<section className="pm-section">
<div className="pm-section-header">
<h2><Trans i18nKey="admin_pads.all_pads"/></h2>
<span className="pm-count-badge">{filteredResults.length}</span>
<span className="pm-count-badge">{visibleResults.length}</span>
<div className="pm-spacer"/>
<div className="pm-toolbar">
<div className="pm-search">
Expand All @@ -268,12 +273,12 @@ export const PadPage = () => {
<select
className="pm-select"
value={searchParams.sortBy}
onChange={e => setSearchParams({
...searchParams,
onChange={e => setSearchParams((sp) => ({
...sp,
sortBy: e.target.value,
// Keep current direction when only the column changes; the
// ↑/↓ button below is the sole control for direction.
})}
}))}
>
<option value="lastEdited">{t('ep_admin_pads:ep_adminpads2_last-edited')}</option>
<option value="padName">{t('admin_pads.sort.name')}</option>
Expand All @@ -282,10 +287,10 @@ export const PadPage = () => {
</select>
<button
className="pm-sort-dir"
onClick={() => setSearchParams({
...searchParams,
ascending: !searchParams.ascending,
})}
onClick={() => setSearchParams((sp) => ({
...sp,
ascending: !sp.ascending,
}))}
title={t(searchParams.ascending
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
Expand Down Expand Up @@ -330,7 +335,7 @@ export const PadPage = () => {
</div>
)}

{filteredResults.length > 0 ? (
{visibleResults.length > 0 ? (
<div className="pm-table-wrap">
<table>
<thead>
Expand All @@ -348,7 +353,7 @@ export const PadPage = () => {
</tr>
</thead>
<tbody>
{filteredResults.map(pad => {
{visibleResults.map(pad => {
const isEmpty = pad.revisionNumber === 0
const isSel = selected.has(pad.padName)
return (
Expand Down Expand Up @@ -432,7 +437,7 @@ export const PadPage = () => {
onClick={() => {
const p = currentPage - 1
setCurrentPage(p)
setSearchParams({...searchParams, offset: p * searchParams.limit})
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
}}
>
<ChevronLeft size={14}/> <Trans i18nKey="admin_pads.pagination.previous"/>
Expand All @@ -444,7 +449,7 @@ export const PadPage = () => {
onClick={() => {
const p = currentPage + 1
setCurrentPage(p)
setSearchParams({...searchParams, offset: p * searchParams.limit})
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
}}
>
<Trans i18nKey="admin_pads.pagination.next"/> <ChevronRight size={14}/>
Expand Down
3 changes: 3 additions & 0 deletions admin/src/utils/PadSearch.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export type PadFilter = 'all' | 'active' | 'recent' | 'empty' | 'stale';

export type PadSearchQuery = {
pattern: string;
offset: number;
limit: number;
ascending: boolean;
sortBy: string;
filter?: PadFilter;
}


Expand Down
Loading
Loading