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
This commit is contained in:
Hemachandar
2025-01-05 18:25:05 +05:30
committed by GitHub
parent e93ef8b392
commit 9bc1788bc0
16 changed files with 822 additions and 701 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ const FilterOptions = ({
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const menu = useMenuState({
modal: true,
modal: false,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
+31
View File
@@ -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<T> = Omit<TableProps<T>, "onChangeSort">;
export function SortableTable<T>(props: Props<T>) {
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 <Table onChangeSort={handleChangeSort} {...props} />;
}
+302 -240
View File
@@ -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<any>;
onChangePage: (index: number) => void;
onChangeSort: (
sort: string | null | undefined,
direction: "ASC" | "DESC"
) => void;
columns: any;
defaultSortDirection: "ASC" | "DESC";
const HEADER_HEIGHT = 40;
type DataColumn<TData> = {
type: "data";
header: string;
accessor: AccessorFn<TData>;
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<TData> = {
id: string;
component: (data: TData) => React.ReactNode;
width: string;
} & (DataColumn<TData> | ActionColumn);
export type Props<TData> = {
data: TData[];
columns: Column<TData>[];
sort: ColumnSort;
onChangeSort: (sort: ColumnSort) => void;
loading: boolean;
page: {
hasNext: boolean;
fetchNext?: () => void;
};
rowHeight: number;
stickyOffset?: number;
};
function Table<TData>({
data,
columns,
sort,
onChangeSort,
loading,
page,
rowHeight,
stickyOffset = 0,
}: Props<TData>) {
const { t } = useTranslation();
const virtualContainerRef = React.useRef<HTMLDivElement>(null);
const [virtualContainerTop, setVirtualContainerTop] =
React.useState<number>();
const columnHelper = React.useMemo(() => createColumnHelper<TData>(), []);
const observedColumns = React.useMemo(
() =>
columns.map((column) => {
const cell = ({ row }: CellContext<TData, unknown>) => (
<ObservedCell data={row.original} render={column.component} />
);
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 (
<div style={{ overflowX: "auto" }}>
<Anchor ref={topRef} />
<InnerTable {...getTableProps()}>
<thead>
{headerGroups.map((headerGroup) => {
const groupProps = headerGroup.getHeaderGroupProps();
return (
<tr {...groupProps} key={groupProps.key}>
{headerGroup.headers.map((column) => (
<Head
{...column.getHeaderProps(column.getSortByToggleProps())}
key={column.id}
<>
<InnerTable role="table">
<THead role="rowgroup" $topPos={stickyOffset}>
{table.getHeaderGroups().map((headerGroup) => (
<TR role="row" key={headerGroup.id} $columns={gridColumns}>
{headerGroup.headers.map((header) => (
<TH role="columnheader" key={header.id}>
<SortWrapper
align="center"
gap={4}
onClick={header.column.getToggleSortingHandler()}
$sortable={header.column.getCanSort()}
>
<SortWrapper
align="center"
$sortable={!column.disableSortBy}
gap={4}
>
{column.render("Header")}
{column.isSorted &&
(column.isSortedDesc ? (
<DescSortIcon />
) : (
<AscSortIcon />
))}
</SortWrapper>
</Head>
))}
</tr>
);
})}
</thead>
<tbody {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row);
return (
<Row {...row.getRowProps()} key={row.id}>
{row.cells.map((cell) => (
<Cell
{...cell.getCellProps([
{
// @ts-expect-error ts-migrate(2339) FIXME: Property 'className' does not exist on type 'Colum... Remove this comment to see the full error message
className: cell.column.className,
},
])}
key={cell.column.id}
>
{cell.render("Cell")}
</Cell>
))}
</Row>
);
})}
</tbody>
{showPlaceholder && <Placeholder columns={columns.length} />}
</InnerTable>
{isEmpty ? (
empty || <Empty>{t("No results")}</Empty>
) : (
<Pagination
justify={canPreviousPage ? "space-between" : "flex-end"}
gap={8}
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" ? (
<AscSortIcon />
) : header.column.getIsSorted() === "desc" ? (
<DescSortIcon />
) : (
<div />
)}
</SortWrapper>
</TH>
))}
</TR>
))}
</THead>
<TBody
ref={virtualContainerRef}
role="rowgroup"
$height={rowVirtualizer.getTotalSize()}
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={handlePreviousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={handleNextPage} neutral>
{t("Next page")}
</Button>
)}
</Pagination>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as TRow<TData>;
return (
<TR
role="row"
key={row.id}
data-index={virtualRow.index}
style={{
position: "absolute",
transform: `translateY(${
virtualRow.start - rowVirtualizer.options.scrollMargin
}px)`,
height: `${virtualRow.size}px`,
}}
$columns={gridColumns}
>
{row.getAllCells().map((cell) => (
<TD role="cell" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TD>
))}
</TR>
);
})}
</TBody>
{showPlaceholder && (
<Placeholder columns={columns.length} gridColumns={gridColumns} />
)}
</InnerTable>
{page.hasNext && (
<Waypoint
key={data?.length}
onEnter={page.fetchNext}
bottomOffset={-rowHeight * 5}
/>
)}
</div>
{isEmpty && <Empty>{t("No results")}</Empty>}
</>
);
}
export const Placeholder = ({
const ObservedCell = observer(function <TData>({
data,
render,
}: {
data: TData;
render: (data: TData) => React.ReactNode;
}) {
return <>{render(data)}</>;
});
function Placeholder({
columns,
rows = 3,
gridColumns,
}: {
columns: number;
rows?: number;
}) => (
<DelayedMount>
<tbody>
{new Array(rows).fill(1).map((_, row) => (
<Row key={row}>
{new Array(columns).fill(1).map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
))}
</tbody>
</DelayedMount>
);
const Anchor = styled.div`
top: -32px;
position: relative;
`;
const Pagination = styled(Flex)`
margin: 0 0 32px;
`;
gridColumns: string;
}) {
return (
<DelayedMount>
<TBody $height={150}>
{new Array(rows).fill(1).map((_r, row) => (
<TR key={row} $columns={gridColumns}>
{new Array(columns).fill(1).map((_c, col) => (
<TD key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
</TD>
))}
</TR>
))}
</TBody>
</DelayedMount>
);
}
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);
-71
View File
@@ -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<Props, "onChangeSort" | "onChangePage" | "topRef">
) => {
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 (
<Table
topRef={topRef}
onChangeSort={handleChangeSort}
onChangePage={handleChangePage}
{...props}
/>
);
};
export default observer(TableFromParams);
+9 -1
View File
@@ -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;
}
+102
View File
@@ -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<T> = {
data: T[];
reqFn: (params: FetchPageParams) => Promise<PaginatedResponse<T>>;
reqParams: Omit<FetchPageParams, "offset" | "limit">;
};
type Response<T> = {
data: T[] | undefined;
error: unknown;
loading: boolean;
next: (() => void) | undefined;
};
export function useTableRequest<T extends { id: string }>({
data,
reqFn,
reqParams,
}: Props<T>): Response<T> {
const [dataIds, setDataIds] = React.useState<string[]>();
const [total, setTotal] = React.useState<number>();
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,
};
}
+3
View File
@@ -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;
+118 -121
View File
@@ -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<User[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
const [userIds, setUserIds] = React.useState<string[]>([]);
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 (
<Scene
@@ -191,35 +147,76 @@ function Members() {
{{ signinMethods: team.signinMethods }} but havent signed in yet.
</Trans>
</Text>
<Flex gap={8}>
<StickyFilters gap={8}>
<InputSearch
short
value={query ?? ""}
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={filter ?? ""}
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={role ?? ""}
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
</Flex>
<PeopleTable
data={data}
canManage={can.update}
isLoading={isLoading}
page={page}
pageSize={limit}
totalPages={totalPages}
defaultSortDirection="ASC"
/>
</StickyFilters>
<Fade>
<PeopleTable
data={data ?? []}
sort={sort}
canManage={can.update}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</Fade>
</Scene>
);
}
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;
`;
+40 -53
View File
@@ -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<Share[]>([]);
const [totalPages, setTotalPages] = React.useState(0);
const [shareIds, setShareIds] = React.useState<string[]>([]);
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 (
<Scene title={t("Shared Links")} icon={<GlobeIcon />} wide>
@@ -96,16 +82,17 @@ function Shares() {
</Trans>
</Text>
{data.length ? (
{data?.length ? (
<Fade>
<SharesTable
data={data}
data={data ?? []}
sort={sort}
canManage={can.update}
isLoading={isLoading}
page={page}
pageSize={limit}
totalPages={totalPages}
defaultSortDirection="ASC"
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</Fade>
) : null}
+66 -50
View File
@@ -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<React.ComponentProps<typeof TableFromParams>, "columns"> & {
data: User[];
const ROW_HEIGHT = 60;
const STICKY_OFFSET = HEADER_HEIGHT + 40; // filter height
type Props = Omit<TableProps<User>, "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<TableColumn<User>[]>(
() =>
[
compact<TableColumn<User>>([
{
type: "data",
id: "name",
Header: t("Name"),
accessor: "name",
Cell: observer(
({ value, row }: { value: string; row: { original: User } }) => (
<Flex align="center" gap={8}>
<Avatar model={row.original} size={32} /> {value}{" "}
{currentUser.id === row.original.id && `(${t("You")})`}
</Flex>
)
header: t("Name"),
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={32} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
</Flex>
),
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 ? <Time dateTime={value} addSuffix /> : null
),
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "role",
Header: t("Role"),
accessor: "rank",
Cell: observer(({ row }: { row: { original: User } }) => (
<Badges>
{!row.original.lastActiveAt && <Badge>{t("Invited")}</Badge>}
{row.original.isAdmin ? (
header: t("Role"),
accessor: (user) => user.role,
component: (user) => (
<Badges wrap>
{!user.lastActiveAt && <Badge>{t("Invited")}</Badge>}
{user.isAdmin ? (
<Badge primary>{t("Admin")}</Badge>
) : row.original.isViewer ? (
) : user.isViewer ? (
<Badge>{t("Viewer")}</Badge>
) : row.original.isGuest ? (
) : user.isGuest ? (
<Badge yellow>{t("Guest")}</Badge>
) : (
<Badge>{t("Editor")}</Badge>
)}
{row.original.isSuspended && <Badge>{t("Suspended")}</Badge>}
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
)),
),
width: "2fr",
},
canManage
? {
Header: " ",
accessor: "id",
className: "actions",
disableSortBy: true,
Cell: observer(
({ row, value }: { value: string; row: { original: User } }) =>
currentUser.id !== value ? (
<UserMenu user={row.original} />
) : null
),
type: "action",
id: "action",
component: (user) =>
currentUser.id !== user.id ? <UserMenu user={user} /> : null,
width: "50px",
}
: undefined,
].filter((i) => i),
[t, canManage, currentUser]
]),
[t, currentUser, canManage]
);
return <TableFromParams columns={columns} {...rest} />;
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
{...rest}
/>
);
}
const Badges = styled.div`
const Badges = styled(Flex)`
margin-left: -10px;
row-gap: 4px;
`;
export default observer(PeopleTable);
+83 -59
View File
@@ -1,108 +1,132 @@
import { observer } from "mobx-react";
import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import { Avatar } from "~/components/Avatar";
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 useUserLocale from "~/hooks/useUserLocale";
import ShareMenu from "~/menus/ShareMenu";
import { formatNumber } from "~/utils/language";
type Props = Omit<React.ComponentProps<typeof TableFromParams>, "columns"> & {
data: Share[];
const ROW_HEIGHT = 50;
type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
canManage: boolean;
};
function SharesTable({ canManage, data, ...rest }: Props) {
export function SharesTable({ data, canManage, ...rest }: Props) {
const { t } = useTranslation();
const language = useUserLocale();
const hasDomain = data.some((share) => share.domain);
const columns = React.useMemo(
const columns = React.useMemo<TableColumn<Share>[]>(
() =>
[
compact<TableColumn<Share>>([
{
id: "documentTitle",
Header: t("Document"),
accessor: "documentTitle",
disableSortBy: true,
Cell: observer(({ value }: { value: string }) => <>{value}</>),
type: "data",
id: "title",
header: t("Document"),
accessor: (share) => share.documentTitle,
sortable: false,
component: (share) => <>{share.documentTitle}</>,
width: "4fr",
},
{
id: "who",
Header: t("Shared by"),
accessor: "createdById",
disableSortBy: true,
Cell: observer(
({ row }: { value: string; row: { original: Share } }) => (
<Flex align="center" gap={4}>
{row.original.createdBy && (
<Avatar model={row.original.createdBy} />
)}
{row.original.createdBy.name}
</Flex>
)
type: "data",
id: "createdBy",
header: t("Shared by"),
accessor: (share) => share.createdBy,
sortable: false,
component: (share) => (
<Flex align="center" gap={4}>
{share.createdBy && (
<>
<Avatar model={share.createdBy} />
{share.createdBy.name}
</>
)}
</Flex>
),
width: "2fr",
},
{
type: "data",
id: "createdAt",
Header: t("Date shared"),
accessor: "createdAt",
Cell: observer(({ value }: { value: string }) =>
value ? <Time dateTime={value} addSuffix /> : null
),
header: t("Date shared"),
accessor: (share) => share.createdAt,
component: (share) =>
share.createdAt ? (
<Time dateTime={share.createdAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "lastAccessedAt",
Header: t("Last accessed"),
accessor: "lastAccessedAt",
Cell: observer(({ value }: { value: string }) =>
value ? <Time dateTime={value} addSuffix /> : null
),
header: t("Last accessed"),
accessor: (share) => share.lastAccessedAt,
component: (share) =>
share.lastAccessedAt ? (
<Time dateTime={share.lastAccessedAt} addSuffix />
) : null,
width: "2fr",
},
hasDomain
? {
type: "data",
id: "domain",
Header: t("Domain"),
accessor: "domain",
disableSortBy: true,
header: t("Domain"),
accessor: (share) => share.domain,
sortable: false,
component: (share) => <>{share.domain}</>,
width: "1.5fr",
}
: undefined,
{
type: "data",
id: "views",
Header: t("Views"),
accessor: "views",
Cell: observer(({ value }: { value: number }) => (
header: t("Views"),
accessor: (share) => share.views,
component: (share) => (
<>
{language
? formatNumber(value, unicodeCLDRtoBCP47(language))
: value}
? formatNumber(share.views, unicodeCLDRtoBCP47(language))
: share.views}
</>
)),
),
width: "150px",
},
canManage
? {
Header: " ",
accessor: "id",
className: "actions",
disableSortBy: true,
Cell: observer(
({ row }: { value: string; row: { original: Share } }) => (
<Flex align="center">
<ShareMenu share={row.original} />
</Flex>
)
type: "action",
id: "action",
component: (share) => (
<Flex align="center">
<ShareMenu share={share} />
</Flex>
),
width: "50px",
}
: undefined,
].filter((i) => i),
[t, hasDomain, canManage]
]),
[t, language, hasDomain, canManage]
);
return <TableFromParams columns={columns} data={data} {...rest} />;
return (
<SortableTable
data={data}
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={HEADER_HEIGHT}
{...rest}
/>
);
}
export default SharesTable;
-55
View File
@@ -1,55 +0,0 @@
/* eslint-disable @typescript-eslint/ban-types */
import {
UsePaginationInstanceProps,
UsePaginationOptions,
UsePaginationState,
UseSortByColumnOptions,
UseSortByColumnProps,
UseSortByHooks,
UseSortByInstanceProps,
UseSortByOptions,
UseSortByState,
} from "react-table";
declare module "react-table" {
export interface TableOptions<D extends object>
extends UseExpandedOptions<D>,
UsePaginationOptions<D>,
UseSortByOptions<D>,
// note that having Record here allows you to add anything to the options, this matches the spirit of the
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
// feature set, this is a safe default.
Record<string, any> {}
export interface Hooks<D extends object = {}>
extends UseExpandedHooks<D>,
UseSortByHooks<D> {}
export interface TableInstance<D extends object = {}>
extends UsePaginationInstanceProps<D>,
UseSortByInstanceProps<D> {}
export interface TableState<D extends object = {}>
extends UseColumnOrderState<D>,
UseExpandedState<D>,
UsePaginationState<D>,
UseSortByState<D> {}
export interface ColumnInterface<D extends object = {}>
extends UseResizeColumnsColumnOptions<D>,
UseSortByColumnOptions<D> {}
export interface ColumnInstance<D extends object = {}>
extends UseResizeColumnsColumnProps<D>,
UseSortByColumnProps<D> {}
export interface Cell<D extends object = {}>
extends UseGroupByCellProps<D>,
UseRowStateCellProps<D> {}
export interface Row<D extends object = {}>
extends UseExpandedRowProps<D>,
UseGroupByRowProps<D>,
UseRowSelectRowProps<D>,
UseRowStateRowProps<D> {}
}
+2 -2
View File
@@ -85,6 +85,8 @@
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.119.0",
"@sentry/react": "^7.119.0",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-virtual": "^3.10.9",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.0",
"@types/mailparser": "^3.4.4",
@@ -203,7 +205,6 @@
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
"react-table": "^7.8.0",
"react-virtualized-auto-sizer": "^1.0.21",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.10",
@@ -303,7 +304,6 @@
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
"@types/react-table": "^7.7.18",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/readable-stream": "^4.0.18",
+36 -32
View File
@@ -1,6 +1,6 @@
import Router from "koa-router";
import isUndefined from "lodash/isUndefined";
import { Op, WhereOptions } from "sequelize";
import { FindOptions, Op, WhereOptions } from "sequelize";
import { NotFoundError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
@@ -117,43 +117,47 @@ router.post(
const collectionIds = await user.collectionIds();
const options: FindOptions = {
where,
include: [
{
model: Document,
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
},
include: [
{
model: Collection.scope({
method: ["withMembership", user.id],
}),
as: "collection",
},
],
},
{
model: User,
required: true,
as: "user",
},
{
model: Team,
required: true,
as: "team",
},
],
};
const [shares, total] = await Promise.all([
Share.findAll({
where,
...options,
order: [[sort, direction]],
include: [
{
model: Document,
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
},
include: [
{
model: Collection.scope({
method: ["withMembership", user.id],
}),
as: "collection",
},
],
},
{
model: User,
required: true,
as: "user",
},
{
model: Team,
required: true,
as: "team",
},
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
Share.count({ where }),
Share.count(options),
]);
ctx.body = {
+2 -2
View File
@@ -386,8 +386,6 @@
"Installation": "Installation",
"Unstar document": "Unstar document",
"Star document": "Star document",
"Previous page": "Previous page",
"Next page": "Next page",
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
"Published": "Published",
@@ -939,6 +937,7 @@
"Import pages from a Confluence instance": "Import pages from a Confluence instance",
"Enterprise": "Enterprise",
"Recent imports": "Recent imports",
"Could not load members": "Could not load members",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.": "Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
"Document updated": "Document updated",
@@ -1021,6 +1020,7 @@
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.",
"Grist deployment": "Grist deployment",
"Add your self-hosted grist installation URL here.": "Add your self-hosted grist installation URL here.",
"Could not load shares": "Could not load shares",
"Sharing is currently disabled.": "Sharing is currently disabled.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.",
+27 -14
View File
@@ -4260,6 +4260,30 @@
magic-string "^0.25.0"
string.prototype.matchall "^4.0.6"
"@tanstack/react-table@^8.20.5":
version "8.20.5"
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.20.5.tgz#19987d101e1ea25ef5406dce4352cab3932449d8"
integrity sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==
dependencies:
"@tanstack/table-core" "8.20.5"
"@tanstack/react-virtual@^3.10.9":
version "3.10.9"
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.10.9.tgz#40606b6dd8aba8e977f576d8f7df07f69ca63eea"
integrity sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==
dependencies:
"@tanstack/virtual-core" "3.10.9"
"@tanstack/table-core@8.20.5":
version "8.20.5"
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.20.5.tgz#3974f0b090bed11243d4107283824167a395cf1d"
integrity sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==
"@tanstack/virtual-core@3.10.9":
version "3.10.9"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz#55710c92b311fdaa8d8c66682a0dbdd684bc77c4"
integrity sha512-kBknKOKzmeR7lN+vSadaKWXaLS0SZZG+oqpQ/k80Q6g9REn6zRHS/ZYdrIzHnpHgy/eWs00SujveUN/GJT2qTw==
"@testing-library/dom@^8.0.0":
version "8.20.1"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f"
@@ -5190,13 +5214,6 @@
"@types/history" "*"
"@types/react" "*"
"@types/react-table@^7.7.18":
version "7.7.18"
resolved "https://registry.yarnpkg.com/@types/react-table/-/react-table-7.7.18.tgz#603541083dd01328b576ab4373f7dcbe22033c31"
integrity "sha1-YDVBCD3QEyi1dqtDc/fcviIDPDE= sha512-OncztdDERQ35pjcQCpNoQe8KPOE8Rg2Ox4PlZHMGNgHTEaM1JyT2lWfNNbj2sCnOtQOHrOH7SzUnGUAXzqdksg=="
dependencies:
"@types/react" "*"
"@types/react-virtualized-auto-sizer@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz#42044ef75ac2d2667893a5943e54a9f037f985a3"
@@ -13694,11 +13711,6 @@ react-style-singleton@^2.2.1:
invariant "^2.2.4"
tslib "^2.0.0"
react-table@^7.8.0:
version "7.8.0"
resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.8.0.tgz#07858c01c1718c09f7f1aed7034fcfd7bda907d2"
integrity "sha1-B4WMAcFxjAn38a7XA0/P172pB9I= sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA=="
react-virtual@^2.8.2:
version "2.8.2"
resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.8.2.tgz#e204b30c57c426bd260ed1ac49f8b1099e92b7cb"
@@ -13707,8 +13719,9 @@ react-virtual@^2.8.2:
"@reach/observe-rect" "^1.1.0"
react-virtualized-auto-sizer@^1.0.21:
version "1.0.21"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.21.tgz#c840bbc80a691aee030d090785b98142102013c5"
version "1.0.25"
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.25.tgz#b13cbc528ac200be2bd1ffa40c8bb19bcc60ac3f"
integrity sha512-YHsksEGDfsHbHuaBVDYwJmcktblcHGafz4ZVuYPQYuSHMUGjpwmUCrAOcvMSGMwwk1eFWj1M/1GwYpNPuyhaBg==
react-waypoint@^10.3.0:
version "10.3.0"