mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12013f79fb | |||
| bfa32133f6 | |||
| ca662b0f38 | |||
| dc7f712558 | |||
| f014cc91d4 | |||
| 50b083f4af | |||
| 0e043888ac |
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 haven’t 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;
|
||||
`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
-55
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 validate from "@server/middlewares/validate";
|
||||
@@ -116,43 +116,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 = {
|
||||
|
||||
@@ -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",
|
||||
@@ -930,6 +928,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. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.": "Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t 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",
|
||||
@@ -1010,6 +1009,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.",
|
||||
|
||||
@@ -4255,6 +4255,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"
|
||||
@@ -5192,13 +5216,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"
|
||||
@@ -13337,11 +13354,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"
|
||||
@@ -13350,8 +13362,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"
|
||||
|
||||
Reference in New Issue
Block a user