fix: Flash of empty state on paginated lists (#3351)

* fix: Flash of empty state on paginated lists
fix: Typing of PaginatedList to generic

* test

* test
This commit is contained in:
Tom Moor
2022-04-09 20:31:51 -07:00
committed by GitHub
parent 9281287dba
commit b7a6a34565
39 changed files with 202 additions and 140 deletions

View File

@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
@@ -42,7 +43,7 @@ function DocumentViews({ document, isOpen }: Props) {
<PaginatedList
aria-label={t("Viewers")}
items={users}
renderItem={(item) => {
renderItem={(item: User) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);

View File

@@ -6,7 +6,7 @@ import PaginatedList from "~/components/PaginatedList";
type Props = {
documents: Document[];
fetch: (options: any) => Promise<void>;
fetch: (options: any) => Promise<Document[] | undefined>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -40,7 +40,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, _index, compositeProps) => (
renderItem={(item: Document, _index, compositeProps) => (
<DocumentListItem
key={item.id}
document={item}

View File

@@ -8,7 +8,7 @@ import EventListItem from "./EventListItem";
type Props = {
events: Event[];
document: Document;
fetch: (options: Record<string, any> | null | undefined) => Promise<void>;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -29,7 +29,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, index, compositeProps) => {
renderItem={(item: Event, index, compositeProps) => {
return (
<EventListItem
key={item.id}

View File

@@ -13,18 +13,24 @@ import PlaceholderList from "~/components/List/Placeholder";
import withStores from "~/components/withStores";
import { dateToHeading } from "~/utils/dates";
type Props = WithTranslation &
export interface PaginatedItem {
id: string;
createdAt?: string;
updatedAt?: string;
}
type Props<T> = WithTranslation &
RootStore & {
fetch?: (
options: Record<string, any> | null | undefined
) => Promise<any> | undefined;
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: any[];
items?: T[];
renderItem: (
item: any,
item: T,
index: number,
compositeProps: CompositeStateReturn
) => React.ReactNode;
@@ -33,13 +39,14 @@ type Props = WithTranslation &
};
@observer
class PaginatedList extends React.Component<Props> {
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
@observable
isFetchingMore = false;
@observable
isFetching = false;
@observable
fetchCounter = 0;
@observable
@@ -55,7 +62,7 @@ class PaginatedList extends React.Component<Props> {
this.fetchResults();
}
componentDidUpdate(prevProps: Props) {
componentDidUpdate(prevProps: Props<T>) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
@@ -125,76 +132,82 @@ class PaginatedList extends React.Component<Props> {
};
render() {
const { items, heading, auth, empty, renderHeading, onEscape } = this.props;
const {
items = [],
heading,
auth,
empty = null,
renderHeading,
onEscape,
} = this.props;
let previousHeading = "";
const showList = !!items?.length;
const showEmpty = items?.length === 0;
const showLoading =
this.isFetching && !this.isFetchingMore && !showList && !showEmpty;
this.isFetching &&
!this.isFetchingMore &&
(!items?.length || this.fetchCounter === 0);
if (showLoading) {
return (
this.props.loading || (
<DelayedMount>
<PlaceholderList count={5} />
</DelayedMount>
)
);
}
if (items?.length === 0) {
return empty;
}
return (
<>
{showEmpty && empty}
{showList && (
<>
{heading}
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
>
{(composite: CompositeStateReturn) =>
items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(
item,
index,
composite
);
{heading}
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
>
{(composite: CompositeStateReturn) =>
items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index, composite);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})
}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
{showLoading &&
(this.props.loading || (
<DelayedMount>
<PlaceholderList count={5} />
</DelayedMount>
))}
</>
);
}

View File

@@ -9,9 +9,10 @@ import Empty from "~/components/Empty";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Placeholder from "~/components/List/Placeholder";
import PaginatedList from "~/components/PaginatedList";
import PaginatedList, { PaginatedItem } from "~/components/PaginatedList";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
import { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
type Props = { shareId: string };
@@ -31,7 +32,7 @@ function SearchPopover({ shareId }: Props) {
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
Record<string, any>[] | undefined
PaginatedItem[] | undefined
>(searchResults);
React.useEffect(() => {
@@ -43,7 +44,7 @@ function SearchPopover({ shareId }: Props) {
}, [searchResults, query, popover.show]);
const performSearch = React.useCallback(
async ({ query, ...options }: Record<string, any>) => {
async ({ query, ...options }) => {
if (query?.length > 0) {
return await documents.search(query, { shareId, ...options });
}
@@ -161,7 +162,7 @@ function SearchPopover({ shareId }: Props) {
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item, index, compositeProps) => (
renderItem={(item: SearchResult, index, compositeProps) => (
<SearchListItem
key={item.document.id}
shareId={shareId}

View File

@@ -2,13 +2,17 @@ import { pick } from "lodash";
import { set, computed, observable } from "mobx";
import { getFieldsForModel } from "./decorators/Field";
export default class BaseModel {
export default abstract class BaseModel {
@observable
id: string;
@observable
isSaving: boolean;
createdAt: string;
updatedAt: string;
store: any;
constructor(fields: Record<string, any>, store: any) {

View File

@@ -1,13 +1,13 @@
import { trim } from "lodash";
import { action, computed, observable } from "mobx";
import CollectionsStore from "~/stores/CollectionsStore";
import BaseModel from "~/models/BaseModel";
import Document from "~/models/Document";
import ParanoidModel from "~/models/ParanoidModel";
import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
import Field from "./decorators/Field";
export default class Collection extends BaseModel {
export default class Collection extends ParanoidModel {
store: CollectionsStore;
@observable
@@ -57,12 +57,6 @@ export default class Collection extends BaseModel {
documents: NavigationNode[];
createdAt: string;
updatedAt: string;
deletedAt: string | null | undefined;
url: string;
urlId: string;

View File

@@ -4,9 +4,9 @@ import { action, computed, observable } from "mobx";
import parseTitle from "@shared/utils/parseTitle";
import unescape from "@shared/utils/unescape";
import DocumentsStore from "~/stores/DocumentsStore";
import BaseModel from "~/models/BaseModel";
import User from "~/models/User";
import { NavigationNode } from "~/types";
import ParanoidModel from "./ParanoidModel";
import View from "./View";
import Field from "./decorators/Field";
@@ -17,7 +17,7 @@ type SaveOptions = {
lastRevision?: number;
};
export default class Document extends BaseModel {
export default class Document extends ParanoidModel {
@observable
isSaving = false;
@@ -63,20 +63,14 @@ export default class Document extends BaseModel {
collaboratorIds: string[];
createdAt: string;
createdBy: User;
updatedAt: string;
updatedBy: User;
publishedAt: string | undefined;
archivedAt: string;
deletedAt: string | undefined;
url: string;
urlId: string;

View File

@@ -0,0 +1,5 @@
import BaseModel from "./BaseModel";
export default abstract class ParanoidModel extends BaseModel {
deletedAt: string | undefined;
}

View File

@@ -10,9 +10,6 @@ class Pin extends BaseModel {
@observable
@Field
index: string;
createdAt: string;
updatedAt: string;
}
export default Pin;

View File

@@ -29,10 +29,6 @@ class Share extends BaseModel {
url: string;
createdBy: User;
createdAt: string;
updatedAt: string;
}
export default Share;

View File

@@ -1,9 +1,9 @@
import { computed, observable } from "mobx";
import { Role } from "@shared/types";
import BaseModel from "./BaseModel";
import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field";
class User extends BaseModel {
class User extends ParanoidModel {
@Field
@observable
id: string;
@@ -34,8 +34,6 @@ class User extends BaseModel {
isSuspended: boolean;
createdAt: string;
@computed
get isInvited(): boolean {
return !this.lastActiveAt;

View File

@@ -117,7 +117,7 @@ class AddGroupsToCollection extends React.Component<Props> {
}
items={groups.notInCollection(collection.id, this.query)}
fetch={this.query ? undefined : groups.fetchPage}
renderItem={(item) => (
renderItem={(item: Group) => (
<GroupListItem
key={item.id}
group={item}

View File

@@ -92,7 +92,6 @@ class AddPeopleToCollection extends React.Component<Props> {
</ButtonLink>
.
</Text>
<Input
type="search"
placeholder={`${t("Search by name")}`}
@@ -113,7 +112,7 @@ class AddPeopleToCollection extends React.Component<Props> {
}
items={users.notInCollection(collection.id, this.query)}
fetch={this.query ? undefined : users.fetchPage}
renderItem={(item) => (
renderItem={(item: User) => (
<MemberListItem
key={item.id}
user={item}

View File

@@ -4,6 +4,8 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import User from "~/models/User";
import Button from "~/components/Button";
import Divider from "~/components/Divider";
import Flex from "~/components/Flex";
@@ -292,7 +294,7 @@ function CollectionPermissions({ collection }: Props) {
items={collectionGroups}
fetch={collectionGroupMemberships.fetchPage}
options={fetchOptions}
renderItem={(group) => (
renderItem={(group: Group) => (
<CollectionGroupMemberListItem
key={group.id}
group={group}
@@ -310,7 +312,7 @@ function CollectionPermissions({ collection }: Props) {
items={collectionUsers}
fetch={memberships.fetchPage}
options={fetchOptions}
renderItem={(item) => (
renderItem={(item: User) => (
<MemberListItem
key={item.id}
user={item}

View File

@@ -113,7 +113,7 @@ class AddPeopleToGroup extends React.Component<Props> {
}
items={users.notInGroup(group.id, this.query)}
fetch={this.query ? undefined : users.fetchPage}
renderItem={(item) => (
renderItem={(item: User) => (
<GroupMemberListItem
key={item.id}
user={item}

View File

@@ -103,7 +103,7 @@ function GroupMembers({ group }: Props) {
id: group.id,
}}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(item) => (
renderItem={(item: User) => (
<GroupMemberListItem
key={item.id}
user={item}

View File

@@ -89,7 +89,7 @@ function Export() {
<Trans>Recent exports</Trans>
</h2>
}
renderItem={(item) => (
renderItem={(item: FileOperation) => (
<FileOperationListItem
key={item.id}
fileOperation={item}

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { PlusIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Group from "~/models/Group";
import GroupNew from "~/scenes/GroupNew";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
@@ -61,7 +62,7 @@ function Groups() {
items={groups.orderedData}
empty={<Empty>{t("No groups have been created yet")}</Empty>}
fetch={groups.fetchPage}
renderItem={(item) => (
renderItem={(item: Group) => (
<GroupListItem
key={item.id}
group={item}

View File

@@ -6,6 +6,7 @@ import { useTranslation, Trans } from "react-i18next";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { cdnPath } from "@shared/utils/urls";
import FileOperation from "~/models/FileOperation";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Item from "~/components/List/Item";
@@ -142,7 +143,7 @@ function Import() {
<Trans>Recent imports</Trans>
</h2>
}
renderItem={(item) => (
renderItem={(item: FileOperation) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
/>

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { CodeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import ApiKey from "~/models/ApiKey";
import APITokenNew from "~/scenes/APITokenNew";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
@@ -59,7 +60,7 @@ function Tokens() {
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<Subheading sticky>{t("Tokens")}</Subheading>}
renderItem={(token) => (
renderItem={(token: ApiKey) => (
<TokenListItem key={token.id} token={token} onDelete={token.delete} />
)}
/>

View File

@@ -217,7 +217,7 @@ export default abstract class BaseStore<T extends BaseModel> {
}
@action
fetchPage = async (params: FetchPageParams | undefined): Promise<any> => {
fetchPage = async (params: FetchPageParams | undefined): Promise<T[]> => {
if (!this.actions.includes(RPCAction.List)) {
throw new Error(`Cannot list ${this.modelName}`);
}

View File

@@ -16,18 +16,22 @@ export default class CollectionGroupMembershipsStore extends BaseStore<
}
@action
fetchPage = async (params: PaginationParams | undefined): Promise<any> => {
fetchPage = async (
params: PaginationParams | undefined
): Promise<CollectionGroupMembership[]> => {
this.isFetching = true;
try {
const res = await client.post(`/collections.group_memberships`, params);
invariant(res?.data, "Data not available");
let models: CollectionGroupMembership[] = [];
runInAction(`CollectionGroupMembershipsStore#fetchPage`, () => {
res.data.groups.forEach(this.rootStore.groups.add);
res.data.collectionGroupMemberships.forEach(this.add);
models = res.data.collectionGroupMemberships.map(this.add);
this.isLoaded = true;
});
return res.data.groups;
return models;
} finally {
this.isFetching = false;
}

View File

@@ -408,6 +408,7 @@ export default class DocumentsStore extends BaseStore<Document> {
return null;
}
return {
id: document.id,
ranking: result.ranking,
context: result.context,
document,

View File

@@ -15,18 +15,22 @@ export default class GroupMembershipsStore extends BaseStore<GroupMembership> {
}
@action
fetchPage = async (params: PaginationParams | undefined): Promise<any> => {
fetchPage = async (
params: PaginationParams | undefined
): Promise<GroupMembership[]> => {
this.isFetching = true;
try {
const res = await client.post(`/groups.memberships`, params);
invariant(res?.data, "Data not available");
let models: GroupMembership[] = [];
runInAction(`GroupMembershipsStore#fetchPage`, () => {
res.data.users.forEach(this.rootStore.users.add);
res.data.groupMemberships.forEach(this.add);
models = res.data.groupMemberships.map(this.add);
this.isLoaded = true;
});
return res.data.users;
return models;
} finally {
this.isFetching = false;
}

View File

@@ -21,19 +21,21 @@ export default class GroupsStore extends BaseStore<Group> {
}
@action
fetchPage = async (params: FetchPageParams | undefined): Promise<any> => {
fetchPage = async (params: FetchPageParams | undefined): Promise<Group[]> => {
this.isFetching = true;
try {
const res = await client.post(`/groups.list`, params);
invariant(res?.data, "Data not available");
let models: Group[] = [];
runInAction(`GroupsStore#fetchPage`, () => {
this.addPolicies(res.policies);
res.data.groups.forEach(this.add);
models = res.data.groups.map(this.add);
res.data.groupMemberships.forEach(this.rootStore.groupMemberships.add);
this.isLoaded = true;
});
return res.data.groups;
return models;
} finally {
this.isFetching = false;
}
@@ -72,7 +74,7 @@ export default class GroupsStore extends BaseStore<Group> {
}
function queriedGroups(groups: Group[], query: string) {
return filter(groups, (group) =>
return groups.filter((group) =>
group.name.toLowerCase().match(query.toLowerCase())
);
}

View File

@@ -14,18 +14,22 @@ export default class MembershipsStore extends BaseStore<Membership> {
}
@action
fetchPage = async (params: PaginationParams | undefined): Promise<any> => {
fetchPage = async (
params: PaginationParams | undefined
): Promise<Membership[]> => {
this.isFetching = true;
try {
const res = await client.post(`/collections.memberships`, params);
invariant(res?.data, "Data not available");
runInAction(`/collections.memberships`, () => {
let models: Membership[] = [];
runInAction(`MembershipsStore#fetchPage`, () => {
res.data.users.forEach(this.rootStore.users.add);
res.data.memberships.forEach(this.add);
models = res.data.memberships.map(this.add);
this.isLoaded = true;
});
return res.data.users;
return models;
} finally {
this.isFetching = false;
}

View File

@@ -14,18 +14,22 @@ export default class PinsStore extends BaseStore<Pin> {
}
@action
fetchPage = async (params?: FetchParams | undefined): Promise<void> => {
fetchPage = async (params?: FetchParams | undefined): Promise<Pin[]> => {
this.isFetching = true;
try {
const res = await client.post(`/pins.list`, params);
invariant(res?.data, "Data not available");
let models: Pin[] = [];
runInAction(`PinsStore#fetchPage`, () => {
res.data.documents.forEach(this.rootStore.documents.add);
res.data.pins.forEach(this.add);
models = res.data.pins.map(this.add);
this.addPolicies(res.policies);
this.isLoaded = true;
});
return models;
} finally {
this.isFetching = false;
}

View File

@@ -48,17 +48,21 @@ export default class RevisionsStore extends BaseStore<Revision> {
}
@action
fetchPage = async (options: PaginationParams | undefined): Promise<any> => {
fetchPage = async (
options: PaginationParams | undefined
): Promise<Revision[]> => {
this.isFetching = true;
try {
const res = await client.post("/revisions.list", options);
invariant(res?.data, "Document revisions not available");
let models: Revision[] = [];
runInAction("RevisionsStore#fetchPage", () => {
res.data.forEach(this.add);
models = res.data.map(this.add);
this.isLoaded = true;
});
return res.data;
return models;
} finally {
this.isFetching = false;
}

View File

@@ -12,18 +12,23 @@ export default class StarsStore extends BaseStore<Star> {
}
@action
fetchPage = async (params?: PaginationParams | undefined): Promise<void> => {
fetchPage = async (
params?: PaginationParams | undefined
): Promise<Star[]> => {
this.isFetching = true;
try {
const res = await client.post(`/stars.list`, params);
invariant(res?.data, "Data not available");
let models: Star[] = [];
runInAction(`StarsStore#fetchPage`, () => {
res.data.documents.forEach(this.rootStore.documents.add);
res.data.stars.forEach(this.add);
models = res.data.stars.map(this.add);
this.addPolicies(res.policies);
this.isLoaded = true;
});
return models;
} finally {
this.isFetching = false;
}

View File

@@ -163,6 +163,7 @@ export type PaginationParams = {
};
export type SearchResult = {
id: string;
ranking: number;
context: string;
document: Document;

View File

@@ -4,6 +4,12 @@ import CollectionUser from "./CollectionUser";
import UserAuthentication from "./UserAuthentication";
beforeEach(() => flushdb());
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date("2018-01-02T00:00:00.000Z"));
});
afterAll(() => {
jest.useRealTimers();
});
describe("user model", () => {
describe("destroy", () => {

View File

@@ -11,6 +11,7 @@ Object {
"isViewer": false,
"lastActiveAt": undefined,
"name": "Test User",
"updatedAt": undefined,
}
`;
@@ -25,5 +26,6 @@ Object {
"isViewer": false,
"lastActiveAt": undefined,
"name": "Test User",
"updatedAt": undefined,
}
`;

View File

@@ -6,5 +6,6 @@ export default function present(key: ApiKey) {
name: key.name,
secret: key.secret,
createdAt: key.createdAt,
updatedAt: key.updatedAt,
};
}

View File

@@ -13,5 +13,6 @@ export default function present(data: FileOperation) {
collectionId: data.collectionId,
user: presentUser(data.user),
createdAt: data.createdAt,
updatedAt: data.updatedAt,
};
}

View File

@@ -9,6 +9,7 @@ type UserPresentation = {
name: string;
avatarUrl: string | null | undefined;
createdAt: Date;
updatedAt: Date;
lastActiveAt: Date | null;
color: string;
isAdmin: boolean;
@@ -31,6 +32,7 @@ export default (
isSuspended: user.isSuspended,
isViewer: user.isViewer,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastActiveAt: user.lastActiveAt,
};

View File

@@ -14,6 +14,7 @@ Object {
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"updatedAt": "2018-01-02T00:00:00.000Z",
},
"ok": true,
"policies": Array [
@@ -68,6 +69,7 @@ Object {
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"updatedAt": "2018-01-02T00:00:00.000Z",
},
"ok": true,
"policies": Array [
@@ -104,6 +106,7 @@ Object {
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"updatedAt": "2018-01-02T00:00:00.000Z",
},
"ok": true,
"policies": Array [
@@ -140,6 +143,7 @@ Object {
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"updatedAt": "2018-01-02T00:00:00.000Z",
},
"ok": true,
"policies": Array [
@@ -194,6 +198,7 @@ Object {
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"updatedAt": "2018-01-02T00:00:00.000Z",
},
"ok": true,
"policies": Array [
@@ -257,6 +262,7 @@ Object {
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
"updatedAt": "2018-01-02T00:00:00.000Z",
},
"ok": true,
"policies": Array [

View File

@@ -6,7 +6,14 @@ import { flushdb, seed } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date("2018-01-02T00:00:00.000Z"));
});
afterAll(() => {
jest.useRealTimers();
return server.close();
});
describe("#users.list", () => {
it("should allow filtering by user name", async () => {

View File

@@ -135,7 +135,8 @@ export async function buildUser(overrides: Partial<User> = {}) {
name: `User ${count}`,
username: `user${count}`,
createdAt: new Date("2018-01-01T00:00:00.000Z"),
lastActiveAt: new Date("2018-01-01T00:00:00.000Z"),
updatedAt: new Date("2018-01-02T00:00:00.000Z"),
lastActiveAt: new Date("2018-01-03T00:00:00.000Z"),
authentications: [
{
authenticationProviderId: authenticationProvider!.id,