diff --git a/src/core/client/admin/containers/EntryContainer.tsx b/src/core/client/admin/containers/EntryContainer.tsx
index f04dd40c3..183a3ad97 100644
--- a/src/core/client/admin/containers/EntryContainer.tsx
+++ b/src/core/client/admin/containers/EntryContainer.tsx
@@ -1,9 +1,11 @@
import { BrowserProtocol, queryMiddleware } from "farce";
-import { createFarceRouter, createRender } from "found";
+import { createFarceRouter, ElementsRenderer } from "found";
import { Resolver } from "found-relay";
-
import React, { StatelessComponent } from "react";
+import TransitionControl from "talk-framework/testHelpers/TransitionControl";
+
import { TalkContextConsumer } from "talk-framework/lib/bootstrap/TalkContext";
+
import routeConfig from "../routeConfig";
import NotFound from "../routes/NotFound";
@@ -11,11 +13,16 @@ const Router = createFarceRouter({
historyProtocol: new BrowserProtocol(),
historyMiddlewares: [queryMiddleware],
routeConfig,
- render: createRender({
- renderError: ({ error }) => (
-
{error.status === 404 ? : "Error"}
- ),
- }),
+ renderReady: ({ elements }) => (
+ <>
+
+ {// this enables router transition control when writing tests.
+ process.env.NODE_ENV === "test" && }
+ >
+ ),
+ renderError: ({ error }) => (
+ {error.status === 404 ? : "Error"}
+ ),
});
const EntryContainer: StatelessComponent = () => (
diff --git a/src/core/client/admin/routes/moderate/components/Moderate.tsx b/src/core/client/admin/routes/moderate/components/Moderate.tsx
index 49fe6d263..800383641 100644
--- a/src/core/client/admin/routes/moderate/components/Moderate.tsx
+++ b/src/core/client/admin/routes/moderate/components/Moderate.tsx
@@ -27,7 +27,7 @@ const Moderate: StatelessComponent = ({
}) => (
-`;
-
exports[`rejected queue accepts comment in rejected queue: count should be 1 1`] = `
`;
+exports[`search bar all stories active search with no results 1`] = `
+
+`;
+
+exports[`search bar all stories active search with too many results 1`] = `
+
+
+
+ See all results
+
+
+ arrow_forward
+
+
+
+`;
+
+exports[`search bar all stories renders search bar 1`] = `
+
+`;
+
exports[`single comment view accepts single comment 1`] = `
`;
+
+exports[`tab bar renders tab bar (empty queues) 1`] = `
+
+`;
diff --git a/src/core/client/admin/test/moderate/moderate.spec.tsx b/src/core/client/admin/test/moderate/moderate.spec.tsx
index d60f74abc..1b30c4d44 100644
--- a/src/core/client/admin/test/moderate/moderate.spec.tsx
+++ b/src/core/client/admin/test/moderate/moderate.spec.tsx
@@ -12,20 +12,27 @@ import {
createQueryResolverStub,
createResolversStub,
CreateTestRendererParams,
+ findParentWithType,
replaceHistoryLocation,
toJSON,
+ wait,
waitForElement,
waitUntilThrow,
within,
} from "talk-framework/testHelpers";
+import { noop } from "lodash";
+import { ReactTestRenderer } from "react-test-renderer";
import create from "../create";
import {
emptyModerationQueues,
emptyRejectedComments,
+ emptyStories,
rejectedComments,
reportedComments,
settings,
+ stories,
+ storyConnection,
users,
} from "../fixtures";
@@ -38,7 +45,7 @@ beforeEach(async () => {
async function createTestRenderer(
params: CreateTestRendererParams = {}
) {
- const { testRenderer } = create({
+ const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub({
@@ -58,21 +65,236 @@ async function createTestRenderer(
}
},
});
- return testRenderer;
+ return { testRenderer, context };
}
-describe("navigation bar", () => {
- it("renders navigation bar (empty queues)", async () => {
- const testRenderer = await createTestRenderer();
+describe("search bar", () => {
+ const openSearchBar = async (testRenderer: ReactTestRenderer) => {
+ const searchBar = await waitForElement(() =>
+ within(testRenderer.root).getByTestID("moderate-searchBar-container")
+ );
+ const textField = within(searchBar).getByLabelText(
+ "Search or jump to story..."
+ );
+ const form = findParentWithType(textField, "form")!;
+ textField.props.onFocus({});
+ return { searchBar, textField, form };
+ };
+
+ describe("all stories", () => {
+ it("renders search bar", async () => {
+ const { testRenderer } = await createTestRenderer();
+ const searchBar = await waitForElement(() =>
+ within(testRenderer.root).getByTestID("moderate-searchBar-container")
+ );
+ expect(within(searchBar).toJSON()).toMatchSnapshot();
+ });
+
+ describe("active", () => {
+ it("search with no results", async () => {
+ const query = "InterestingStory";
+ const { testRenderer } = await createTestRenderer({
+ resolvers: createResolversStub({
+ Query: {
+ stories: ({ variables }) => {
+ expectAndFail(variables.query).toBe(query);
+ return emptyStories;
+ },
+ },
+ }),
+ });
+ const { searchBar, textField, form } = await openSearchBar(
+ testRenderer
+ );
+ expect(within(searchBar).toJSON()).toMatchSnapshot();
+
+ // Search for sth.
+ textField.props.onChange(query);
+ form.props.onSubmit();
+
+ // Ensure no results message is shown.
+ await wait(() =>
+ within(searchBar).getByText("No results", { exact: false })
+ );
+
+ // Blurring should close the listbox.
+ textField.props.onBlur({});
+ expect(within(searchBar).queryByText("No results")).toBeNull();
+ });
+ it("search with actual results", async () => {
+ const query = "InterestingStory";
+ const {
+ testRenderer,
+ context: { transitionControl },
+ } = await createTestRenderer({
+ resolvers: createResolversStub({
+ Query: {
+ stories: ({ variables }) => {
+ expectAndFail(variables.query).toBe(query);
+ return storyConnection;
+ },
+ },
+ }),
+ });
+ transitionControl.allowTransition = false;
+ const { searchBar, textField, form } = await openSearchBar(
+ testRenderer
+ );
+
+ // Search for sth.
+ textField.props.onChange(query);
+ form.props.onSubmit();
+
+ const story = storyConnection.edges[0].node;
+
+ // Find the story in the search results.
+ const storyOption = findParentWithType(
+ await waitForElement(() =>
+ within(searchBar).getByText(story.metadata!.title!, {
+ exact: false,
+ })
+ ),
+ "li"
+ )!;
+
+ // Go to story.
+ storyOption.props.onClick({ button: 0, preventDefault: noop });
+
+ // Expect a routing request was made to the right url.
+ expect(transitionControl.history[0].pathname).toBe(
+ `/admin/moderate/${story.id}`
+ );
+ });
+ it("search with too many results", async () => {
+ const query = "InterestingStory";
+ const {
+ testRenderer,
+ context: { transitionControl },
+ } = await createTestRenderer({
+ resolvers: createResolversStub({
+ Query: {
+ stories: ({ variables }) => {
+ expectAndFail(variables.query).toBe(query);
+ return pureMerge(storyConnection, {
+ pageInfo: { hasNextPage: true },
+ });
+ },
+ },
+ }),
+ });
+ transitionControl.allowTransition = false;
+ const { searchBar, textField, form } = await openSearchBar(
+ testRenderer
+ );
+
+ // Search for sth.
+ textField.props.onChange(query);
+ form.props.onSubmit();
+
+ // Find see all options in the search results.
+ const seeAllOption = findParentWithType(
+ await waitForElement(() =>
+ within(searchBar).getByText("See all results", { exact: false })
+ ),
+ "li"
+ )!;
+
+ expect(within(seeAllOption).toJSON()).toMatchSnapshot();
+
+ // Go to story.
+ seeAllOption.props.onClick({ button: 0, preventDefault: noop });
+
+ // Expect a routing request was made to the right url.
+ expect(transitionControl.history[0].pathname).toBe("/admin/stories");
+ expect(transitionControl.history[0].search).toBe(`?q=${query}`);
+ });
+ });
+ });
+ describe("specified story", () => {
+ beforeEach(() => {
+ replaceHistoryLocation(
+ `http://localhost/admin/moderate/${stories[0].id}`
+ );
+ });
+ it("renders search bar", async () => {
+ const { testRenderer } = await createTestRenderer({
+ resolvers: createResolversStub({
+ Query: {
+ story: () => stories[0],
+ },
+ }),
+ });
+ const searchBar = await waitForElement(() =>
+ within(testRenderer.root).getByTestID("moderate-searchBar-container")
+ );
+ const textField = within(searchBar).getByLabelText(
+ "Search or jump to story..."
+ );
+ expect(textField.props.placeholder).toBe(stories[0].metadata!.title);
+ });
+ it("shows moderate all option", async () => {
+ const {
+ testRenderer,
+ context: { transitionControl },
+ } = await createTestRenderer({
+ resolvers: createResolversStub({
+ Query: {
+ story: () => stories[0],
+ },
+ }),
+ });
+ transitionControl.allowTransition = false;
+ const { searchBar } = await openSearchBar(testRenderer);
+
+ // Find see all options in the search results.
+ const moderateAllOptions = findParentWithType(
+ await waitForElement(() =>
+ within(searchBar).getByText("Moderate all", { exact: false })
+ ),
+ "li"
+ )!;
+
+ // Activate moderate all.
+ moderateAllOptions.props.onClick({ button: 0, preventDefault: noop });
+
+ // Expect a routing request was made to the right url.
+ expect(transitionControl.history[0].pathname).toBe("/admin/moderate");
+ });
+ });
+});
+
+describe("tab bar", () => {
+ it("renders tab bar (empty queues)", async () => {
+ const { testRenderer } = await createTestRenderer();
const { getByTestID } = within(testRenderer.root);
await waitForElement(() => getByTestID("moderate-container"));
- expect(toJSON(getByTestID("moderate-subBar-container"))).toMatchSnapshot();
+ expect(toJSON(getByTestID("moderate-tabBar-container"))).toMatchSnapshot();
+ });
+});
+
+describe("moderating specific story", () => {
+ it("passes storyID to the endpoints", async () => {
+ replaceHistoryLocation(`http://localhost/admin/moderate/${stories[0].id}`);
+ await createTestRenderer({
+ resolvers: createResolversStub({
+ Query: {
+ moderationQueues: ({ variables }) => {
+ expectAndFail(variables.storyID).toBe(stories[0].id);
+ return emptyModerationQueues;
+ },
+ comments: ({ variables }) => {
+ expectAndFail(variables.storyID).toBe(stories[0].id);
+ return emptyRejectedComments;
+ },
+ },
+ }),
+ });
});
});
describe("reported queue", () => {
it("renders empty reported queue", async () => {
- const testRenderer = await createTestRenderer();
+ const { testRenderer } = await createTestRenderer();
const { getByTestID } = within(testRenderer.root);
await waitForElement(() => getByTestID("moderate-container"));
@@ -81,14 +303,14 @@ describe("reported queue", () => {
it("renders empty pending queue", async () => {
replaceHistoryLocation("http://localhost/admin/moderate/pending");
- const testRenderer = await createTestRenderer();
+ const { testRenderer } = await createTestRenderer();
const { getByText } = within(testRenderer.root);
await waitForElement(() => getByText("no more pending", { exact: false }));
});
it("renders empty unmoderated queue", async () => {
replaceHistoryLocation("http://localhost/admin/moderate/unmoderated");
- const testRenderer = await createTestRenderer();
+ const { testRenderer } = await createTestRenderer();
const { getByText } = within(testRenderer.root);
await waitForElement(() =>
getByText("comments have been moderated", { exact: false })
@@ -97,7 +319,7 @@ describe("reported queue", () => {
it("renders empty rejected queue", async () => {
replaceHistoryLocation("http://localhost/admin/moderate/rejected");
- const testRenderer = await createTestRenderer();
+ const { testRenderer } = await createTestRenderer();
const { getByText } = within(testRenderer.root);
await waitForElement(() =>
getByText("no rejected comments", { exact: false })
@@ -105,7 +327,7 @@ describe("reported queue", () => {
});
it("renders reported queue with comments", async () => {
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub({
Query: {
moderationQueues: () =>
@@ -191,7 +413,7 @@ describe("reported queue", () => {
},
});
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub({
Query: {
moderationQueues: () => moderationQueuesStub,
@@ -278,7 +500,7 @@ describe("reported queue", () => {
},
});
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub({
Query: {
moderationQueues: () => moderationQueuesStub,
@@ -333,7 +555,7 @@ describe("reported queue", () => {
};
});
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub({
Query: {
moderationQueues: () =>
@@ -400,7 +622,7 @@ describe("rejected queue", () => {
});
it("renders rejected queue with comments", async () => {
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub({
Query: {
comments: ({ variables }) => {
@@ -435,7 +657,7 @@ describe("rejected queue", () => {
});
it("renders rejected queue with comments and load more", async () => {
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub({
Query: {
comments: ({ variables, callCount }) => {
@@ -540,7 +762,7 @@ describe("rejected queue", () => {
};
});
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub({
Query: {
comments: ({ variables }) => {
@@ -613,7 +835,7 @@ describe("single comment view", () => {
});
it("renders single comment view", async () => {
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: {
Query: {
comment: commentStub,
@@ -644,7 +866,7 @@ describe("single comment view", () => {
};
});
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: {
Query: {
comment: commentStub,
@@ -683,7 +905,7 @@ describe("single comment view", () => {
};
});
- const testRenderer = await createTestRenderer({
+ const { testRenderer } = await createTestRenderer({
resolvers: {
Query: {
comment: commentStub,
diff --git a/src/core/client/admin/test/stories/stories.spec.tsx b/src/core/client/admin/test/stories/stories.spec.tsx
index 305ff2452..c665a9dce 100644
--- a/src/core/client/admin/test/stories/stories.spec.tsx
+++ b/src/core/client/admin/test/stories/stories.spec.tsx
@@ -1,3 +1,4 @@
+import { noop } from "lodash";
import TestRenderer from "react-test-renderer";
import { pureMerge } from "talk-common/utils";
@@ -36,7 +37,7 @@ beforeEach(async () => {
async function createTestRenderer(
params: CreateTestRendererParams = {}
) {
- const { testRenderer } = create({
+ const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub({
@@ -62,7 +63,7 @@ async function createTestRenderer(
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("stories-container")
);
- return { testRenderer, container };
+ return { testRenderer, container, context };
}
it("renders stories", async () => {
@@ -81,6 +82,26 @@ it("renders empty stories", async () => {
expect(within(container).toJSON()).toMatchSnapshot();
});
+it("goes to moderation when clicking on title", async () => {
+ const {
+ container,
+ context: { transitionControl },
+ } = await createTestRenderer();
+
+ // Prevent router transitions.
+ transitionControl.allowTransition = false;
+
+ const story = storyConnection.edges[0].node;
+ within(container)
+ .getByText(story.metadata!.title!)
+ .props.onClick({ button: 0, preventDefault: noop });
+
+ // Expect a routing request was made to the right url.
+ expect(transitionControl.history[0].pathname).toBe(
+ `/admin/moderate/${story.id}`
+ );
+});
+
it("filter by status", async () => {
const { container } = await createTestRenderer({
resolvers: createResolversStub({
@@ -268,3 +289,24 @@ it("filter by search", async () => {
within(container).getByText("could not find any", { exact: false })
);
});
+
+it("use searchFilter from url", async () => {
+ const searchFilter = "CandyMountain";
+ replaceHistoryLocation(`http://localhost/admin/stories?q=${searchFilter}`);
+ const { container } = await createTestRenderer({
+ resolvers: createResolversStub({
+ Query: {
+ stories: ({ variables }) => {
+ expectAndFail(variables.query).toBe(searchFilter);
+ return emptyStories;
+ },
+ },
+ }),
+ });
+
+ const searchField = within(container).getByLabelText(
+ "Search by story title",
+ { exact: false }
+ );
+ expect(searchField.props.value).toBe(searchFilter);
+});
diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx
index 49b2479d6..16a7c54fb 100644
--- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx
+++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx
@@ -11,6 +11,7 @@ import { BrowserInfo } from "talk-framework/lib/browserInfo";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { PromisifiedStorage } from "talk-framework/lib/storage";
+import { TransitionControlData } from "talk-framework/testHelpers";
import { UIContext } from "talk-ui/components";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
@@ -62,6 +63,9 @@ export interface TalkContext {
/** Clear session data. */
clearSession: () => Promise;
+
+ /** Controls router transitions (for tests) */
+ transitionControl?: TransitionControlData;
}
export const TalkReactContext = React.createContext({} as any);
diff --git a/src/core/client/framework/testHelpers/TransitionControl.tsx b/src/core/client/framework/testHelpers/TransitionControl.tsx
new file mode 100644
index 000000000..29eaa83a7
--- /dev/null
+++ b/src/core/client/framework/testHelpers/TransitionControl.tsx
@@ -0,0 +1,43 @@
+import { Location, Match, Router, withRouter } from "found";
+import React, { useEffect } from "react";
+import { withContext } from "talk-framework/lib/bootstrap";
+
+interface Props {
+ /** router is injected by `withRouter` HOC */
+ router: Router;
+ /** match is injected by `withRouter` HOC */
+ match: Match;
+ /** transitionControl is injected by `withContext` HOC */
+ transitionControl: TransitionControlData | undefined;
+}
+
+/**
+ * TransitionControlData allows controlling router transition.
+ */
+export interface TransitionControlData {
+ /** allowTransition if set to false, will prevent router transitions from happening. */
+ allowTransition: boolean;
+ /** history contains all records of router transition requests. */
+ history: Location[];
+}
+
+const TransitionControl: React.FunctionComponent = props => {
+ useEffect(() => {
+ return props.router.addTransitionHook(location => {
+ if (props.transitionControl) {
+ props.transitionControl.history.push(location);
+ if (!props.transitionControl.allowTransition) {
+ return false;
+ }
+ }
+ return;
+ });
+ }, []);
+ return null;
+};
+
+const enhanced = withContext(({ transitionControl }) => ({
+ transitionControl,
+}))(withRouter(TransitionControl));
+
+export default enhanced;
diff --git a/src/core/client/framework/testHelpers/createTestRenderer.tsx b/src/core/client/framework/testHelpers/createTestRenderer.tsx
index c1f358ea4..106a72769 100644
--- a/src/core/client/framework/testHelpers/createTestRenderer.tsx
+++ b/src/core/client/framework/testHelpers/createTestRenderer.tsx
@@ -6,6 +6,7 @@ import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
+import { RequireProperty } from "talk-common/types";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
@@ -77,7 +78,7 @@ export default function createTestRenderer<
},
});
- const context: TalkContext = {
+ const context: RequireProperty = {
relayEnvironment: environment,
locales: ["en-US"],
localeBundles: [
@@ -94,6 +95,10 @@ export default function createTestRenderer<
uuidGenerator: createUUIDGenerator(),
eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }),
clearSession: () => Promise.resolve(),
+ transitionControl: {
+ allowTransition: true,
+ history: [],
+ },
};
let testRenderer: ReactTestRenderer;
diff --git a/src/core/client/framework/testHelpers/index.ts b/src/core/client/framework/testHelpers/index.ts
index b050d1c7b..7a9b9d109 100644
--- a/src/core/client/framework/testHelpers/index.ts
+++ b/src/core/client/framework/testHelpers/index.ts
@@ -36,3 +36,7 @@ export {
CreateTestRendererParams,
} from "./createTestRenderer";
export { default as createResolversStub } from "./createResolversStub";
+export {
+ TransitionControlData,
+ default as TransitionControl,
+} from "./TransitionControl";
diff --git a/src/core/client/test/mocks.ts b/src/core/client/test/mocks.ts
index d41224454..adfd30d08 100644
--- a/src/core/client/test/mocks.ts
+++ b/src/core/client/test/mocks.ts
@@ -9,3 +9,20 @@ jest.mock("react-dom", () => ({
...require.requireActual("react-dom"),
createPortal: (node: any) => node,
}));
+
+jest.mock("popper.js", () => {
+ const PopperJS = require.requireActual("popper.js");
+
+ return class Popper {
+ public static placements = PopperJS.placements;
+
+ constructor() {
+ return {
+ // tslint:disable-next-line:no-empty
+ destroy: () => {},
+ // tslint:disable-next-line:no-empty
+ scheduleUpdate: () => {},
+ };
+ }
+ };
+});
diff --git a/src/core/common/types.ts b/src/core/common/types.ts
index 973cd7e05..dea60e9b0 100644
--- a/src/core/common/types.ts
+++ b/src/core/common/types.ts
@@ -7,7 +7,7 @@ export type Overwrite = Pick> & U;
export type Sub = Pick>;
-export type RequireProperty = Omit, P> &
+export type RequireProperty = Omit &
Required>;
/**
diff --git a/src/types/found.d.ts b/src/types/found.d.ts
new file mode 100644
index 000000000..02484ba43
--- /dev/null
+++ b/src/types/found.d.ts
@@ -0,0 +1,7 @@
+import { FunctionComponent } from "react";
+
+declare module "found" {
+ const ElementsRenderer: FunctionComponent<{
+ elements: ReactElementOrGroup[];
+ }>;
+}