diff --git a/dashboard/src/components/entity-table/entity-table-provider.ts b/dashboard/src/components/entity-table/entity-table-provider.ts new file mode 100644 index 0000000..2204475 --- /dev/null +++ b/dashboard/src/components/entity-table/entity-table-provider.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; +import { UseFilteringReturn } from "./hooks"; +import { Table } from "@tanstack/react-table"; + +interface EntityTableContextProps { + table: Table + data: TData[] + filtering: UseFilteringReturn +} + +export const EntityTableContext = createContext | null>(null); + +export const useEntityTableContext = () => { + const ctx = useContext(EntityTableContext); + if (!ctx) + throw new Error('EntityTable.* component must be rendered as child of EntityTable'); + return ctx; +} diff --git a/dashboard/src/components/entity-table/hooks/index.ts b/dashboard/src/components/entity-table/hooks/index.ts new file mode 100644 index 0000000..6ae7e1b --- /dev/null +++ b/dashboard/src/components/entity-table/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './use-filtering' +export * from './use-pagination' +export * from './use-dialog' +export * from './use-row-selection' +export * from './use-entity-query' diff --git a/dashboard/src/components/entity-table/hooks/use-dialog.ts b/dashboard/src/components/entity-table/hooks/use-dialog.ts new file mode 100644 index 0000000..eaee78b --- /dev/null +++ b/dashboard/src/components/entity-table/hooks/use-dialog.ts @@ -0,0 +1,9 @@ +import { useState } from "react"; + +export interface UseDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + entity: T | null; +} + +export const useDialog = () => useState(false) diff --git a/dashboard/src/components/entity-table/hooks/use-entity-query.ts b/dashboard/src/components/entity-table/hooks/use-entity-query.ts new file mode 100644 index 0000000..04ddca5 --- /dev/null +++ b/dashboard/src/components/entity-table/hooks/use-entity-query.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +const UseEntityQueryPropsSchema = z.object({ + page: z.number().gte(1), + size: z.number().gte(1).lte(100) +}) + +export type UseEntityQueryProps = z.infer + +export type EntityQueryKeyType = { queryKey: [string, number, number] } + +interface FetchEntityResult { + pageCount: number + entity: T[] +} + +export type FetchEntityReturn = Promise> diff --git a/dashboard/src/components/entity-table/hooks/use-filtering.ts b/dashboard/src/components/entity-table/hooks/use-filtering.ts new file mode 100644 index 0000000..8a09960 --- /dev/null +++ b/dashboard/src/components/entity-table/hooks/use-filtering.ts @@ -0,0 +1,16 @@ +import { ColumnFiltersState, OnChangeFn } from "@tanstack/react-table" +import { useState } from "react" + +export interface UseFilteringType { + column: string +} + +export interface UseFilteringReturn extends UseFilteringType { + columnFilters: ColumnFiltersState + setColumnFilters: OnChangeFn +} + +export const useFiltering = ({ column }: UseFilteringType): UseFilteringReturn => { + const [columnFilters, setColumnFilters] = useState([]) + return { setColumnFilters, columnFilters, column } +} diff --git a/dashboard/src/components/entity-table/hooks/use-pagination.ts b/dashboard/src/components/entity-table/hooks/use-pagination.ts new file mode 100644 index 0000000..d0f3965 --- /dev/null +++ b/dashboard/src/components/entity-table/hooks/use-pagination.ts @@ -0,0 +1,17 @@ +import { useState } from "react"; + +export function usePagination() { + const [pagination, setPagination] = useState({ + pageSize: 10, + pageIndex: 1, + }); + const { pageSize, pageIndex } = pagination; + + return { + pageSize, + pageIndex, + onPaginationChange: setPagination, + pagination, + skip: pageSize * pageIndex, + }; +} diff --git a/dashboard/src/components/entity-table/hooks/use-row-selection.ts b/dashboard/src/components/entity-table/hooks/use-row-selection.ts new file mode 100644 index 0000000..106f763 --- /dev/null +++ b/dashboard/src/components/entity-table/hooks/use-row-selection.ts @@ -0,0 +1,6 @@ +import { OnChangeFn, RowSelectionState } from "@tanstack/react-table" + +export interface UseRowSelectionReturn { + setSelectedRow: OnChangeFn + selectedRow: RowSelectionState +} diff --git a/dashboard/src/components/entity-table/index.tsx b/dashboard/src/components/entity-table/index.tsx index a01d594..df1a825 100644 --- a/dashboard/src/components/entity-table/index.tsx +++ b/dashboard/src/components/entity-table/index.tsx @@ -1,45 +1,46 @@ import { FC, useState } from "react"; -import { DataTable } from "@marzneshin/components"; - -interface MutationDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - entity: T | null; -} - -interface DeleteConfirmationDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - entity: T | null; -} - -interface SettingsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - entity: T | null; -} +import { Button, DataTableViewOptions } from "@marzneshin/components"; +import { EntityTableContext } from "./entity-table-provider"; +import { EntityDataTable } from "./table" +import { DataTablePagination } from "./table-pagination" +import { SortingState, VisibilityState, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; +import { + UseRowSelectionReturn, + UseDialogProps, + useFiltering, + useDialog, + usePagination, + FetchEntityReturn, + EntityQueryKeyType +} from "./hooks"; +import { useTranslation } from "react-i18next"; +import { TableFiltering } from "./table-filtering"; +import { useQuery } from "@tanstack/react-query"; interface EntityTableProps { - useQuery: () => { data: T[] }; - MutationDialog: FC>; - DeleteConfirmationDialog: FC>; - SettingsDialog: FC>; - columns: any; + fetchEntity: ({ queryKey }: EntityQueryKeyType) => FetchEntityReturn; + MutationDialog: FC>; + DeleteConfirmationDialog: FC>; + SettingsDialog: FC>; + columnsFn: any; filteredColumn: string; + entityKey: string + rowSelection?: UseRowSelectionReturn } export function EntityTable({ - useQuery, + fetchEntity, MutationDialog, DeleteConfirmationDialog, SettingsDialog, - columns, + columnsFn, filteredColumn, + rowSelection, + entityKey, }: EntityTableProps) { - const { data } = useQuery(); - const [mutationDialogOpen, setMutationDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [mutationDialogOpen, setMutationDialogOpen] = useDialog(); + const [deleteDialogOpen, setDeleteDialogOpen] = useDialog(); + const [settingsDialogOpen, setSettingsDialogOpen] = useDialog(); const [selectedEntity, selectEntity] = useState(null); const onEdit = (entity: T) => { @@ -62,8 +63,45 @@ export function EntityTable({ setSettingsDialogOpen(true); }; + const { t } = useTranslation() + const filtering = useFiltering({ column: filteredColumn }) + const [sorting, setSorting] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const { onPaginationChange, pageIndex, pageSize } = usePagination(); + + const { data } = useQuery({ + queryFn: fetchEntity, + queryKey: [entityKey, pageIndex, pageSize], + initialData: { entity: [], pageCount: 1 } + }); + + const columns = columnsFn({ onEdit, onDelete, onOpen }) + const table = useReactTable({ + data: data.entity, + columns, + manualPagination: true, + pageCount: data.pageCount + 1, + autoResetPageIndex: false, + onPaginationChange, + onSortingChange: setSorting, + onColumnFiltersChange: filtering.setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: rowSelection && rowSelection.setSelectedRow, + state: { + sorting, + columnFilters: filtering.columnFilters, + columnVisibility, + pagination: { pageIndex: pageIndex - 1, pageSize }, + rowSelection: rowSelection ? rowSelection.selectedRow : {}, + }, + }) + return ( -
+ ({ onOpenChange={setMutationDialogOpen} entity={selectedEntity} /> - -
+ +
+
+ + + {onCreate && ()} +
+
+ + +
+
+ ); } + +export * from './hooks' diff --git a/dashboard/src/components/entity-table/table-filtering.tsx b/dashboard/src/components/entity-table/table-filtering.tsx new file mode 100644 index 0000000..409dac3 --- /dev/null +++ b/dashboard/src/components/entity-table/table-filtering.tsx @@ -0,0 +1,22 @@ + +import { FC } from 'react' +import { Input } from '@marzneshin/components'; +import { useEntityTableContext } from './entity-table-provider'; +import { useTranslation } from 'react-i18next'; + +interface TableFilteringProps { } + +export const TableFiltering: FC = () => { + const { table, filtering } = useEntityTableContext() + const { t } = useTranslation() + return ( + + table.getColumn(filtering.column)?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> + ); +} diff --git a/dashboard/src/components/entity-table/table-pagination.tsx b/dashboard/src/components/entity-table/table-pagination.tsx new file mode 100644 index 0000000..bc8088f --- /dev/null +++ b/dashboard/src/components/entity-table/table-pagination.tsx @@ -0,0 +1,96 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + DoubleArrowLeftIcon, + DoubleArrowRightIcon, +} from "@radix-ui/react-icons" +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@marzneshin/components" +import { useTranslation } from "react-i18next" +import { useEntityTableContext } from "./entity-table-provider"; + +export interface DataTablePaginationProps { } + +export function DataTablePagination() { + const { t } = useTranslation(); + const { table } = useEntityTableContext() + return ( +
+ {table.options.onRowSelectionChange && +
+ {table.getFilteredSelectedRowModel().rows.length} {"/"} + {table.getFilteredRowModel().rows.length} {t('table.selected')} +
} +
+
+

{t('table.row-per-page')}

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount() - 1} +
+
+ + + + +
+
+
+ ) +} diff --git a/dashboard/src/components/entity-table/table.tsx b/dashboard/src/components/entity-table/table.tsx new file mode 100644 index 0000000..d0acb8b --- /dev/null +++ b/dashboard/src/components/entity-table/table.tsx @@ -0,0 +1,74 @@ +import { + ColumnDef, + flexRender, +} from "@tanstack/react-table" + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@marzneshin/components" + +import { useTranslation } from "react-i18next" +import { useEntityTableContext } from "./entity-table-provider" + +interface DataTableProps { + columns: ColumnDef[] + onRowClick?: (object: TData) => void +} + +export function EntityDataTable({ + columns, + onRowClick, +}: DataTableProps) { + const { t } = useTranslation(); + const { table } = useEntityTableContext() + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + onRowClick && onRowClick(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {t('table.no-result')} + + + )} + +
+ ) +} diff --git a/dashboard/src/features/hosts/tables/inbound-hosts/table.tsx b/dashboard/src/features/hosts/tables/inbound-hosts/table.tsx index fe5ba71..58aaa95 100644 --- a/dashboard/src/features/hosts/tables/inbound-hosts/table.tsx +++ b/dashboard/src/features/hosts/tables/inbound-hosts/table.tsx @@ -57,11 +57,8 @@ export function InboundHostsDataTable({ setSelectedInbound, }: InboundHostsDataTableProps) { const [sorting, setSorting] = useState([]) - const [columnFilters, setColumnFilters] = useState( - [] - ) - const [columnVisibility, setColumnVisibility] = - useState({}) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) const table = useReactTable({ data, columns, diff --git a/dashboard/src/features/nodes/services/nodes.query.ts b/dashboard/src/features/nodes/services/nodes.query.ts index 6d284f6..37d9185 100644 --- a/dashboard/src/features/nodes/services/nodes.query.ts +++ b/dashboard/src/features/nodes/services/nodes.query.ts @@ -1,19 +1,27 @@ import { NodeType } from "@marzneshin/features/nodes"; import { useQuery } from "@tanstack/react-query"; import { fetch } from "@marzneshin/utils"; +import { + EntityQueryKeyType, + UseEntityQueryProps +} from "@marzneshin/components"; -export async function fetchNodes(): Promise { - return fetch('/nodes').then((result) => { - return result.items; +export async function fetchNodes({ queryKey }: EntityQueryKeyType): Promise<{ entity: NodeType[], pageCount: number }> { + return fetch(`/nodes?page=${queryKey[1]}&size=${queryKey[2]}`).then((result) => { + return { + entity: result.items, + pageCount: result.pages, + }; }); } export const NodesQueryFetchKey = "nodes"; -export const useNodesQuery = () => { + +export const useNodesQuery = ({ page, size }: UseEntityQueryProps) => { return useQuery({ - queryKey: [NodesQueryFetchKey], + queryKey: [NodesQueryFetchKey, page, size], queryFn: fetchNodes, - initialData: [] + initialData: { entity: [], pageCount: 0 }, }) } diff --git a/dashboard/src/features/nodes/tables/table.tsx b/dashboard/src/features/nodes/tables/table.tsx index f857687..cd39ad8 100644 --- a/dashboard/src/features/nodes/tables/table.tsx +++ b/dashboard/src/features/nodes/tables/table.tsx @@ -1,8 +1,8 @@ import { FC } from "react"; import { - useNodesQuery, columns, - NodesSettingsDialog + NodesSettingsDialog, + fetchNodes, } from '@marzneshin/features/nodes'; import { MutationDialog } from "../dialogs/mutation.dialog"; import { NodesDeleteConfirmationDialog } from "../dialogs/delete-confirmation.dialog"; @@ -11,12 +11,13 @@ import { EntityTable } from "@marzneshin/components"; export const NodesTable: FC = () => { return ( ) } diff --git a/dashboard/src/features/services/services/services.query.ts b/dashboard/src/features/services/services/services.query.ts index 95e8260..2e99511 100644 --- a/dashboard/src/features/services/services/services.query.ts +++ b/dashboard/src/features/services/services/services.query.ts @@ -1,6 +1,11 @@ -import { useQuery } from "@tanstack/react-query"; import { fetch } from "@marzneshin/utils"; import { ServiceType } from "../types"; +import { + EntityQueryKeyType, + FetchEntityReturn, + UseEntityQueryProps +} from "@marzneshin/components"; +import { useQuery } from "@tanstack/react-query"; interface ServiceResponse { id: number @@ -11,24 +16,27 @@ interface ServiceResponse { inbound_ids: number[] } -export async function fetchServices(): Promise { - return fetch('/services').then((result) => { +export async function fetchServices({ queryKey }: EntityQueryKeyType): FetchEntityReturn { + return fetch(`/services?page=${queryKey[1]}&size=${queryKey[2]}`).then((result) => { const services: ServiceType[] = result.items.map((fetchedService: ServiceResponse) => { const service: ServiceType = fetchedService; service.users = fetchedService.user_ids service.inbounds = fetchedService.inbound_ids return service }) - return services; + return { + entity: services, + pageCount: result.pages + }; }); } export const ServicesQueryFetchKey = "services"; -export const useServicesQuery = () => { +export const useServicesQuery = ({ page, size }: UseEntityQueryProps) => { return useQuery({ - queryKey: [ServicesQueryFetchKey], + queryKey: [ServicesQueryFetchKey, page, size], queryFn: fetchServices, - initialData: [] + initialData: { entity: [], pageCount: 0 } }) } diff --git a/dashboard/src/features/services/tables/services/index.tsx b/dashboard/src/features/services/tables/services/index.tsx index d8653fb..c225d01 100644 --- a/dashboard/src/features/services/tables/services/index.tsx +++ b/dashboard/src/features/services/tables/services/index.tsx @@ -1,8 +1,9 @@ import { FC } from "react"; import { - useServicesQuery, MutationDialog, - ServicesDeleteConfirmationDialog + ServicesDeleteConfirmationDialog, + ServicesQueryFetchKey, + fetchServices } from '@marzneshin/features/services'; import { columns } from "./columns"; import { ServiceSettingsDialog } from "../../dialogs/settings.dialog"; @@ -11,12 +12,13 @@ import { EntityTable } from "@marzneshin/components"; export const ServicesTable: FC = () => { return ( ) } diff --git a/dashboard/src/features/users/dialogs/mutation/fields/services.tsx b/dashboard/src/features/users/dialogs/mutation/fields/services.tsx index 28e0f2b..e5c43f4 100644 --- a/dashboard/src/features/users/dialogs/mutation/fields/services.tsx +++ b/dashboard/src/features/users/dialogs/mutation/fields/services.tsx @@ -24,7 +24,7 @@ export const ServicesField: FC = ({ setServices }) => { const form = useFormContext(); - const { data } = useServicesQuery(); + const { data } = useServicesQuery({ page: 1, size: 100 }); const { t } = useTranslation(); const handleToggle = (selectedServiceIds: string[]) => { @@ -46,7 +46,7 @@ export const ServicesField: FC = ({ type="multiple" > - {data.map(service => ( + {data.entity.map(service => ( { - return fetch('/users').then((result) => { +export async function fetchUsers({ queryKey }: EntityQueryKeyType): FetchEntityReturn { + return fetch(`/users?page=${queryKey[1]}&size=${queryKey[2]}`).then((result) => { const users: UserType[] = result.items.map((fetchedUser: UserResponse) => { const user: UserType = fetchedUser; user.services = fetchedUser.service_ids return user }) - return users; + return { + entity: users, + pageCount: result.pages + }; }); } export const UsersQueryFetchKey = "users"; -export const useUsersQuery = () => { +export const useUsersQuery = ({ page, size }: UseEntityQueryProps) => { return useQuery({ - queryKey: [UsersQueryFetchKey], + queryKey: [UsersQueryFetchKey, page, size], queryFn: fetchUsers, - initialData: [] + initialData: { entity: [], pageCount: 0 } }) } diff --git a/dashboard/src/features/users/tables/services/index.tsx b/dashboard/src/features/users/tables/services/index.tsx index 1e200b0..1161ddb 100644 --- a/dashboard/src/features/users/tables/services/index.tsx +++ b/dashboard/src/features/users/tables/services/index.tsx @@ -17,9 +17,10 @@ interface UserServicesTableProps { user: UserType } +// TODO: Implement pagination for row selectable with appliedable action table export const UserServicesTable: FC = ({ user }) => { const { mutate: updateUser } = useUsersUpdateMutation(); - const { data } = useServicesQuery(); + const { data } = useServicesQuery({ page: 1, size: 100 }); const [selectedService, setSelectedService] = useState<{ [key: number]: boolean }>({}) const { t } = useTranslation() @@ -27,7 +28,7 @@ export const UserServicesTable: FC = ({ user }) => { setSelectedService((prevSelected) => { const updatedSelected: RowSelectionState = { ...prevSelected }; user.services.forEach((serviceId) => { - data.forEach((fetchedService: ServiceType, i) => { + data.entity.forEach((fetchedService: ServiceType, i) => { if (fetchedService.id === serviceId) { updatedSelected[i] = true; } @@ -40,7 +41,7 @@ export const UserServicesTable: FC = ({ user }) => { const handleApply = useCallback(() => { const selectedServiceIds = Object.keys(selectedService) .filter(key => selectedService[parseInt(key)]) - .map(key => data[parseInt(key)].id); + .map(key => data.entity[parseInt(key)].id); updateUser({ ...user, services: selectedServiceIds }); }, [data, selectedService, user, updateUser]); @@ -50,7 +51,7 @@ export const UserServicesTable: FC = ({ user }) => {
{ return ( ) } diff --git a/dashboard/src/routes/_dashboard/users.tsx b/dashboard/src/routes/_dashboard/users.tsx index 11b79dd..916115c 100644 --- a/dashboard/src/routes/_dashboard/users.tsx +++ b/dashboard/src/routes/_dashboard/users.tsx @@ -17,7 +17,7 @@ import { FC } from 'react'; import { useTranslation } from 'react-i18next'; export const UsersPage: FC = () => { - const { data } = useServicesQuery(); + const { data } = useServicesQuery({ page: 1, size: 10 }); const { t } = useTranslation(); return ( @@ -31,7 +31,7 @@ export const UsersPage: FC = () => { - {(data && data.length === 0) && ( + {(data && data.pageCount === 0) && ( {t('page.users.services-alert.title')}