From 9bc1788bc0aa8d82d458c375bcb3fc4027eed834 Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Sun, 5 Jan 2025 18:25:05 +0530 Subject: [PATCH] Upgrade and virtualize table component (#8157) * Upgrade and virtualize table component * width in column def * container height * share query options * full page scroll * change z-index and remove shrink * non-modal menu --- app/components/FilterOptions.tsx | 2 +- app/components/SortableTable.tsx | 31 + app/components/Table.tsx | 542 ++++++++++-------- app/components/TableFromParams.tsx | 71 --- app/hooks/useQuery.ts | 10 +- app/hooks/useTableRequest.ts | 102 ++++ app/models/Share.ts | 3 + app/scenes/Settings/Members.tsx | 239 ++++---- app/scenes/Settings/Shares.tsx | 93 ++- .../Settings/components/PeopleTable.tsx | 116 ++-- .../Settings/components/SharesTable.tsx | 142 +++-- app/typings/react-table.d.ts | 55 -- package.json | 4 +- server/routes/api/shares/shares.ts | 68 +-- shared/i18n/locales/en_US/translation.json | 4 +- yarn.lock | 41 +- 16 files changed, 822 insertions(+), 701 deletions(-) create mode 100644 app/components/SortableTable.tsx delete mode 100644 app/components/TableFromParams.tsx create mode 100644 app/hooks/useTableRequest.ts delete mode 100644 app/typings/react-table.d.ts diff --git a/app/components/FilterOptions.tsx b/app/components/FilterOptions.tsx index 61fd0fd250..94fc704007 100644 --- a/app/components/FilterOptions.tsx +++ b/app/components/FilterOptions.tsx @@ -46,7 +46,7 @@ const FilterOptions = ({ const searchInputRef = React.useRef(null); const listRef = React.useRef(null); const menu = useMenuState({ - modal: true, + modal: false, }); const selectedItems = options.filter((option) => selectedKeys.includes(option.key) diff --git a/app/components/SortableTable.tsx b/app/components/SortableTable.tsx new file mode 100644 index 0000000000..306cef7bd7 --- /dev/null +++ b/app/components/SortableTable.tsx @@ -0,0 +1,31 @@ +import { ColumnSort } from "@tanstack/react-table"; +import * as React from "react"; +import { useHistory, useLocation } from "react-router-dom"; +import useQuery from "~/hooks/useQuery"; +import lazyWithRetry from "~/utils/lazyWithRetry"; +import type { Props as TableProps } from "./Table"; + +const Table = lazyWithRetry(() => import("~/components/Table")); + +export type Props = Omit, "onChangeSort">; + +export function SortableTable(props: Props) { + const location = useLocation(); + const history = useHistory(); + const params = useQuery(); + + const handleChangeSort = React.useCallback( + (sort: ColumnSort) => { + params.set("sort", sort.id); + params.set("direction", sort.desc ? "desc" : "asc"); + + history.replace({ + pathname: location.pathname, + search: params.toString(), + }); + }, + [params, history, location.pathname] + ); + + return ; +} diff --git a/app/components/Table.tsx b/app/components/Table.tsx index 717b14670d..d1b9c25e74 100644 --- a/app/components/Table.tsx +++ b/app/components/Table.tsx @@ -1,231 +1,283 @@ -import isEqual from "lodash/isEqual"; +import { + useReactTable, + getCoreRowModel, + SortingState, + flexRender, + ColumnSort, + functionalUpdate, + Row as TRow, + createColumnHelper, + AccessorFn, + CellContext, +} from "@tanstack/react-table"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; import { observer } from "mobx-react"; import { CollapsedIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useTable, useSortBy, usePagination } from "react-table"; +import { Waypoint } from "react-waypoint"; import styled from "styled-components"; import { s } from "@shared/styles"; -import Button from "~/components/Button"; import DelayedMount from "~/components/DelayedMount"; import Empty from "~/components/Empty"; import Flex from "~/components/Flex"; import NudeButton from "~/components/NudeButton"; import PlaceholderText from "~/components/PlaceholderText"; +import usePrevious from "~/hooks/usePrevious"; -export type Props = { - data: any[]; - offset?: number; - isLoading: boolean; - empty?: React.ReactNode; - currentPage?: number; - page: number; - pageSize?: number; - totalPages?: number; - defaultSort?: string; - topRef?: React.Ref; - onChangePage: (index: number) => void; - onChangeSort: ( - sort: string | null | undefined, - direction: "ASC" | "DESC" - ) => void; - columns: any; - defaultSortDirection: "ASC" | "DESC"; +const HEADER_HEIGHT = 40; + +type DataColumn = { + type: "data"; + header: string; + accessor: AccessorFn; + sortable?: boolean; }; -function Table({ - data, - isLoading, - totalPages, - empty, - columns, - page, - pageSize = 50, - defaultSort = "name", - topRef, - onChangeSort, - onChangePage, - defaultSortDirection, -}: Props) { - const { t } = useTranslation(); - const { - getTableProps, - getTableBodyProps, - headerGroups, - rows, - prepareRow, - canNextPage, - nextPage, - canPreviousPage, - previousPage, - state: { pageIndex, sortBy }, - } = useTable( - { - columns, - data, - manualPagination: true, - manualSortBy: true, - autoResetSortBy: false, - autoResetPage: false, - pageCount: totalPages, - initialState: { - sortBy: [ - { - id: defaultSort, - desc: defaultSortDirection === "DESC" ? true : false, - }, - ], - pageSize, - pageIndex: page, - }, - stateReducer: (newState, action, prevState) => { - if (!isEqual(newState.sortBy, prevState.sortBy)) { - return { ...newState, pageIndex: 0 }; - } +type ActionColumn = { + type: "action"; + header?: string; +}; - return newState; - }, - }, - useSortBy, - usePagination +export type Column = { + id: string; + component: (data: TData) => React.ReactNode; + width: string; +} & (DataColumn | ActionColumn); + +export type Props = { + data: TData[]; + columns: Column[]; + sort: ColumnSort; + onChangeSort: (sort: ColumnSort) => void; + loading: boolean; + page: { + hasNext: boolean; + fetchNext?: () => void; + }; + rowHeight: number; + stickyOffset?: number; +}; + +function Table({ + data, + columns, + sort, + onChangeSort, + loading, + page, + rowHeight, + stickyOffset = 0, +}: Props) { + const { t } = useTranslation(); + const virtualContainerRef = React.useRef(null); + const [virtualContainerTop, setVirtualContainerTop] = + React.useState(); + + const columnHelper = React.useMemo(() => createColumnHelper(), []); + const observedColumns = React.useMemo( + () => + columns.map((column) => { + const cell = ({ row }: CellContext) => ( + + ); + + return column.type === "data" + ? columnHelper.accessor(column.accessor, { + id: column.id, + header: column.header, + enableSorting: column.sortable ?? true, + cell, + }) + : columnHelper.display({ + id: column.id, + header: column.header ?? "", + cell, + }); + }), + [columns, columnHelper] ); - const prevSortBy = React.useRef(sortBy); + + const gridColumns = React.useMemo( + () => columns.map((column) => column.width).join(" "), + [columns] + ); + + const handleChangeSort = React.useCallback( + (sortState: SortingState) => { + const newState = functionalUpdate(sortState, [sort]); + const newSort = newState[0]; + onChangeSort(newSort); + }, + [sort, onChangeSort] + ); + + const prevSort = usePrevious(sort); + const sortChanged = sort !== prevSort; + + const isEmpty = !loading && data.length === 0; + const showPlaceholder = loading && data.length === 0; + + const table = useReactTable({ + data, + columns: observedColumns, + getCoreRowModel: getCoreRowModel(), + manualSorting: true, + enableMultiSort: false, + enableSortingRemoval: false, + state: { + sorting: [sort], + }, + onSortingChange: handleChangeSort, + }); + + const { rows } = table.getRowModel(); + + const rowVirtualizer = useWindowVirtualizer({ + count: rows.length, + estimateSize: () => rowHeight, + scrollMargin: virtualContainerTop, + overscan: 5, + }); React.useEffect(() => { - if (!isEqual(sortBy, prevSortBy.current)) { - prevSortBy.current = sortBy; - onChangePage(0); - onChangeSort( - sortBy.length ? sortBy[0].id : undefined, - !sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC" + if (!sortChanged || !virtualContainerTop) { + return; + } + + const scrollThreshold = + virtualContainerTop - (stickyOffset + HEADER_HEIGHT); + const reset = window.scrollY > scrollThreshold; + + if (reset) { + rowVirtualizer.scrollToOffset(scrollThreshold, { + behavior: "smooth", + }); + } + }, [rowVirtualizer, sortChanged, virtualContainerTop, stickyOffset]); + + React.useLayoutEffect(() => { + if (virtualContainerRef.current) { + // determine the scrollable virtual container offsetTop on mount + setVirtualContainerTop( + virtualContainerRef.current.getBoundingClientRect().top ); } - }, [defaultSortDirection, onChangePage, onChangeSort, sortBy]); - - const handleNextPage = () => { - nextPage(); - onChangePage(pageIndex + 1); - }; - - const handlePreviousPage = () => { - previousPage(); - onChangePage(pageIndex - 1); - }; - - const isEmpty = !isLoading && data.length === 0; - const showPlaceholder = isLoading && data.length === 0; + }, []); return ( -
- - -
- {headerGroups.map((headerGroup) => { - const groupProps = headerGroup.getHeaderGroupProps(); - return ( - - {headerGroup.headers.map((column) => ( - + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + - ); - })} - - - {rows.map((row) => { - prepareRow(row); - return ( - - {row.cells.map((cell) => ( - - {cell.render("Cell")} - - ))} - - ); - })} - - {showPlaceholder && } - - {isEmpty ? ( - empty || {t("No results")} - ) : ( - + ) : header.column.getIsSorted() === "desc" ? ( + + ) : ( +
+ )} + + + ))} + + ))} + + +
- {/* Note: the page > 0 check shouldn't be needed here but is */} - {canPreviousPage && page > 0 && ( - - )} - {canNextPage && ( - - )} - + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index] as TRow; + return ( + + {row.getAllCells().map((cell) => ( + + ))} + + ); + })} + + {showPlaceholder && ( + + )} + + {page.hasNext && ( + )} - + {isEmpty && {t("No results")}} + ); } -export const Placeholder = ({ +const ObservedCell = observer(function ({ + data, + render, +}: { + data: TData; + render: (data: TData) => React.ReactNode; +}) { + return <>{render(data)}; +}); + +function Placeholder({ columns, rows = 3, + gridColumns, }: { columns: number; rows?: number; -}) => ( - - - {new Array(rows).fill(1).map((_, row) => ( - - {new Array(columns).fill(1).map((_, col) => ( - - - - ))} - - ))} - - -); - -const Anchor = styled.div` - top: -32px; - position: relative; -`; - -const Pagination = styled(Flex)` - margin: 0 0 32px; -`; + gridColumns: string; +}) { + return ( + + + {new Array(rows).fill(1).map((_r, row) => ( + + {new Array(columns).fill(1).map((_c, col) => ( + + ))} + + ))} + + + ); +} const DescSortIcon = styled(CollapsedIcon)` margin-left: -2px; @@ -239,12 +291,6 @@ const AscSortIcon = styled(DescSortIcon)` transform: rotate(180deg); `; -const InnerTable = styled.table` - border-collapse: collapse; - margin: 16px 0; - min-width: 100%; -`; - const SortWrapper = styled(Flex)<{ $sortable: boolean }>` display: inline-flex; height: 24px; @@ -261,15 +307,66 @@ const SortWrapper = styled(Flex)<{ $sortable: boolean }>` } `; -const Cell = styled.td` - padding: 10px 6px; - border-bottom: 1px solid ${s("divider")}; +const InnerTable = styled.div` + width: 100%; +`; + +const THead = styled.div<{ $topPos: number }>` + position: sticky; + top: ${({ $topPos }) => `${$topPos}px`}; + height: ${HEADER_HEIGHT}px; + z-index: 1; font-size: 14px; - text-wrap: nowrap; + color: ${s("textSecondary")}; + font-weight: 500; + + border-bottom: 1px solid ${s("divider")}; + background: ${s("background")}; +`; + +const TBody = styled.div<{ $height: number }>` + position: relative; + height: ${({ $height }) => `${$height}px`}; +`; + +const TR = styled.div<{ $columns: string }>` + width: 100%; + display: grid; + grid-template-columns: ${({ $columns }) => `${$columns}`}; + align-items: center; + border-bottom: 1px solid ${s("divider")}; + + &:last-child { + border-bottom: 0; + } +`; + +const TH = styled.span` + padding: 6px 6px 2px; + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } +`; + +const TD = styled.span` + padding: 10px 6px; + font-size: 14px; + text-wrap: wrap; + word-break: break-word; &:first-child { font-size: 15px; font-weight: 500; + padding-left: 0; + } + + &:last-child { + padding-right: 0; } &.actions, @@ -292,39 +389,4 @@ const Cell = styled.td` } `; -const Row = styled.tr` - ${Cell} { - &:first-child { - padding-left: 0; - } - &:last-child { - padding-right: 0; - } - } - &:last-child { - ${Cell} { - border-bottom: 0; - } - } -`; - -const Head = styled.th` - text-align: left; - padding: 6px 6px 2px; - border-bottom: 1px solid ${s("divider")}; - background: ${s("background")}; - font-size: 14px; - color: ${s("textSecondary")}; - font-weight: 500; - z-index: 1; - - :first-child { - padding-left: 0; - } - - :last-child { - padding-right: 0; - } -`; - export default observer(Table); diff --git a/app/components/TableFromParams.tsx b/app/components/TableFromParams.tsx deleted file mode 100644 index b450422782..0000000000 --- a/app/components/TableFromParams.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { observer } from "mobx-react"; -import * as React from "react"; -import { useHistory, useLocation } from "react-router-dom"; -import scrollIntoView from "scroll-into-view-if-needed"; -import useQuery from "~/hooks/useQuery"; -import lazyWithRetry from "~/utils/lazyWithRetry"; -import type { Props } from "./Table"; - -const Table = lazyWithRetry(() => import("~/components/Table")); - -const TableFromParams = ( - props: Omit -) => { - const topRef = React.useRef(); - const location = useLocation(); - const history = useHistory(); - const params = useQuery(); - - const handleChangeSort = React.useCallback( - (sort, direction) => { - if (sort) { - params.set("sort", sort); - } else { - params.delete("sort"); - } - - params.set("direction", direction.toLowerCase()); - - history.replace({ - pathname: location.pathname, - search: params.toString(), - }); - }, - [params, history, location.pathname] - ); - - const handleChangePage = React.useCallback( - (page) => { - if (page) { - params.set("page", page.toString()); - } else { - params.delete("page"); - } - - history.replace({ - pathname: location.pathname, - search: params.toString(), - }); - - if (topRef.current) { - scrollIntoView(topRef.current, { - scrollMode: "if-needed", - behavior: "auto", - block: "start", - }); - } - }, - [params, history, location.pathname] - ); - - return ( -
+ - - {column.render("Header")} - {column.isSorted && - (column.isSortedDesc ? ( - - ) : ( - - ))} - - - ))} -
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ +
- ); -}; - -export default observer(TableFromParams); diff --git a/app/hooks/useQuery.ts b/app/hooks/useQuery.ts index accc29f7cc..02405e1afc 100644 --- a/app/hooks/useQuery.ts +++ b/app/hooks/useQuery.ts @@ -1,5 +1,13 @@ +import React from "react"; import { useLocation } from "react-router-dom"; export default function useQuery() { - return new URLSearchParams(useLocation().search); + const location = useLocation(); + + const query = React.useMemo( + () => new URLSearchParams(location.search), + [location.search] + ); + + return query; } diff --git a/app/hooks/useTableRequest.ts b/app/hooks/useTableRequest.ts new file mode 100644 index 0000000000..1f861f8a22 --- /dev/null +++ b/app/hooks/useTableRequest.ts @@ -0,0 +1,102 @@ +import sortBy from "lodash/sortBy"; +import React from "react"; +import { + FetchPageParams, + PaginatedResponse, + PAGINATION_SYMBOL, +} from "~/stores/base/Store"; +import useRequest from "./useRequest"; + +const INITIAL_OFFSET = 0; +const PAGE_SIZE = 25; + +type Props = { + data: T[]; + reqFn: (params: FetchPageParams) => Promise>; + reqParams: Omit; +}; + +type Response = { + data: T[] | undefined; + error: unknown; + loading: boolean; + next: (() => void) | undefined; +}; + +export function useTableRequest({ + data, + reqFn, + reqParams, +}: Props): Response { + const [dataIds, setDataIds] = React.useState(); + const [total, setTotal] = React.useState(); + const [offset, setOffset] = React.useState({ value: INITIAL_OFFSET }); + const prevParamsRef = React.useRef(reqParams); + + const fetchPage = React.useCallback( + () => reqFn({ ...reqParams, offset: offset.value, limit: PAGE_SIZE }), + [reqFn, reqParams, offset] + ); + + const { request, loading, error } = useRequest(fetchPage); + + const nextPage = React.useCallback( + () => + setOffset((prev) => ({ + value: prev.value + PAGE_SIZE, + })), + [] + ); + + React.useEffect(() => { + if (prevParamsRef.current !== reqParams) { + prevParamsRef.current = reqParams; + setOffset({ value: INITIAL_OFFSET }); + return; + } + + let ignore = false; + + const handleRequest = async () => { + const response = await request(); + if (!response || ignore) { + return; + } + + const ids = response.map((item) => item.id); + + if (offset.value === INITIAL_OFFSET) { + setDataIds(response.map((item) => item.id)); + } else { + setDataIds((prev) => (prev ?? []).concat(ids)); + } + + setTotal(response[PAGINATION_SYMBOL]?.total); + }; + + void handleRequest(); + + return () => { + ignore = true; + }; + }, [reqParams, offset, request]); + + const filteredData = dataIds + ? sortBy( + data.filter((item) => dataIds.includes(item.id)), + (item) => dataIds.indexOf(item.id) + ) + : undefined; + + const next = + !loading && dataIds && total && dataIds.length < total + ? nextPage + : undefined; + + return { + data: filteredData, + error, + loading, + next, + }; +} diff --git a/app/models/Share.ts b/app/models/Share.ts index 06d3c9740e..f6fee1ffc3 100644 --- a/app/models/Share.ts +++ b/app/models/Share.ts @@ -59,6 +59,9 @@ class Share extends Model { @observable allowIndexing: boolean; + @observable + views: number; + /** The user that shared the document. */ @Relation(() => User, { onDelete: "null" }) createdBy: User; diff --git a/app/scenes/Settings/Members.tsx b/app/scenes/Settings/Members.tsx index 48a496dc03..2a5edc47bb 100644 --- a/app/scenes/Settings/Members.tsx +++ b/app/scenes/Settings/Members.tsx @@ -1,15 +1,18 @@ -import sortBy from "lodash/sortBy"; +import { ColumnSort } from "@tanstack/react-table"; import { observer } from "mobx-react"; import { PlusIcon, UserIcon } from "outline-icons"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; import { useHistory, useLocation } from "react-router-dom"; +import { toast } from "sonner"; import styled from "styled-components"; -import { PAGINATION_SYMBOL } from "~/stores/base/Store"; -import User from "~/models/User"; +import { depths, s } from "@shared/styles"; +import UsersStore from "~/stores/UsersStore"; import { Action } from "~/components/Actions"; import Button from "~/components/Button"; +import Fade from "~/components/Fade"; import Flex from "~/components/Flex"; +import { HEADER_HEIGHT } from "~/components/Header"; import Heading from "~/components/Heading"; import InputSearch from "~/components/InputSearch"; import Scene from "~/components/Scene"; @@ -21,11 +24,13 @@ import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePolicy from "~/hooks/usePolicy"; import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; -import PeopleTable from "./components/PeopleTable"; +import { useTableRequest } from "~/hooks/useTableRequest"; +import { PeopleTable } from "./components/PeopleTable"; import UserRoleFilter from "./components/UserRoleFilter"; import UserStatusFilter from "./components/UserStatusFilter"; function Members() { + const appName = env.APP_NAME; const location = useLocation(); const history = useHistory(); const team = useCurrentTeam(); @@ -33,83 +38,46 @@ function Members() { const { users } = useStores(); const { t } = useTranslation(); const params = useQuery(); - const [isLoading, setIsLoading] = React.useState(false); - const [data, setData] = React.useState([]); - const [totalPages, setTotalPages] = React.useState(0); - const [userIds, setUserIds] = React.useState([]); const can = usePolicy(team); - const query = params.get("query") || undefined; - const filter = params.get("filter") || undefined; - const role = params.get("role") || undefined; - const sort = params.get("sort") || "name"; - const direction = (params.get("direction") || "asc").toUpperCase() as - | "ASC" - | "DESC"; - const page = parseInt(params.get("page") || "0", 10); - const limit = 25; + const [query, setQuery] = React.useState(""); - React.useEffect(() => { - const fetchData = async () => { - setIsLoading(true); + const reqParams = React.useMemo( + () => ({ + query: params.get("query") || undefined, + filter: params.get("filter") || undefined, + role: params.get("role") || undefined, + sort: params.get("sort") || "name", + direction: (params.get("direction") || "asc").toUpperCase() as + | "ASC" + | "DESC", + }), + [params] + ); - try { - const response = await users.fetchPage({ - offset: page * limit, - limit, - sort, - direction, - query, - filter, - role, - }); - if (response[PAGINATION_SYMBOL]) { - setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit)); - } - setUserIds(response.map((u: User) => u.id)); - } finally { - setIsLoading(false); - } - }; + const sort: ColumnSort = React.useMemo( + () => ({ + id: reqParams.sort, + desc: reqParams.direction === "DESC", + }), + [reqParams.sort, reqParams.direction] + ); - void fetchData(); - }, [query, sort, filter, role, page, direction, users]); + const { data, error, loading, next } = useTableRequest({ + data: getFilteredUsers({ + users, + filter: reqParams.filter, + role: reqParams.role, + }), + reqFn: users.fetchPage, + reqParams, + }); - React.useEffect(() => { - let filtered = users.orderedData; - - if (!filter) { - filtered = users.active.filter((u) => userIds.includes(u.id)); - } else if (filter === "all") { - filtered = users.orderedData.filter((u) => userIds.includes(u.id)); - } else if (filter === "suspended") { - filtered = users.suspended.filter((u) => userIds.includes(u.id)); - } else if (filter === "invited") { - filtered = users.invited.filter((u) => userIds.includes(u.id)); - } - - if (role) { - filtered = filtered.filter((u) => u.role === role); - } - - // sort the resulting data by the original order from the server - setData(sortBy(filtered, (item) => userIds.indexOf(item.id))); - }, [ - filter, - role, - users.active, - users.orderedData, - users.suspended, - users.invited, - userIds, - ]); - - const handleStatusFilter = React.useCallback( - (f) => { - if (f) { - params.set("filter", f); - params.delete("page"); + const updateParams = React.useCallback( + (name: string, value: string) => { + if (value) { + params.set(name, value); } else { - params.delete("filter"); + params.delete(name); } history.replace({ @@ -120,43 +88,31 @@ function Members() { [params, history, location.pathname] ); + const handleStatusFilter = React.useCallback( + (status) => updateParams("filter", status), + [updateParams] + ); + const handleRoleFilter = React.useCallback( - (r) => { - if (r) { - params.set("role", r); - params.delete("page"); - } else { - params.delete("role"); - } - - history.replace({ - pathname: location.pathname, - search: params.toString(), - }); - }, - [params, history, location.pathname] + (role) => updateParams("role", role), + [updateParams] ); - const handleSearch = React.useCallback( - (event) => { - const { value } = event.target; + const handleSearch = React.useCallback((event) => { + const { value } = event.target; + setQuery(value); + }, []); - if (value) { - params.set("query", event.target.value); - params.delete("page"); - } else { - params.delete("query"); - } + React.useEffect(() => { + if (error) { + toast.error(t("Could not load members")); + } + }, [t, error]); - history.replace({ - pathname: location.pathname, - search: params.toString(), - }); - }, - [params, history, location.pathname] - ); - - const appName = env.APP_NAME; + React.useEffect(() => { + const timeout = setTimeout(() => updateParams("query", query), 250); + return () => clearTimeout(timeout); + }, [query, updateParams]); return ( - + - - + + + + ); } +function getFilteredUsers({ + users, + filter, + role, +}: { + users: UsersStore; + filter?: string; + role?: string; +}) { + let filteredUsers; + + switch (filter) { + case "all": + filteredUsers = users.orderedData; + break; + case "suspended": + filteredUsers = users.suspended; + break; + case "invited": + filteredUsers = users.invited; + break; + default: + filteredUsers = users.active; + } + + return role + ? filteredUsers.filter((user) => user.role === role) + : filteredUsers; +} + +const StickyFilters = styled(Flex)` + height: 40px; + position: sticky; + top: ${HEADER_HEIGHT}px; + z-index: ${depths.header}; + background: ${s("background")}; +`; + const LargeUserStatusFilter = styled(UserStatusFilter)` height: 32px; `; diff --git a/app/scenes/Settings/Shares.tsx b/app/scenes/Settings/Shares.tsx index 0b9c1e1459..d2691b60d0 100644 --- a/app/scenes/Settings/Shares.tsx +++ b/app/scenes/Settings/Shares.tsx @@ -1,11 +1,10 @@ -import sortBy from "lodash/sortBy"; +import { ColumnSort } from "@tanstack/react-table"; import { observer } from "mobx-react"; import { GlobeIcon, WarningIcon } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; -import { PAGINATION_SYMBOL } from "~/stores/base/Store"; -import Share from "~/models/Share"; +import { toast } from "sonner"; import Fade from "~/components/Fade"; import Heading from "~/components/Heading"; import Notice from "~/components/Notice"; @@ -15,7 +14,8 @@ import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePolicy from "~/hooks/usePolicy"; import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; -import SharesTable from "./components/SharesTable"; +import { useTableRequest } from "~/hooks/useTableRequest"; +import { SharesTable } from "./components/SharesTable"; function Shares() { const team = useCurrentTeam(); @@ -23,51 +23,37 @@ function Shares() { const { shares, auth } = useStores(); const canShareDocuments = auth.team && auth.team.sharing; const can = usePolicy(team); - const [isLoading, setIsLoading] = React.useState(false); - const [data, setData] = React.useState([]); - const [totalPages, setTotalPages] = React.useState(0); - const [shareIds, setShareIds] = React.useState([]); const params = useQuery(); - const query = params.get("query") || ""; - const sort = params.get("sort") || "createdAt"; - const direction = (params.get("direction") || "desc").toUpperCase() as - | "ASC" - | "DESC"; - const page = parseInt(params.get("page") || "0", 10); - const limit = 25; + + const reqParams = React.useMemo( + () => ({ + sort: params.get("sort") || "createdAt", + direction: (params.get("direction") || "desc").toUpperCase() as + | "ASC" + | "DESC", + }), + [params] + ); + + const sort: ColumnSort = React.useMemo( + () => ({ + id: reqParams.sort, + desc: reqParams.direction === "DESC", + }), + [reqParams.sort, reqParams.direction] + ); + + const { data, error, loading, next } = useTableRequest({ + data: shares.orderedData, + reqFn: shares.fetchPage, + reqParams, + }); React.useEffect(() => { - const fetchData = async () => { - setIsLoading(true); - - try { - const response = await shares.fetchPage({ - offset: page * limit, - limit, - sort, - direction, - }); - if (response[PAGINATION_SYMBOL]) { - setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit)); - } - setShareIds(response.map((u: Share) => u.id)); - } finally { - setIsLoading(false); - } - }; - - void fetchData(); - }, [query, sort, page, direction, shares]); - - React.useEffect(() => { - // sort the resulting data by the original order from the server - setData( - sortBy( - shares.orderedData.filter((item) => shareIds.includes(item.id)), - (item) => shareIds.indexOf(item.id) - ) - ); - }, [shares.orderedData, shareIds]); + if (error) { + toast.error(t("Could not load shares")); + } + }, [t, error]); return ( } wide> @@ -96,16 +82,17 @@ function Shares() { - {data.length ? ( + {data?.length ? ( ) : null} diff --git a/app/scenes/Settings/components/PeopleTable.tsx b/app/scenes/Settings/components/PeopleTable.tsx index cc2ab44d77..193a627185 100644 --- a/app/scenes/Settings/components/PeopleTable.tsx +++ b/app/scenes/Settings/components/PeopleTable.tsx @@ -1,4 +1,4 @@ -import { observer } from "mobx-react"; +import compact from "lodash/compact"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; @@ -6,94 +6,110 @@ import User from "~/models/User"; import { Avatar } from "~/components/Avatar"; import Badge from "~/components/Badge"; import Flex from "~/components/Flex"; -import TableFromParams from "~/components/TableFromParams"; +import { HEADER_HEIGHT } from "~/components/Header"; +import { + type Props as TableProps, + SortableTable, +} from "~/components/SortableTable"; +import { type Column as TableColumn } from "~/components/Table"; import Time from "~/components/Time"; import useCurrentUser from "~/hooks/useCurrentUser"; import UserMenu from "~/menus/UserMenu"; -type Props = Omit, "columns"> & { - data: User[]; +const ROW_HEIGHT = 60; +const STICKY_OFFSET = HEADER_HEIGHT + 40; // filter height + +type Props = Omit, "columns" | "rowHeight"> & { canManage: boolean; }; -function PeopleTable({ canManage, ...rest }: Props) { +export function PeopleTable({ canManage, ...rest }: Props) { const { t } = useTranslation(); const currentUser = useCurrentUser(); - const columns = React.useMemo( + + const columns = React.useMemo[]>( () => - [ + compact>([ { + type: "data", id: "name", - Header: t("Name"), - accessor: "name", - Cell: observer( - ({ value, row }: { value: string; row: { original: User } }) => ( - - {value}{" "} - {currentUser.id === row.original.id && `(${t("You")})`} - - ) + header: t("Name"), + accessor: (user) => user.name, + component: (user) => ( + + {user.name}{" "} + {currentUser.id === user.id && `(${t("You")})`} + ), + width: "4fr", }, canManage ? { + type: "data", id: "email", - Header: t("Email"), - accessor: "email", - Cell: observer(({ value }: { value: string }) => <>{value}), + header: t("Email"), + accessor: (user) => user.email, + component: (user) => <>{user.email}, + width: "4fr", } : undefined, { + type: "data", id: "lastActiveAt", - Header: t("Last active"), - accessor: "lastActiveAt", - Cell: observer(({ value }: { value: string }) => - value ?