Merge pull request #1784 from coralproject/use-anchor-in-permalink

[next] Use anchor for better accessibility and restore history
This commit is contained in:
Belén Curcio
2018-08-08 08:22:24 -03:00
committed by GitHub
24 changed files with 264 additions and 115 deletions
@@ -1,3 +1,7 @@
import simulant from "simulant";
import sinon from "sinon";
import { CleanupCallback } from ".";
import withSetCommentID from "./withSetCommentID";
it("should add commentID", () => {
@@ -10,8 +14,9 @@ it("should add commentID", () => {
}
},
};
withSetCommentID(fakePym as any);
const cleanup = withSetCommentID(fakePym as any) as CleanupCallback;
expect(location.toString()).toBe("http://localhost/?commentID=comment-id");
cleanup();
window.history.replaceState(previousState, document.title, previousLocation);
});
@@ -30,7 +35,31 @@ it("should remove commentID", () => {
}
},
};
withSetCommentID(fakePym as any);
const cleanup = withSetCommentID(fakePym as any) as CleanupCallback;
expect(location.toString()).toBe("http://localhost/");
cleanup();
window.history.replaceState(previousState, document.title, previousLocation);
});
it("should send commentID over pym when history changes", () => {
const previousLocation = location.toString();
const previousState = window.history.state;
window.history.replaceState(
previousState,
document.title,
"http://localhost/?commentID=comment-id"
);
const fakePym = {
onMessage: sinon.stub(),
sendMessage: sinon
.mock()
.once()
.withArgs("setCommentID", "comment-id"),
};
const cleanup = withSetCommentID(fakePym as any) as CleanupCallback;
simulant.fire(window as any, "popstate");
cleanup();
simulant.fire(window as any, "popstate");
fakePym.sendMessage.verify();
window.history.replaceState(previousState, document.title, previousLocation);
});
@@ -3,6 +3,10 @@ import qs from "query-string";
import { buildURL } from "../utils";
import { Decorator } from "./";
function getCurrentCommentID() {
return qs.parse(location.search).commentID;
}
const withSetCommentID: Decorator = pym => {
// Add the permalink comment id to the query.
pym.onMessage("setCommentID", (id: string) => {
@@ -15,8 +19,20 @@ const withSetCommentID: Decorator = pym => {
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
window.history.pushState({}, document.title, url);
});
// Send new commentID when history state changes.
const sendSetCommentID = (e: Event) => {
const commentID = getCurrentCommentID();
pym.sendMessage("setCommentID", commentID);
};
window.addEventListener("popstate", sendSetCommentID);
// Cleanup.
return () => {
window.removeEventListener("popstate", sendSetCommentID);
};
};
export default withSetCommentID;
@@ -0,0 +1,18 @@
import buildURL from "./buildURL";
it("should default to window.location", () => {
const url = buildURL();
expect(url).toBe("http://localhost/");
});
it("should build from parameters", () => {
const url = buildURL({
protocol: "https",
hostname: "hostname",
port: "8080",
pathname: "/pathname",
search: "search",
hash: "#hash",
});
expect(url).toBe("https//hostname:8080/pathname?search#hash");
});
@@ -0,0 +1,17 @@
export default function buildURL({
protocol = window.location.protocol,
hostname = window.location.hostname,
port = window.location.port,
pathname = window.location.pathname,
search = window.location.search,
hash = window.location.hash,
} = {}) {
if (search && search[0] !== "?") {
search = `?${search}`;
} else if (search === "?") {
search = "";
}
return `${protocol}//${hostname}${
port ? `:${port}` : ""
}${pathname}${search}${hash}`;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as buildURL } from "./buildURL";
export { default as parseURL } from "./parseURL";
@@ -0,0 +1,20 @@
import parseURL from "./parseURL";
it("should parse url", () => {
const testCases: [[string, ReturnType<typeof parseURL>]] = [
[
"https://coralproject.net",
{
protocol: "https:",
hostname: "coralproject.net",
port: "",
pathname: "/",
search: "",
hash: "",
},
],
];
testCases.forEach(([url, expected]) => {
expect(parseURL(url)).toEqual(expected);
});
});
@@ -0,0 +1,14 @@
import { pick } from "lodash";
export default function parseURL(url: string) {
const parser = document.createElement("a");
parser.href = url;
return pick(parser, [
"protocol",
"hostname",
"port",
"pathname",
"search",
"hash",
]);
}
@@ -1,4 +1,4 @@
import React, { StatelessComponent } from "react";
import React, { MouseEvent, StatelessComponent } from "react";
import { Button, Flex, Typography } from "talk-ui/components";
@@ -7,25 +7,28 @@ import * as styles from "./PermalinkView.css";
export interface PermalinkViewProps {
comment: {} | null;
assetURL: string | null;
onShowAllComments: () => void;
showAllCommentsHref: string | null;
onShowAllComments: (e: MouseEvent<any>) => void;
}
const PermalinkView: StatelessComponent<PermalinkViewProps> = ({
assetURL,
showAllCommentsHref,
comment,
onShowAllComments,
}) => {
return (
<div className={styles.root}>
{assetURL && (
{showAllCommentsHref && (
<Button
id="talk-comments-permalinkView-showAllComments"
variant="outlined"
color="primary"
onClick={onShowAllComments}
className={styles.button}
href={showAllCommentsHref}
target="_parent"
fullWidth
anchor
>
Show all Comments
</Button>
@@ -1,54 +1,67 @@
import React from "react";
import { Child as PymChild } from "pym.js";
import qs from "query-string";
import React, { MouseEvent } from "react";
import { graphql } from "react-relay";
import { withContext } from "talk-framework/lib/bootstrap";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PermalinkViewContainer_asset as AssetData } from "talk-stream/__generated__/PermalinkViewContainer_asset.graphql";
import { buildURL, parseURL } from "talk-framework/utils";
import { PermalinkViewContainer_comment as CommentData } from "talk-stream/__generated__/PermalinkViewContainer_comment.graphql";
import {
SetCommentIDMutation,
withSetCommentIDMutation,
} from "talk-stream/mutations";
import PermalinkView from "../components/PermalinkView";
interface PermalinkViewContainerProps {
comment: CommentData | null;
asset: AssetData | null;
setCommentID: SetCommentIDMutation;
pym: PymChild | undefined;
}
class PermalinkViewContainer extends React.Component<
PermalinkViewContainerProps
> {
private showAllComments = () => {
private showAllComments = (e: MouseEvent<any>) => {
this.props.setCommentID({ id: null });
e.preventDefault();
};
private getShowAllCommentsHref() {
const { pym } = this.props;
const urlParts = parseURL((pym && pym.parentUrl) || window.location.href);
const search = qs.stringify({
...qs.parse(urlParts.search),
commentID: undefined,
});
// Remove the commentId url param.
return buildURL({ ...urlParts, search });
}
public render() {
const { comment, asset } = this.props;
const { comment } = this.props;
return (
<PermalinkView
comment={comment}
assetURL={(asset && asset.url) || null}
showAllCommentsHref={this.getShowAllCommentsHref()}
onShowAllComments={this.showAllComments}
/>
);
}
}
const enhanced = withSetCommentIDMutation(
withFragmentContainer<{
comment: CommentData | null;
asset: AssetData | null;
}>({
comment: graphql`
fragment PermalinkViewContainer_comment on Comment {
...CommentContainer
}
`,
asset: graphql`
fragment PermalinkViewContainer_asset on Asset {
url
}
`,
})(PermalinkViewContainer)
const enhanced = withContext(ctx => ({
pym: ctx.pym,
}))(
withSetCommentIDMutation(
withFragmentContainer<{
comment: CommentData | null;
}>({
comment: graphql`
fragment PermalinkViewContainer_comment on Comment {
...CommentContainer
}
`,
})(PermalinkViewContainer)
)
);
export default enhanced;
+8 -4
View File
@@ -1,4 +1,4 @@
import pym from "pym.js";
import { Child as PymChild } from "pym.js";
import React from "react";
import { StatelessComponent } from "react";
import ReactDOM from "react-dom";
@@ -12,10 +12,14 @@ import {
import AppContainer from "./containers/AppContainer";
import { initLocalState } from "./local";
import localesData from "./locales";
import { withSetCommentID } from "./pym";
const pymFeatures = [withSetCommentID];
// This is called when the context is first initialized.
async function init({ relayEnvironment }: TalkContext) {
await initLocalState(relayEnvironment);
async function init(context: TalkContext) {
await initLocalState(context.relayEnvironment);
pymFeatures.forEach(f => f(context));
}
async function main() {
@@ -24,7 +28,7 @@ async function main() {
init,
localesData,
userLocales: navigator.languages,
pym: new pym.Child({ polling: 100 }),
pym: new PymChild({ polling: 100 }),
});
const Index: StatelessComponent = () => (
+1
View File
@@ -0,0 +1 @@
export { default as withSetCommentID } from "./withSetCommentID";
@@ -0,0 +1,45 @@
import { Environment, RecordSource } from "relay-runtime";
import { LOCAL_ID } from "talk-framework/lib/relay";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import withSetCommentID from "./withSetCommentID";
let relayEnvironment: Environment;
const source: RecordSource = new RecordSource();
beforeAll(() => {
relayEnvironment = createRelayEnvironment({
source,
});
});
it("Sets comment id", () => {
const id = "comment1-id";
const context = {
pym: {
onMessage: (eventName: string, cb: (id: string) => void) => {
expect(eventName).toBe("setCommentID");
cb(id);
},
},
relayEnvironment,
};
withSetCommentID(context as any);
expect(source.get(LOCAL_ID)!.commentID).toEqual(id);
});
it("Sets comment id to null when empty", () => {
const id = "";
const context = {
pym: {
onMessage: (eventName: string, cb: (data: string) => void) => {
expect(eventName).toBe("setCommentID");
cb(id);
},
},
relayEnvironment,
};
withSetCommentID(context as any);
expect(source.get(LOCAL_ID)!.commentID).toEqual(null);
});
@@ -0,0 +1,19 @@
import { commitLocalUpdate } from "react-relay";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { LOCAL_ID } from "talk-framework/lib/relay";
export default function withSetCommentID({
relayEnvironment,
pym,
}: TalkContext) {
// Sets comment id through pym.
pym!.onMessage("setCommentID", raw => {
commitLocalUpdate(relayEnvironment, s => {
const id = raw || null;
if (s.get(LOCAL_ID)!.getValue("commentID") !== id) {
s.get(LOCAL_ID)!.setValue(id, "commentID");
}
});
});
}
@@ -6,7 +6,6 @@ import { render } from "./PermalinkViewQuery";
it("renders permalink view container", () => {
const data = {
props: {
asset: {},
comment: {},
} as any,
error: null,
@@ -27,29 +27,23 @@ export const render = ({
return <div>{error.message}</div>;
}
if (props) {
return (
<PermalinkViewContainer asset={props.asset} comment={props.comment} />
);
return <PermalinkViewContainer comment={props.comment} />;
}
return <div>Loading</div>;
};
const PermalinkViewQuery: StatelessComponent<InnerProps> = ({
local: { commentID, assetID },
local: { commentID },
}) => (
<QueryRenderer<PermalinkViewQueryVariables, PermalinkViewQueryResponse>
query={graphql`
query PermalinkViewQuery($assetID: ID!, $commentID: ID!) {
asset(id: $assetID) {
...PermalinkViewContainer_asset
}
query PermalinkViewQuery($commentID: ID!) {
comment(id: $commentID) {
...PermalinkViewContainer_comment
}
}
`}
variables={{
assetID,
commentID,
}}
render={render}
@@ -59,7 +53,6 @@ const PermalinkViewQuery: StatelessComponent<InnerProps> = ({
const enhanced = withLocalStateContainer<Local>(
graphql`
fragment PermalinkViewQueryLocal on Local {
assetID
commentID
}
`
@@ -13,8 +13,7 @@ exports[`renders loading 1`] = `
`;
exports[`renders permalink view container 1`] = `
<withContext(createMutationContainer(Relay(PermalinkViewContainer)))
asset={Object {}}
<withContext(withContext(createMutationContainer(Relay(PermalinkViewContainer))))
comment={Object {}}
/>
`;
@@ -7,8 +7,9 @@ exports[`renders permalink view 1`] = `
<div
className="PermalinkView-root"
>
<button
<a
className="BaseButton-root Button-root PermalinkView-button Button-sizeRegular Button-colorPrimary Button-variantOutlined Button-fullWidth"
href="http://localhost/"
id="talk-comments-permalinkView-showAllComments"
onBlur={[Function]}
onClick={[Function]}
@@ -17,9 +18,10 @@ exports[`renders permalink view 1`] = `
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
target="_parent"
>
Show all Comments
</button>
</a>
<div
className="Flex-root Flex-alignFlexStart Flex-directionColumn"
>
@@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders permalink view with unknown asset 1`] = `
<div
className="Flex-root App-root Flex-justifyCenter Flex-alignCenter"
>
<div
className="PermalinkView-root"
>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Comment not found
</p>
</div>
</div>
`;
@@ -7,8 +7,9 @@ exports[`renders permalink view with unknown comment 1`] = `
<div
className="PermalinkView-root"
>
<button
<a
className="BaseButton-root Button-root PermalinkView-button Button-sizeRegular Button-colorPrimary Button-variantOutlined Button-fullWidth"
href="http://localhost/"
id="talk-comments-permalinkView-showAllComments"
onBlur={[Function]}
onClick={[Function]}
@@ -17,9 +18,10 @@ exports[`renders permalink view with unknown comment 1`] = `
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
target="_parent"
>
Show all Comments
</button>
</a>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
@@ -75,11 +75,15 @@ it("renders permalink view", async () => {
});
it("show all comments", async () => {
const mockEvent = {
preventDefault: sinon.mock().once(),
};
testRenderer.root
.findByProps({
id: "talk-comments-permalinkView-showAllComments",
})
.props.onClick();
.props.onClick(mockEvent);
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot();
mockEvent.preventDefault.verify();
});
@@ -1,45 +0,0 @@
import React from "react";
import TestRenderer from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
const resolvers = {
Query: {
comment: () => null,
asset: () => null,
},
};
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
projectName: "tenant",
},
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue("unknown-asset-id", "assetID");
localRecord.setValue("unknown-comment-id", "commentID");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeMessages: [],
};
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>
);
it("renders permalink view with unknown asset", async () => {
// Wait for loading.
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot();
});
@@ -71,11 +71,15 @@ it("renders permalink view with unknown comment", async () => {
});
it("show all comments", async () => {
const mockEvent = {
preventDefault: sinon.mock().once(),
};
testRenderer.root
.findByProps({
id: "talk-comments-permalinkView-showAllComments",
})
.props.onClick();
.props.onClick(mockEvent);
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot();
mockEvent.preventDefault.verify();
});
@@ -15,6 +15,9 @@ import * as styles from "./BaseButton.css";
interface InnerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** If set renders an anchor tag instead */
anchor?: boolean;
href?: string;
target?: string;
/**
* This prop can be used to add custom classnames.
* It is handled by the `withStyles `HOC.
@@ -11,6 +11,10 @@ import * as styles from "./Button.css";
// This should extend from BaseButton instead but we can't because of this bug
// TODO: add bug link.
interface InnerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** If set renders an anchor tag instead */
anchor?: boolean;
href?: string;
target?: string;
/**
* This prop can be used to add custom classnames.
* It is handled by the `withStyles `HOC.