import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { WpPost } from '../../../types'; import { getWpApi } from '../../../api'; import { ApiErrorPanel } from '../ApiErrorPanel'; import type { Column } from '../DataTable'; import { DataTable } from '../DataTable'; import { DataTableSkeleton } from '../Skeleton'; import { useSikshyaDialog } from '../SikshyaDialogContext'; import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; import { useWpPostCollection, type WpPostCollectionQuery } from '../../../hooks/useWpPostCollection'; import { useWpPostStatusCounts, type WpPostCollectionStatus, } from '../../../hooks/useWpPostStatusCounts'; import { columnVisibilityStorageKey, loadColumnVisibility, saveColumnVisibility } from '../../../lib/columnVisibility'; import { BulkActionsBar } from './BulkActionsBar'; import { ColumnVisibilityMenu } from './ColumnVisibilityMenu'; import { ListEmptyState } from './ListEmptyState'; import { ListPanel } from './ListPanel'; import { ListSearchToolbar, type SortFieldOption } from './ListSearchToolbar'; import type { RowActionItem } from './RowActionsMenu'; import { StatusCountPills, type StatusPillDef } from './StatusCountPills'; import { DEFAULT_LIST_PER_PAGE, ListPaginationBar } from './ListPaginationBar'; import { WpPostInlineRowActions } from './WpPostInlineRowActions'; import { __, sprintf } from '../../../lib/i18n'; export type EntityListPostRowActions = { /** Column id to attach inline actions under (default `title`). */ titleColumnId?: string; buildLeadingItems: (row: WpPost) => RowActionItem[]; buildViewHref?: (row: WpPost) => string | null | undefined; }; const DEFAULT_PILLS: StatusPillDef[] = [ { id: 'any', label: __('All', 'sikshya') }, { id: 'publish', label: __('Published', 'sikshya') }, { id: 'draft', label: __('Draft', 'sikshya') }, { id: 'pending', label: __('Pending', 'sikshya') }, { id: 'future', label: __('Scheduled', 'sikshya') }, { id: 'private', label: __('Private', 'sikshya') }, { id: 'trash', label: __('Trash', 'sikshya') }, ]; function columnMenuLabel(c: Column): string { if (typeof c.header === 'string' && c.header.trim() !== '') { return c.header; } return c.columnPickerLabel ?? c.id; } function skeletonHeaderText(c: Column): string { if (typeof c.header === 'string' && c.header.trim() !== '') { return c.header; } return c.columnPickerLabel || '\u00a0'; } type Props = { restBase: string; searchPlaceholder: string; sortFieldOptions: SortFieldOption[]; defaultSortField: string; /** Optional override for status tabs (order + labels). */ statusPills?: StatusPillDef[]; columns: Column[]; emptyMessage: string; /** * Optional header labels for the loading skeleton (same order as `columns`). * Defaults to each column’s `header` (empty headers become a space). */ skeletonHeaders?: string[]; /** Namespace for column picker + `localStorage`. Omit to hide the picker. */ columnPickerStorageKey?: string; /** Merged into the WP collection query (e.g. `{ embed: '1' }` for `_embed`). */ collectionQueryExtras?: Partial; emptyStateTitle?: string; emptyStateDescription?: string; emptyStateAction?: ReactNode; /** Row checkboxes + bulk move to trash / permanent delete (trash tab). Default true. */ bulkDeleteEnabled?: boolean; /** Called when the list query is ready so parents can refresh after row actions (stable callback recommended). */ onListReady?: (api: { refresh: () => Promise }) => void; /** WordPress-style row actions under the title; also drops a trailing `actions` column if present. */ postRowActions?: EntityListPostRowActions; /** Extra controls rendered in the search/sort toolbar (before column picker). */ toolbarTrailing?: ReactNode; }; /** * Reusable WP post-type list: search, sort, status pills with counts, table. * Use on Courses, Lessons, Quizzes, etc. */ export function EntityListView({ restBase, searchPlaceholder, sortFieldOptions, defaultSortField, statusPills = DEFAULT_PILLS, columns, emptyMessage, skeletonHeaders: skeletonHeadersProp, columnPickerStorageKey, collectionQueryExtras, emptyStateTitle, emptyStateDescription, emptyStateAction, bulkDeleteEnabled = true, onListReady, postRowActions, toolbarTrailing, }: Props) { const { confirm } = useSikshyaDialog(); const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search, 320); const [status, setStatus] = useState('any'); const [page, setPage] = useState(1); const [orderby, setOrderby] = useState(defaultSortField); const [order, setOrder] = useState<'asc' | 'desc'>(defaultSortField === 'id' ? 'desc' : 'asc'); const [selectedIds, setSelectedIds] = useState>(() => new Set()); const [bulkActionValue, setBulkActionValue] = useState(''); const [bulkBusy, setBulkBusy] = useState(false); const [bulkError, setBulkError] = useState(null); const headerSelectRef = useRef(null); const sourceColumns = useMemo( () => (postRowActions ? columns.filter((c) => c.id !== 'actions') : columns), [columns, postRowActions] ); const [colVis, setColVis] = useState>({}); const onColumnToggle = useCallback( (id: string, next: boolean) => { if (!columnPickerStorageKey) { return; } const key = columnVisibilityStorageKey(columnPickerStorageKey); setColVis((prev) => { const merged = { ...prev, [id]: next }; saveColumnVisibility(key, merged); return merged; }); }, [columnPickerStorageKey] ); const countsQuery = useWpPostStatusCounts(restBase); const listQuery = useWpPostCollection(restBase, { ...collectionQueryExtras, search: debouncedSearch, status, orderby, order, page, perPage: DEFAULT_LIST_PER_PAGE, }); const rows = Array.isArray(listQuery.data?.data) ? listQuery.data.data : []; const includeBulkCol = bulkDeleteEnabled; const trashMode = status === 'trash'; const refreshList = useCallback(async () => { await listQuery.refetch(); await countsQuery.refetch(); }, [listQuery.refetch, countsQuery.refetch]); const columnsWithTitleActions = useMemo(() => { if (!postRowActions) { return sourceColumns; } const tid = postRowActions.titleColumnId ?? 'title'; return sourceColumns.map((c) => { if (c.id !== tid) { return c; } const prev = c.render; return { ...c, render: (r: WpPost) => (
{prev(r)}
), }; }); }, [sourceColumns, postRowActions, restBase, confirm, refreshList]); const tableColumns = columnsWithTitleActions; const pickable = useMemo(() => tableColumns.filter((c) => !c.alwaysVisible), [tableColumns]); useEffect(() => { if (!columnPickerStorageKey || pickable.length === 0) { setColVis({}); return; } const key = columnVisibilityStorageKey(columnPickerStorageKey); setColVis( loadColumnVisibility( key, pickable.map((c) => ({ id: c.id, defaultHidden: c.defaultHidden })) ) ); }, [columnPickerStorageKey, pickable]); const pickerVisibility = useMemo(() => { const o: Record = {}; for (const c of pickable) { const v = colVis[c.id]; o[c.id] = v === undefined ? !c.defaultHidden : v; } return o; }, [pickable, colVis]); const visibleColumns = useMemo(() => { if (!columnPickerStorageKey) { return tableColumns; } return tableColumns.filter((c) => { if (c.alwaysVisible) { return true; } const v = colVis[c.id]; if (v === undefined) { return !c.defaultHidden; } return v; }); }, [tableColumns, columnPickerStorageKey, colVis]); useEffect(() => { if (!onListReady) { return; } onListReady({ refresh: async () => { await listQuery.refetch(); await countsQuery.refetch(); }, }); }, [onListReady, listQuery.refetch, countsQuery.refetch]); useEffect(() => { setPage(1); setSelectedIds(new Set()); setBulkActionValue(''); setBulkError(null); }, [debouncedSearch, status, orderby, order, restBase]); useEffect(() => { setBulkActionValue(''); }, [trashMode]); const toggleSelectAll = useCallback(() => { setSelectedIds((prev) => { const ids = rows.map((r) => r.id); const allSel = ids.length > 0 && ids.every((id) => prev.has(id)); const next = new Set(prev); if (allSel) { ids.forEach((id) => next.delete(id)); } else { ids.forEach((id) => next.add(id)); } return next; }); }, [rows]); const toggleOne = useCallback((id: number) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); const visibleRowIds = useMemo(() => rows.map((r) => r.id), [rows]); const checkedOnPage = useMemo( () => visibleRowIds.filter((id) => selectedIds.has(id)).length, [visibleRowIds, selectedIds] ); const allVisibleSelected = visibleRowIds.length > 0 && checkedOnPage === visibleRowIds.length; useEffect(() => { const el = headerSelectRef.current; if (!el || !includeBulkCol) { return; } el.indeterminate = checkedOnPage > 0 && checkedOnPage < visibleRowIds.length; }, [checkedOnPage, visibleRowIds.length, includeBulkCol]); const selectionColumn: Column = useMemo( () => ({ id: '_bulk_select', header: ( ), alwaysVisible: true, headerClassName: 'w-12', cellClassName: 'w-12', render: (r) => ( toggleOne(r.id)} /> ), }), [allVisibleSelected, selectedIds, toggleOne, toggleSelectAll] ); const displayColumns = useMemo( () => (includeBulkCol ? [selectionColumn, ...visibleColumns] : visibleColumns), [includeBulkCol, selectionColumn, visibleColumns] ); const tableSkeletonHeaders = useMemo(() => { const base = skeletonHeadersProp?.length ? skeletonHeadersProp : sourceColumns.map(skeletonHeaderText); return includeBulkCol ? ['', ...base] : base; }, [sourceColumns, skeletonHeadersProp, includeBulkCol]); const onBulkApply = useCallback(async () => { if (!includeBulkCol || selectedIds.size === 0 || bulkActionValue === '') { return; } const n = selectedIds.size; const api = getWpApi(); const ids = [...selectedIds]; const done = async () => { setSelectedIds(new Set()); setBulkActionValue(''); await listQuery.refetch(); await countsQuery.refetch(); }; const runPatchAll = async (body: Record) => { setBulkBusy(true); setBulkError(null); try { for (const id of ids) { await api.patch(`/${restBase}/${id}`, body); } await done(); } catch (e) { setBulkError(e); } finally { setBulkBusy(false); } }; if (trashMode) { if (bulkActionValue === 'restore_draft') { await runPatchAll({ status: 'draft' }); return; } if (bulkActionValue === 'delete_permanent') { const ok = await confirm({ title: __('Delete permanently?', 'sikshya'), message: sprintf( __('Permanently delete %d item(s)? This cannot be undone.', 'sikshya'), n ), variant: 'danger', confirmLabel: __('Delete permanently', 'sikshya'), }); if (!ok) { return; } setBulkBusy(true); setBulkError(null); try { for (const id of ids) { await api.delete(`/${restBase}/${id}?force=true`); } await done(); } catch (e) { setBulkError(e); } finally { setBulkBusy(false); } return; } return; } if (bulkActionValue === 'move_trash') { const ok = await confirm({ title: __('Move to trash?', 'sikshya'), message: sprintf( __('Move %d item(s) to the trash? You can restore from the All or Trash tab.', 'sikshya'), n ), variant: 'danger', confirmLabel: __('Move to trash', 'sikshya'), }); if (!ok) { return; } setBulkBusy(true); setBulkError(null); try { for (const id of ids) { await api.delete(`/${restBase}/${id}`); } await done(); } catch (e) { setBulkError(e); } finally { setBulkBusy(false); } return; } if ( bulkActionValue === 'publish' || bulkActionValue === 'draft' || bulkActionValue === 'pending' || bulkActionValue === 'private' ) { await runPatchAll({ status: bulkActionValue }); } }, [ includeBulkCol, selectedIds, bulkActionValue, trashMode, confirm, restBase, listQuery, countsQuery, ]); const onSortOrderToggle = () => setOrder((o) => (o === 'asc' ? 'desc' : 'asc')); const onSortColumn = useCallback( (key: string) => { if (key === orderby) { setOrder((o) => (o === 'asc' ? 'desc' : 'asc')); } else { setOrderby(key); setOrder('asc'); } }, [orderby] ); const columnPicker = columnPickerStorageKey && pickable.length > 0 ? ( ({ id: c.id, label: columnMenuLabel(c) }))} visibility={pickerVisibility} onChange={onColumnToggle} /> ) : null; const emptyContent = ( ); return ( {toolbarTrailing} {columnPicker} ) : null } />
void onBulkApply()} applyBusy={bulkBusy} trashMode={trashMode} />
{bulkError ? (
setBulkError(null)} />
) : null} {listQuery.error ? (
) : listQuery.loading ? ( ) : ( <> r.id} emptyContent={emptyContent} wrapInCard={false} getRowClassName={ status === 'any' ? (r) => (r.status === 'trash' ? 'opacity-50 saturate-75' : undefined) : undefined } sortState={{ orderby, order }} onSortColumn={onSortColumn} /> )}
); }