Emails
1 month ago
EngagementChart
1 month ago
MediaHub
1 month ago
Onboarding
1 month ago
Popup
1 month ago
Skeletons
2 weeks ago
WhatsNew
1 month ago
charts
1 month ago
test
1 month ago
AdminMenuSync.js
1 month ago
ChartEmptyState.js
1 month ago
ChooseDate.js
1 month ago
ColorPicker.js
1 month ago
ExtendPlugins.js
1 month ago
Filters.js
1 month ago
Link.js
1 month ago
Navbar.js
2 weeks ago
NoFound.js
1 month ago
PageHeader.js
1 month ago
PluginRecommendations.js
1 month ago
PostScheduleField.js
1 month ago
PrestoPlayerIcon.js
1 month ago
ProGateOverlay.js
1 month ago
QuickAccess.js
1 month ago
RankedTable.js
1 month ago
StatCard.js
1 month ago
TopMedia.js
1 month ago
TopPerformingMedia.js
1 month ago
TopUsers.js
1 month ago
TruncatedTitle.js
1 month ago
UpgradeNotice.js
1 month ago
UpgradeToPro.js
1 month ago
VideoModal.js
1 month ago
WelcomeBanner.js
1 month ago
RankedTable.js
225 lines
| 1 | const { __ } = wp.i18n; |
| 2 | import { Container, Table, Pagination, Text } from "@bsf/force-ui"; |
| 3 | |
| 4 | /** |
| 5 | * Build the page list for the pager — first/last pinned, current ± siblings, |
| 6 | * ellipses for gaps. Bounded output keeps the pager inside the card on every |
| 7 | * page so the last page stays reachable however many pages there are. |
| 8 | * |
| 9 | * Returns at most `boundary*2 + siblings*2 + 3` entries (default = 7). |
| 10 | */ |
| 11 | const buildPagerWindow = (currentPage, totalPages, siblings = 1, boundary = 1) => { |
| 12 | const totalSlots = boundary * 2 + siblings * 2 + 3; |
| 13 | if (totalPages <= totalSlots) { |
| 14 | return Array.from({ length: totalPages }, (_, i) => i + 1); |
| 15 | } |
| 16 | |
| 17 | const leftSibling = Math.max(currentPage - siblings, boundary + 1); |
| 18 | const rightSibling = Math.min(currentPage + siblings, totalPages - boundary); |
| 19 | const showLeftEllipsis = leftSibling > boundary + 2; |
| 20 | const showRightEllipsis = rightSibling < totalPages - boundary - 1; |
| 21 | |
| 22 | const range = (start, end) => |
| 23 | Array.from({ length: end - start + 1 }, (_, i) => start + i); |
| 24 | |
| 25 | const head = range(1, boundary); |
| 26 | const tail = range(totalPages - boundary + 1, totalPages); |
| 27 | |
| 28 | if (!showLeftEllipsis && showRightEllipsis) { |
| 29 | return [...range(1, boundary + siblings * 2 + 2), "ellipsis-right", ...tail]; |
| 30 | } |
| 31 | if (showLeftEllipsis && !showRightEllipsis) { |
| 32 | return [ |
| 33 | ...head, |
| 34 | "ellipsis-left", |
| 35 | ...range(totalPages - boundary - siblings * 2 - 1, totalPages), |
| 36 | ]; |
| 37 | } |
| 38 | return [ |
| 39 | ...head, |
| 40 | "ellipsis-left", |
| 41 | ...range(leftSibling, rightSibling), |
| 42 | "ellipsis-right", |
| 43 | ...tail, |
| 44 | ]; |
| 45 | }; |
| 46 | |
| 47 | /** |
| 48 | * Card + table + pagination chrome shared by Analytics ranked-list widgets |
| 49 | * (Top Media, Top Users, etc.). |
| 50 | * |
| 51 | * The shared component owns ONLY the chrome: card wrapper, header, table |
| 52 | * layout, and the pagination footer ("X–Y of N items" + numbered page |
| 53 | * buttons). Per-row content is supplied by `columns`; an optional trailing |
| 54 | * action cell is supplied by `rowAction`. Pagination is fully controlled |
| 55 | * — pass the metadata + `onPageChange` callback from the parent's data |
| 56 | * hook (server-side pagination) and the footer wires itself up. |
| 57 | * |
| 58 | * @param {Object} props |
| 59 | * @param {string} props.title Card header text. |
| 60 | * @param {Array} props.columns Column definitions: |
| 61 | * { key, header, headerClassName?, cellClassName?, render(row) } |
| 62 | * @param {Array} props.rows Current page of rows. |
| 63 | * @param {Function} [props.rowKey] (row) => key. Falls back to row.id. |
| 64 | * @param {Function} [props.onRowClick] (row) => void. Makes the row clickable. |
| 65 | * @param {Function} [props.rowAction] (row) => ReactNode. Renders trailing action cell. |
| 66 | * @param {Object} [props.pagination] { currentPage, totalItems, totalPages, perPage, onPageChange }. |
| 67 | * When omitted, no footer renders. |
| 68 | * @param {ReactNode} [props.emptyState] Rendered when `rows` is empty. |
| 69 | * @param {string} [props.className] Extra classes applied to the outer card Container. |
| 70 | */ |
| 71 | const RankedTable = ({ |
| 72 | title, |
| 73 | columns = [], |
| 74 | rows = [], |
| 75 | rowKey, |
| 76 | onRowClick, |
| 77 | rowAction, |
| 78 | pagination, |
| 79 | emptyState = null, |
| 80 | className = "", |
| 81 | }) => { |
| 82 | const hasPagination = Boolean(pagination); |
| 83 | const total = pagination?.totalItems ?? rows.length; |
| 84 | const totalPages = pagination?.totalPages ?? 0; |
| 85 | const currentPage = pagination?.currentPage ?? 1; |
| 86 | const perPage = pagination?.perPage ?? rows.length; |
| 87 | const startIndex = hasPagination ? (currentPage - 1) * perPage : 0; |
| 88 | const endIndex = hasPagination |
| 89 | ? Math.min(startIndex + rows.length, total) |
| 90 | : rows.length; |
| 91 | |
| 92 | const goToPage = (page) => { |
| 93 | pagination?.onPageChange?.(page); |
| 94 | }; |
| 95 | |
| 96 | const renderPaginationItems = () => { |
| 97 | if (totalPages <= 1) return null; |
| 98 | |
| 99 | const items = buildPagerWindow(currentPage, totalPages).map((entry) => |
| 100 | typeof entry === "number" ? ( |
| 101 | <Pagination.Item |
| 102 | key={entry} |
| 103 | isActive={entry === currentPage} |
| 104 | onClick={() => goToPage(entry)} |
| 105 | > |
| 106 | {entry} |
| 107 | </Pagination.Item> |
| 108 | ) : ( |
| 109 | <Pagination.Ellipsis key={entry} /> |
| 110 | ) |
| 111 | ); |
| 112 | |
| 113 | return ( |
| 114 | <> |
| 115 | <Pagination.Previous |
| 116 | onClick={() => goToPage(Math.max(currentPage - 1, 1))} |
| 117 | disabled={currentPage === 1} |
| 118 | /> |
| 119 | {items} |
| 120 | <Pagination.Next |
| 121 | onClick={() => goToPage(Math.min(currentPage + 1, totalPages))} |
| 122 | disabled={currentPage === totalPages} |
| 123 | /> |
| 124 | </> |
| 125 | ); |
| 126 | }; |
| 127 | |
| 128 | const cardClasses = [ |
| 129 | "overflow-hidden border-0.5 border-solid border-border-subtle rounded-xl shadow-sm bg-background-primary", |
| 130 | "h-full w-full", |
| 131 | className, |
| 132 | ] |
| 133 | .filter(Boolean) |
| 134 | .join(" "); |
| 135 | |
| 136 | const getKey = (row, idx) => { |
| 137 | if (rowKey) return rowKey(row); |
| 138 | return row?.id ?? idx; |
| 139 | }; |
| 140 | |
| 141 | return ( |
| 142 | <Container direction="column" gap="none" className={cardClasses}> |
| 143 | <Container align="center" gap="xs" className="px-3 py-3"> |
| 144 | <Text className="text-base font-semibold text-text-primary"> |
| 145 | {title} |
| 146 | </Text> |
| 147 | </Container> |
| 148 | |
| 149 | {rows.length === 0 ? ( |
| 150 | emptyState |
| 151 | ) : ( |
| 152 | <> |
| 153 | <div className="[&>div]:rounded-none [&>div]:border-b-0"> |
| 154 | <Table className="shadow-none border-0 rounded-none"> |
| 155 | <Table.Head className="bg-background-primary border-b-0.5"> |
| 156 | {columns.map((col, idx) => ( |
| 157 | <Table.HeadCell |
| 158 | key={col.key ?? idx} |
| 159 | className={col.headerClassName} |
| 160 | > |
| 161 | {col.header} |
| 162 | </Table.HeadCell> |
| 163 | ))} |
| 164 | {rowAction ? <Table.HeadCell className="py-2" /> : null} |
| 165 | </Table.Head> |
| 166 | |
| 167 | <Table.Body> |
| 168 | {rows.map((row, idx) => ( |
| 169 | <Table.Row |
| 170 | key={getKey(row, idx)} |
| 171 | className={ |
| 172 | onRowClick |
| 173 | ? "cursor-pointer hover:bg-background-secondary" |
| 174 | : undefined |
| 175 | } |
| 176 | onClick={onRowClick ? () => onRowClick(row) : undefined} |
| 177 | > |
| 178 | {columns.map((col, colIdx) => ( |
| 179 | <Table.Cell |
| 180 | key={col.key ?? colIdx} |
| 181 | className={col.cellClassName} |
| 182 | > |
| 183 | {col.render(row)} |
| 184 | </Table.Cell> |
| 185 | ))} |
| 186 | {rowAction ? ( |
| 187 | <Table.Cell className="whitespace-nowrap py-2"> |
| 188 | {rowAction(row)} |
| 189 | </Table.Cell> |
| 190 | ) : null} |
| 191 | </Table.Row> |
| 192 | ))} |
| 193 | </Table.Body> |
| 194 | </Table> |
| 195 | </div> |
| 196 | |
| 197 | {hasPagination && totalPages > 1 ? ( |
| 198 | <div className="bg-background-primary px-4 py-3 border-t-[0.5px] border-x-0 border-b-0 border-solid border-border-subtle"> |
| 199 | <Container align="center" justify="between" className="w-full"> |
| 200 | <Text |
| 201 | size="sm" |
| 202 | weight="regular" |
| 203 | className="text-text-secondary" |
| 204 | > |
| 205 | {`${startIndex + 1}–${endIndex} ${__( |
| 206 | "of", |
| 207 | "presto-player" |
| 208 | )} ${total} ${__("items", "presto-player")}`} |
| 209 | </Text> |
| 210 | <Pagination className="w-fit [&_li]:!my-0"> |
| 211 | <Pagination.Content> |
| 212 | {renderPaginationItems()} |
| 213 | </Pagination.Content> |
| 214 | </Pagination> |
| 215 | </Container> |
| 216 | </div> |
| 217 | ) : null} |
| 218 | </> |
| 219 | )} |
| 220 | </Container> |
| 221 | ); |
| 222 | }; |
| 223 | |
| 224 | export default RankedTable; |
| 225 |