[CORL-138] Stories (#2242)

* feat: implement stories

* test: add feature test

* fix: id name

* fix: improve word breaks, column widths

* fix: snapshots

* feat: add min width to status dropdown button

* fix: polishing
This commit is contained in:
Kiwi
2019-03-22 23:13:05 +00:00
committed by Wyatt Johnson
parent e0627925fb
commit 618646e71c
47 changed files with 1827 additions and 77 deletions
+34 -15
View File
@@ -2011,7 +2011,7 @@
},
"@types/accepts": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz",
"resolved": "http://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz",
"integrity": "sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==",
"requires": {
"@types/node": "*"
@@ -11365,7 +11365,7 @@
"integrity": "sha1-ETOUSrJHeINHOZVZaIPg05z4hc8=",
"dev": true,
"requires": {
"intl-pluralrules": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b"
"intl-pluralrules": "github:projectfluent/IntlPluralRules#module"
}
},
"fluent-langneg": {
@@ -11675,7 +11675,8 @@
"ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
"optional": true
},
"aproba": {
"version": "1.2.0",
@@ -11696,12 +11697,14 @@
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -11716,17 +11719,20 @@
"code-point-at": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
"optional": true
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@@ -11843,7 +11849,8 @@
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
"optional": true
},
"ini": {
"version": "1.3.5",
@@ -11855,6 +11862,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -11869,6 +11877,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -11876,12 +11885,14 @@
"minimist": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"optional": true
},
"minipass": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz",
"integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
"optional": true,
"requires": {
"safe-buffer": "^5.1.1",
"yallist": "^3.0.0"
@@ -11900,6 +11911,7 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -11980,7 +11992,8 @@
"number-is-nan": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"optional": true
},
"object-assign": {
"version": "4.1.1",
@@ -11992,6 +12005,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"optional": true,
"requires": {
"wrappy": "1"
}
@@ -12077,7 +12091,8 @@
"safe-buffer": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -12113,6 +12128,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -12132,6 +12148,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -12175,12 +12192,14 @@
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"optional": true
},
"yallist": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz",
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k="
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
"optional": true
}
}
},
@@ -16860,7 +16879,7 @@
},
"chalk": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
"resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
"dev": true,
"requires": {
@@ -19473,7 +19492,7 @@
"dependencies": {
"async": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
"dev": true
}
@@ -5,17 +5,14 @@ import { AppBarNavigation } from "talk-ui/components";
import NavigationLink from "./NavigationLink";
/* TODO:
<Localized id="navigation-stories">
<NavigationLink to="/admin/stories">Stories</NavigationLink>
</Localized>
*/
const Navigation: StatelessComponent = () => (
<AppBarNavigation>
<Localized id="navigation-moderate">
<NavigationLink to="/admin/moderate">Moderate</NavigationLink>
</Localized>
<Localized id="navigation-stories">
<NavigationLink to="/admin/stories">Stories</NavigationLink>
</Localized>
<Localized id="navigation-community">
<NavigationLink to="/admin/community">Community</NavigationLink>
</Localized>
@@ -0,0 +1,3 @@
.root {
color: var(--palette-grey-light);
}
@@ -1,9 +1,11 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import styles from "./NotAvailable.css";
const NotAvailable: StatelessComponent = props => (
<Localized id="community-notAvailable">
<span>Not available</span>
<Localized id="general-notAvailable">
<span className={styles.root}>Not available</span>
</Localized>
);
@@ -0,0 +1,46 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import { GQLSTORY_STATUS, GQLSTORY_STATUS_RL } from "talk-framework/schema";
interface Props {
container?: React.ReactElement<any> | React.ComponentType<any> | string;
children: GQLSTORY_STATUS_RL;
}
function createElement(
Container: React.ReactElement<any> | React.ComponentType<any> | string,
children: React.ReactNode
) {
if (React.isValidElement<any>(Container)) {
return React.cloneElement(Container, { children });
} else {
return <Container>{children}</Container>;
}
}
const TranslatedRole: React.StatelessComponent<Props> = props => {
switch (props.children) {
case GQLSTORY_STATUS.OPEN:
return (
<Localized id="storyStatus-open">
{createElement(props.container!, "Open")}
</Localized>
);
case GQLSTORY_STATUS.CLOSED:
return (
<Localized id="storyStatus-closed">
{createElement(props.container!, "Closed")}
</Localized>
);
default:
// Unknown role, just use untranslated string.
return createElement(props.container!, props.children);
}
};
TranslatedRole.defaultProps = {
container: "span",
};
export default TranslatedRole;
@@ -11,6 +11,15 @@ exports[`renders correctly 1`] = `
Moderate
</NavigationLink>
</Localized>
<Localized
id="navigation-stories"
>
<NavigationLink
to="/admin/stories"
>
Stories
</NavigationLink>
</Localized>
<Localized
id="navigation-community"
>
@@ -0,0 +1,62 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { CloseStoryMutation as MutationTypes } from "talk-admin/__generated__/CloseStoryMutation.graphql";
import { GQLSTORY_STATUS } from "talk-framework/schema";
export type CloseStoryInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation CloseStoryMutation($input: CloseStoryInput!) {
closeStory(input: $input) {
story {
id
status
closedAt
isClosed
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: CloseStoryInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
optimisticResponse: {
closeStory: {
story: {
id: input.id,
status: GQLSTORY_STATUS.CLOSED,
closedAt: new Date().toISOString(),
isClosed: true,
},
clientMutationId: clientMutationId.toString(),
},
},
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
export const withCloseStoryMutation = createMutationContainer(
"closeStory",
commit
);
export type CloseStoryMutation = (
input: CloseStoryInput
) => MutationResponsePromise<MutationTypes, "closeStory">;
@@ -0,0 +1,62 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
MutationInput,
MutationResponsePromise,
} from "talk-framework/lib/relay";
import { OpenStoryMutation as MutationTypes } from "talk-admin/__generated__/OpenStoryMutation.graphql";
import { GQLSTORY_STATUS } from "talk-framework/schema";
export type OpenStoryInput = MutationInput<MutationTypes>;
const mutation = graphql`
mutation OpenStoryMutation($input: OpenStoryInput!) {
openStory(input: $input) {
story {
id
status
closedAt
isClosed
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: OpenStoryInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
optimisticResponse: {
openStory: {
story: {
id: input.id,
status: GQLSTORY_STATUS.OPEN,
closedAt: null,
isClosed: false,
},
clientMutationId: clientMutationId.toString(),
},
},
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
export const withOpenStoryMutation = createMutationContainer(
"openStory",
commit
);
export type OpenStoryMutation = (
input: OpenStoryInput
) => MutationResponsePromise<MutationTypes, "openStory">;
@@ -34,7 +34,7 @@ function commit(environment: Environment, input: UpdateUserRoleInput) {
id: input.userID,
role: input.role,
},
clientMutationId: (clientMutationId++).toString(),
clientMutationId: clientMutationId.toString(),
},
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
variables: {
+10
View File
@@ -45,3 +45,13 @@ export {
withUpdateUserRoleMutation,
UpdateUserRoleMutation,
} from "./UpdateUserRoleMutation";
export {
OpenStoryInput,
withOpenStoryMutation,
OpenStoryMutation,
} from "./OpenStoryMutation";
export {
CloseStoryInput,
withCloseStoryMutation,
CloseStoryMutation,
} from "./CloseStoryMutation";
+3
View File
@@ -15,6 +15,9 @@ import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "talk-framework/schema";
const permissionMap = {
// Mutation.updateUserRole
CHANGE_ROLE: [GQLUSER_ROLE.ADMIN],
// Mutation.openStory
// Mutation.closeStory
CHANGE_STORY_STATUS: [GQLUSER_ROLE.ADMIN],
};
export type AbilityType = keyof typeof permissionMap;
+2
View File
@@ -21,6 +21,7 @@ import {
import RejectedQueueContainer from "./routes/moderate/containers/RejectedQueueContainer";
import SingleModerateContainer from "./routes/moderate/containers/SingleModerateContainer";
import Stories from "./routes/stories/components/Stories";
import StoriesContainer from "./routes/stories/containers/StoriesContainer";
export default makeRouteConfig(
<Route path="admin">
@@ -41,6 +42,7 @@ export default makeRouteConfig(
/>
<Route path="rejected" {...RejectedQueueContainer.routeConfig} />
</Route>
<Route path="stories" {...StoriesContainer.routeConfig} />
<Route path="community" {...CommunityContainer.routeConfig} />
<Route path="stories" Component={Stories} />
<Route path="configure" Component={ConfigureContainer}>
@@ -23,7 +23,7 @@ interface Props {
const RoleChange: StatelessComponent<Props> = props => (
<Localized id="community-role-popover" attrs={{ description: true }}>
<Popover
id="userMenu"
id="community-roleChange"
placement="bottom-start"
description="A dropdown to change the user role"
body={({ toggleVisibility }) => (
@@ -0,0 +1,20 @@
.usernameColumn {
vertical-align: top;
word-break: break-word;
padding-top: 15px;
padding-bottom: 15px;
}
.emailColumn {
vertical-align: top;
word-break: break-word;
padding-top: 15px;
padding-bottom: 15px;
}
.memberSinceColumn {
vertical-align: top;
padding-top: 15px;
padding-bottom: 15px;
}
.roleColumn {
vertical-align: top;
}
@@ -1,12 +1,14 @@
import React, { StatelessComponent } from "react";
import NotAvailable from "talk-admin/components/NotAvailable";
import { PropTypesOf } from "talk-framework/types";
import { TableCell, TableRow, TextLink } from "talk-ui/components";
import RoleChangeContainer from "../containers/RoleChangeContainer";
import NotAvailable from "./NotAvailable";
import RoleText from "./RoleText";
import styles from "./UserRow.css";
interface Props {
canChangeRole: boolean;
userID: string;
@@ -18,14 +20,18 @@ interface Props {
const UserRow: StatelessComponent<Props> = props => (
<TableRow>
<TableCell>{props.username || <NotAvailable />}</TableCell>
<TableCell>
<TableCell className={styles.usernameColumn}>
{props.username || <NotAvailable />}
</TableCell>
<TableCell className={styles.emailColumn}>
{<TextLink href={`mailto:${props.email}`}>{props.email}</TextLink> || (
<NotAvailable />
)}
</TableCell>
<TableCell>{props.memberSince}</TableCell>
<TableCell>
<TableCell className={styles.memberSinceColumn}>
{props.memberSince}
</TableCell>
<TableCell className={styles.roleColumn}>
{props.canChangeRole ? (
<RoleChangeContainer userID={props.userID} role={props.role} />
) : (
@@ -24,7 +24,7 @@ const UserRowContainer: StatelessComponent<Props> = props => {
memberSince={new Intl.DateTimeFormat(locales, {
day: "2-digit",
month: "2-digit",
year: "2-digit",
year: "numeric",
}).format(new Date(props.user.createdAt))}
role={props.user.role}
canChangeRole={
@@ -0,0 +1,3 @@
.root {
font-size: calc(18rem / var(--rem-base));
}
@@ -0,0 +1,16 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Typography } from "talk-ui/components";
import styles from "./EmptyMessage.css";
const EmptyMessage: StatelessComponent = props => (
<Localized id="stories-emptyMessage">
<Typography className={styles.root} variant="bodyCopyBold" align="center">
There are currently no published stories.
</Typography>
</Localized>
);
export default EmptyMessage;
@@ -0,0 +1,3 @@
.root {
font-size: calc(18rem / var(--rem-base));
}
@@ -0,0 +1,16 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Typography } from "talk-ui/components";
import styles from "./NoMatchMessage.css";
const NoMatchMessage: StatelessComponent = props => (
<Localized id="stories-noMatchMessage">
<Typography className={styles.root} variant="bodyCopyBold" align="center">
We could not find any stories matching your criteria.
</Typography>
</Localized>
);
export default NoMatchMessage;
@@ -0,0 +1,9 @@
.button {
width: 100%;
justify-content: space-between;
padding: 0;
}
.dropdownButton {
min-width: 80px;
}
@@ -0,0 +1,80 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import TranslatedStoryStatus from "talk-admin/components/TranslatedStoryStatus";
import { GQLSTORY_STATUS, GQLSTORY_STATUS_RL } from "talk-framework/schema";
import {
Button,
ButtonIcon,
ClickOutside,
Dropdown,
DropdownButton,
Popover,
} from "talk-ui/components";
import styles from "./StatusChange.css";
import StatusText from "./StatusText";
interface Props {
onChangeStatus: (status: GQLSTORY_STATUS_RL) => void;
status: GQLSTORY_STATUS_RL;
}
const StatusChange: StatelessComponent<Props> = props => (
<Localized id="stories-status-popover" attrs={{ description: true }}>
<Popover
id="stories-statusChange"
placement="bottom-start"
description="A dropdown to change the user status"
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Dropdown>
{Object.keys(GQLSTORY_STATUS).map((r: GQLSTORY_STATUS_RL) => (
<TranslatedStoryStatus
key={r}
container={
<DropdownButton
className={styles.dropdownButton}
onClick={() => {
props.onChangeStatus(r);
toggleVisibility();
}}
>
dummy
</DropdownButton>
}
>
{r}
</TranslatedStoryStatus>
))}
</Dropdown>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<Localized
id="stories-changeStatusButton"
attrs={{ "aria-label": true }}
>
<Button
aria-label="Change status"
className={styles.button}
onClick={toggleVisibility}
ref={ref}
variant="regular"
size="small"
>
<StatusText>{props.status}</StatusText>
{
<ButtonIcon size="lg">
{visible ? "arrow_drop_up" : "arrow_drop_down"}
</ButtonIcon>
}
</Button>
</Localized>
)}
</Popover>
</Localized>
);
export default StatusChange;
@@ -0,0 +1,9 @@
.root {
font-weight: var(--font-weight-regular);
color: var(--palette-grey-light);
}
.open {
font-weight: var(--font-weight-regular);
color: var(--palette-success-main);
}
@@ -0,0 +1,27 @@
import cn from "classnames";
import React, { StatelessComponent } from "react";
import TranslatedStoryStatus from "talk-admin/components/TranslatedStoryStatus";
import { GQLSTORY_STATUS_RL } from "talk-framework/schema";
import styles from "./StatusText.css";
interface Props {
children: GQLSTORY_STATUS_RL;
}
const StatusText: StatelessComponent<Props> = props => (
<TranslatedStoryStatus
container={
<span
className={cn(styles.root, {
[styles.open]: props.children === "OPEN",
})}
/>
}
>
{props.children}
</TranslatedStoryStatus>
);
export default StatusText;
@@ -0,0 +1,5 @@
.root {
max-width: 950px;
margin-top: calc(3 * var(--spacing-unit));
margin-bottom: calc(3 * var(--spacing-unit));
}
@@ -1,10 +0,0 @@
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import Stories from "./Stories";
it("renders correctly", () => {
const renderer = createRenderer();
renderer.render(<Stories />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -1,11 +1,19 @@
import React, { StatelessComponent } from "react";
import MainLayout from "talk-admin/components/MainLayout";
import { Typography } from "talk-ui/components";
import { PropTypesOf } from "talk-framework/types";
const Stories: StatelessComponent = ({ children }) => (
<MainLayout>
<Typography variant="heading3">Stories</Typography>
import StoryTableContainer from "../containers/StoryTableContainer";
import styles from "./Stories.css";
interface Props {
query: PropTypesOf<typeof StoryTableContainer>["query"];
}
const Stories: StatelessComponent<Props> = props => (
<MainLayout className={styles.root} data-testid="stories-container">
<StoryTableContainer query={props.query} />
</MainLayout>
);
@@ -0,0 +1,20 @@
.titleColumn {
vertical-align: top;
word-break: break-word;
padding-top: 15px;
padding-bottom: 15px;
}
.authorColumn {
vertical-align: top;
word-break: break-word;
padding-top: 15px;
padding-bottom: 15px;
}
.publishDateColumn {
vertical-align: top;
padding-top: 15px;
padding-bottom: 15px;
}
.statusColumn {
vertical-align: top;
}
@@ -0,0 +1,42 @@
import React, { StatelessComponent } from "react";
import NotAvailable from "talk-admin/components/NotAvailable";
import { PropTypesOf } from "talk-framework/types";
import { TableCell, TableRow } from "talk-ui/components";
import StatusChangeContainer from "../containers/StatusChangeContainer";
import StatusText from "./StatusText";
import styles from "./StoryRow.css";
interface Props {
canChangeStatus: boolean;
storyID: string;
title: string | null;
author: string | null;
publishDate: string | null;
status: PropTypesOf<typeof StatusChangeContainer>["status"];
}
const UserRow: StatelessComponent<Props> = props => (
<TableRow>
<TableCell className={styles.titleColumn}>
{props.title || <NotAvailable />}
</TableCell>
<TableCell className={styles.authorColumn}>
{props.author || <NotAvailable />}
</TableCell>
<TableCell className={styles.publishDateColumn}>
{props.publishDate || <NotAvailable />}
</TableCell>
<TableCell className={styles.statusColumn}>
{props.canChangeStatus ? (
<StatusChangeContainer storyID={props.storyID} status={props.status} />
) : (
<StatusText>{props.status}</StatusText>
)}
</TableCell>
</TableRow>
);
export default UserRow;
@@ -0,0 +1,12 @@
.titleColumn {
width: 50%;
}
.authorColumn {
width: 17.5%;
}
.publishDateColumn {
width: 17.5%;
}
.statusColumn {
width: 15%;
}
@@ -0,0 +1,84 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { PropTypesOf } from "talk-framework/types";
import AutoLoadMoreContainer from "talk-admin/containers/AutoLoadMoreContainer";
import { Flex, HorizontalGutter, Spinner } from "talk-ui/components";
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "talk-ui/components/Table";
import StoryRowContainer from "../containers/StoryRowContainer";
import EmptyMessage from "./EmptyMessage";
import NoMatchMessage from "./NoMatchMessage";
import styles from "./StoryTable.css";
interface Props {
viewer: PropTypesOf<typeof StoryRowContainer>["viewer"] | null;
stories: Array<
{ id: string } & PropTypesOf<typeof StoryRowContainer>["story"]
>;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
isSearching: boolean;
}
const StoryTable: StatelessComponent<Props> = props => (
<>
<HorizontalGutter size="double">
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="stories-column-title">
<TableCell className={styles.titleColumn}>Title</TableCell>
</Localized>
<Localized id="stories-column-author">
<TableCell className={styles.authorColumn}>Author</TableCell>
</Localized>
<Localized id="stories-column-publishDate">
<TableCell className={styles.publishDateColumn}>
Publish Date
</TableCell>
</Localized>
<Localized id="stories-column-status">
<TableCell className={styles.statusColumn}>Status</TableCell>
</Localized>
</TableRow>
</TableHead>
<TableBody>
{!props.loading &&
props.stories.map(u => (
<StoryRowContainer key={u.id} story={u} viewer={props.viewer!} />
))}
</TableBody>
</Table>
{!props.loading &&
props.stories.length === 0 &&
(props.isSearching ? <NoMatchMessage /> : <EmptyMessage />)}
{props.loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{props.hasMore && (
<Flex justifyContent="center">
<AutoLoadMoreContainer
disableLoadMore={props.disableLoadMore}
onLoadMore={props.onLoadMore}
/>
</Flex>
)}
</HorizontalGutter>
</>
);
export default StoryTable;
@@ -0,0 +1,8 @@
.legend {
margin-bottom: 2px;
text-transform: uppercase;
}
.textField {
height: 31px;
}
@@ -0,0 +1,78 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { GQLSTORY_STATUS, GQLSTORY_STATUS_RL } from "talk-framework/schema";
import {
FieldSet,
Flex,
Option,
SelectField,
TextField,
Typography,
} from "talk-ui/components";
import styles from "./StoryTableFilter.css";
interface Props {
statusFilter: GQLSTORY_STATUS_RL | null;
onSetStatusFilter: (status: GQLSTORY_STATUS_RL) => void;
}
const StoryTableFilter: StatelessComponent<Props> = props => (
<Flex itemGutter="double">
<FieldSet>
<Localized id="stories-filter-search">
<Typography
container="legend"
className={styles.legend}
variant="bodyCopyBold"
>
Search
</Typography>
</Localized>
<Localized
id="stories-filter-searchField"
attrs={{ placeholder: true, "aria-label": true }}
>
<TextField
classes={{ input: styles.textField }}
placeholder="Search by story title or author..."
aria-label="Search by story title or author"
/>
</Localized>
</FieldSet>
<FieldSet>
<Localized id="stories-filter-showMe">
<Typography
className={styles.legend}
container="legend"
variant="bodyCopyBold"
>
Show Me
</Typography>
</Localized>
<Localized
id="stories-filter-statusSelectField"
attrs={{ "aria-label": true }}
>
<SelectField
aria-label="Search by status"
value={props.statusFilter || ""}
onChange={e => props.onSetStatusFilter(e.target.value as any)}
>
<Localized id="stories-filter-allStories">
<Option value="">All Stories</Option>
</Localized>
<Localized id="stories-filter-openStories">
<Option value={GQLSTORY_STATUS.OPEN}>Open Stories</Option>
</Localized>
<Localized id="stories-filter-closedStories">
<Option value={GQLSTORY_STATUS.CLOSED}>Closed Stories</Option>
</Localized>
</SelectField>
</Localized>
</FieldSet>
</Flex>
);
export default StoryTableFilter;
@@ -1,11 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<MainLayout>
<ForwardRef(forwardRef)
variant="heading3"
>
Stories
</ForwardRef(forwardRef)>
</MainLayout>
`;
@@ -0,0 +1,43 @@
import React, { StatelessComponent, useCallback } from "react";
import {
CloseStoryMutation,
OpenStoryMutation,
withCloseStoryMutation,
withOpenStoryMutation,
} from "talk-admin/mutations";
import { GQLSTORY_STATUS, GQLSTORY_STATUS_RL } from "talk-framework/schema";
import StatusChange from "../components/StatusChange";
interface Props {
storyID: string;
status: GQLSTORY_STATUS_RL;
openStory: OpenStoryMutation;
closeStory: CloseStoryMutation;
}
const StatusChangeContainer: StatelessComponent<Props> = props => {
const hanldeChangeStatus = useCallback(
(status: GQLSTORY_STATUS_RL) => {
if (props.status === status) {
return;
}
if (status === GQLSTORY_STATUS.CLOSED) {
props.closeStory({ id: props.storyID });
} else if (status === GQLSTORY_STATUS.OPEN) {
props.openStory({ id: props.storyID });
}
},
[props.storyID, props.closeStory, props.openStory, props.status]
);
return (
<StatusChange onChangeStatus={hanldeChangeStatus} status={props.status} />
);
};
const enhanced = withOpenStoryMutation(
withCloseStoryMutation(StatusChangeContainer)
);
export default enhanced;
@@ -0,0 +1,28 @@
import { FormApi } from "final-form";
import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import { StoriesContainerQueryResponse } from "talk-admin/__generated__/StoriesContainerQuery.graphql";
import { withRouteConfig } from "talk-framework/lib/router";
import Stories from "../components/Stories";
interface Props {
data: StoriesContainerQueryResponse | null;
form: FormApi;
}
const StoriesContainer: StatelessComponent<Props> = props => {
return <Stories query={props.data} />;
};
const enhanced = withRouteConfig({
query: graphql`
query StoriesContainerQuery {
...StoryTableContainer_query
}
`,
cacheConfig: { force: true },
})(StoriesContainer);
export default enhanced;
@@ -0,0 +1,69 @@
import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import { StoryRowContainer_story as StoryData } from "talk-admin/__generated__/StoryRowContainer_story.graphql";
import { StoryRowContainer_viewer as ViewerData } from "talk-admin/__generated__/StoryRowContainer_viewer.graphql";
import { useTalkContext } from "talk-framework/lib/bootstrap";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { Ability, can } from "talk-admin/permissions";
import StoryRow from "../components/StoryRow";
interface Props {
story: StoryData;
viewer: ViewerData;
}
const StoryRowContainer: StatelessComponent<Props> = props => {
const { locales } = useTalkContext();
const title = props.story.metadata && props.story.metadata.title;
const author = props.story.metadata && props.story.metadata.author;
const publishedAt = props.story.metadata && props.story.metadata.publishedAt;
return (
<StoryRow
storyID={props.story.id}
title={title}
author={author}
publishDate={
publishedAt
? new Intl.DateTimeFormat(locales, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour12: true,
hour: "2-digit",
minute: "2-digit",
}).format(new Date(publishedAt))
: null
}
status={props.story.status}
canChangeStatus={
props.viewer.id !== props.story.id &&
can(props.viewer, Ability.CHANGE_STORY_STATUS)
}
/>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment StoryRowContainer_viewer on User {
id
role
}
`,
story: graphql`
fragment StoryRowContainer_story on Story {
id
metadata {
title
author
publishedAt
}
isClosed
status
}
`,
})(StoryRowContainer);
export default enhanced;
@@ -0,0 +1,166 @@
import React, { StatelessComponent, useCallback, useState } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { StoryTableContainer_query as QueryData } from "talk-admin/__generated__/StoryTableContainer_query.graphql";
import { StoryTableContainerPaginationQueryVariables } from "talk-admin/__generated__/StoryTableContainerPaginationQuery.graphql";
import { IntersectionProvider } from "talk-framework/lib/intersection";
import { withPaginationContainer } from "talk-framework/lib/relay";
import { GQLSTORY_STATUS_RL } from "talk-framework/schema";
import { HorizontalGutter } from "talk-ui/components";
import StoryTable from "../components/StoryTable";
import StoryTableFilter from "../components/StoryTableFilter";
interface Props {
query: QueryData | null;
relay: RelayPaginationProp;
}
const StoryTableContainer: StatelessComponent<Props> = props => {
const stories = props.query
? props.query.stories.edges.map(edge => edge.node)
: [];
const [disableLoadMore, setDisableLoadMore] = useState(false);
const [refetching, setRefetching] = useState(false);
const [statusFilter, setStatusFilter] = useState<GQLSTORY_STATUS_RL | null>(
null
);
const setStatusFilterAndRefetch = useCallback(
(status: GQLSTORY_STATUS_RL | null) => {
setStatusFilter(status);
setRefetching(true);
props.relay.refetchConnection(
10,
error => {
setRefetching(false);
if (error) {
// tslint:disable-next-line:no-console
console.error(error);
}
},
{
statusFilter: status,
}
);
},
[statusFilter, props.relay]
);
const loadMore = useCallback(
() => {
if (!props.relay.hasMore() || props.relay.isLoading()) {
return;
}
setDisableLoadMore(true);
props.relay.loadMore(
10, // Fetch the next 10 feed items
error => {
setDisableLoadMore(false);
if (error) {
// tslint:disable-next-line:no-console
console.error(error);
}
}
);
},
[props.relay]
);
return (
<IntersectionProvider>
<HorizontalGutter size="double">
<StoryTableFilter
onSetStatusFilter={status =>
setStatusFilterAndRefetch(status || null)
}
statusFilter={statusFilter}
/>
<StoryTable
viewer={props.query && props.query.viewer}
loading={!props.query || refetching}
stories={stories}
onLoadMore={loadMore}
hasMore={!refetching && props.relay.hasMore()}
disableLoadMore={disableLoadMore}
isSearching={Boolean(statusFilter)}
/>
</HorizontalGutter>
</IntersectionProvider>
);
};
// TODO: (cvle) This should be autogenerated.
interface FragmentVariables {
count: number;
cursor?: string;
statusFilter: GQLSTORY_STATUS_RL | null;
}
const enhanced = withPaginationContainer<
Props,
StoryTableContainerPaginationQueryVariables,
FragmentVariables
>(
{
query: graphql`
fragment StoryTableContainer_query on Query
@argumentDefinitions(
count: { type: "Int!", defaultValue: 10 }
cursor: { type: "Cursor" }
statusFilter: { type: "STORY_STATUS" }
) {
viewer {
...StoryRowContainer_viewer
}
stories(first: $count, after: $cursor, status: $statusFilter)
@connection(key: "StoryTable_stories") {
edges {
node {
id
...StoryRowContainer_story
}
}
}
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.query && props.query.stories;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
return {
...prevVars,
count: totalCount,
};
},
getVariables(props, { count, cursor }, fragmentVariables) {
return {
count,
cursor,
statusFilter: fragmentVariables.statusFilter,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query StoryTableContainerPaginationQuery(
$count: Int!
$cursor: Cursor
$statusFilter: STORY_STATUS
) {
...StoryTableContainer_query
@arguments(
count: $count
cursor: $cursor
statusFilter: $statusFilter
)
}
`,
}
)(StoryTableContainer);
export default enhanced;
@@ -138,12 +138,12 @@ exports[`renders community 1`] = `
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root TableCell-body"
className="TableCell-root UserRow-usernameColumn TableCell-body"
>
Markus
</td>
<td
className="TableCell-root TableCell-body"
className="TableCell-root UserRow-emailColumn TableCell-body"
>
<a
className="TextLink-root"
@@ -153,12 +153,12 @@ exports[`renders community 1`] = `
</a>
</td>
<td
className="TableCell-root TableCell-body"
className="TableCell-root UserRow-memberSinceColumn TableCell-body"
>
07/06/18
07/06/2018
</td>
<td
className="TableCell-root TableCell-body"
className="TableCell-root UserRow-roleColumn TableCell-body"
>
<span
className="RoleText-root"
@@ -171,12 +171,12 @@ exports[`renders community 1`] = `
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root TableCell-body"
className="TableCell-root UserRow-usernameColumn TableCell-body"
>
Lukas
</td>
<td
className="TableCell-root TableCell-body"
className="TableCell-root UserRow-emailColumn TableCell-body"
>
<a
className="TextLink-root"
@@ -186,12 +186,12 @@ exports[`renders community 1`] = `
</a>
</td>
<td
className="TableCell-root TableCell-body"
className="TableCell-root UserRow-memberSinceColumn TableCell-body"
>
07/06/18
07/06/2018
</td>
<td
className="TableCell-root TableCell-body"
className="TableCell-root UserRow-roleColumn TableCell-body"
>
<div
className="Popover-root"
@@ -221,13 +221,13 @@ exports[`renders community 1`] = `
</button>
<div
aria-hidden={true}
aria-labelledby="userMenu-ariainfo"
id="userMenu"
aria-labelledby="community-roleChange-ariainfo"
id="community-roleChange"
role="popup"
>
<div
className="AriaInfo-root"
id="userMenu-ariainfo"
id="community-roleChange-ariainfo"
>
A dropdown to change the user role
</div>
@@ -2,6 +2,7 @@ import { get, merge } from "lodash";
import TestRenderer from "react-test-renderer";
import sinon from "sinon";
import { GQLUSER_ROLE } from "talk-framework/schema";
import {
createSinonStub,
replaceHistoryLocation,
@@ -72,7 +73,7 @@ it("filter by role", async () => {
s => s.onFirstCall().returns(communityUsers),
s =>
s.onSecondCall().callsFake((_, data) => {
expectAndFail(data.role).toBe("COMMENTER");
expectAndFail(data.role).toBe(GQLUSER_ROLE.COMMENTER);
return emptyCommunityUsers;
})
),
@@ -110,7 +111,7 @@ it("change user role", async () => {
const updateUserRole = sinon.stub().callsFake((_: any, data: any) => {
expectAndFail(data.input).toMatchObject({
userID: user.id,
role: "STAFF",
role: GQLUSER_ROLE.STAFF,
});
const userRecord = merge({}, user, { role: data.input.role });
return {
+54
View File
@@ -1,5 +1,7 @@
import { merge } from "lodash";
import { GQLStory, GQLSTORY_STATUS } from "talk-framework/schema";
export const settings = {
id: "settings",
moderation: "POST",
@@ -390,3 +392,55 @@ export const emptyCommunityUsers = {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
};
export const stories: Array<Partial<GQLStory>> = [
{
id: "story-1",
closedAt: null,
isClosed: false,
status: GQLSTORY_STATUS.OPEN,
createdAt: "2018-11-29T16:01:51.897Z",
metadata: {
author: "Vin Hoa",
title: "Finally a Cure for Cancer",
publishedAt: "2018-11-29T16:01:51.897Z",
},
},
{
id: "story-2",
closedAt: null,
isClosed: false,
status: GQLSTORY_STATUS.OPEN,
createdAt: "2018-11-29T16:01:51.897Z",
metadata: {
author: "Linh Nguyen",
title: "First Colony on Mars",
publishedAt: "2018-11-29T16:01:51.897Z",
},
},
{
id: "story-3",
closedAt: "2018-11-29T16:01:51.897Z",
createdAt: "2018-11-29T16:01:51.897Z",
isClosed: true,
status: GQLSTORY_STATUS.CLOSED,
metadata: {
author: undefined,
title: "World hunger has been defeated",
publishedAt: "2018-11-29T16:01:51.897Z",
},
},
];
export const storyConnection = {
edges: [
{ node: stories[0], cursor: stories[0].createdAt },
{ node: stories[1], cursor: stories[1].createdAt },
],
pageInfo: { endCursor: null, hasNextPage: false },
};
export const emptyStories = {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
};
@@ -0,0 +1,499 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders empty stories 1`] = `
<div
className="MainLayout-root Stories-root"
data-testid="stories-container"
>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<div
className="Flex-root Flex-flex Flex-doubleItemGutter"
>
<fieldset
className="FieldSet-root"
>
<legend
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary StoryTableFilter-legend"
>
Search
</legend>
<div
className="TextField-root"
>
<input
aria-label="Search by story title or author"
className="TextField-input StoryTableFilter-textField TextField-colorRegular"
placeholder="Search by story title or author..."
type="text"
/>
</div>
</fieldset>
<fieldset
className="FieldSet-root"
>
<legend
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary StoryTableFilter-legend"
>
Show Me
</legend>
<span
className="SelectField-root"
>
<select
aria-label="Search by status"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
>
<option
value=""
>
All Stories
</option>
<option
value="OPEN"
>
Open Stories
</option>
<option
value="CLOSED"
>
Closed Stories
</option>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</span>
</span>
</span>
</fieldset>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<table
className="Table-root Table-fullWidth"
>
<thead
className="TableHead-root"
>
<tr
className="TableRow-root"
>
<th
className="TableCell-root StoryTable-titleColumn TableCell-header"
>
Title
</th>
<th
className="TableCell-root StoryTable-authorColumn TableCell-header"
>
Author
</th>
<th
className="TableCell-root StoryTable-publishDateColumn TableCell-header"
>
Publish Date
</th>
<th
className="TableCell-root StoryTable-statusColumn TableCell-header"
>
Status
</th>
</tr>
</thead>
<tbody
className="TableBody-root"
>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root StoryRow-titleColumn TableCell-body"
>
Finally a Cure for Cancer
</td>
<td
className="TableCell-root StoryRow-authorColumn TableCell-body"
>
Vin Hoa
</td>
<td
className="TableCell-root StoryRow-publishDateColumn TableCell-body"
>
11/29/2018, 4:01 PM
</td>
<td
className="TableCell-root StoryRow-statusColumn TableCell-body"
>
<div
className="Popover-root"
>
<button
aria-label="Change status"
className="BaseButton-root Button-root StatusChange-button Button-sizeSmall Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
className="StatusText-root StatusText-open"
>
Open
</span>
<span
aria-hidden="true"
className="Icon-root Icon-lg ButtonIcon-root"
>
arrow_drop_down
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="stories-statusChange-ariainfo"
id="stories-statusChange"
role="popup"
>
<div
className="AriaInfo-root"
id="stories-statusChange-ariainfo"
>
A dropdown to change the story status
</div>
</div>
</div>
</td>
</tr>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root StoryRow-titleColumn TableCell-body"
>
First Colony on Mars
</td>
<td
className="TableCell-root StoryRow-authorColumn TableCell-body"
>
Linh Nguyen
</td>
<td
className="TableCell-root StoryRow-publishDateColumn TableCell-body"
>
11/29/2018, 4:01 PM
</td>
<td
className="TableCell-root StoryRow-statusColumn TableCell-body"
>
<div
className="Popover-root"
>
<button
aria-label="Change status"
className="BaseButton-root Button-root StatusChange-button Button-sizeSmall Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
className="StatusText-root StatusText-open"
>
Open
</span>
<span
aria-hidden="true"
className="Icon-root Icon-lg ButtonIcon-root"
>
arrow_drop_down
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="stories-statusChange-ariainfo"
id="stories-statusChange"
role="popup"
>
<div
className="AriaInfo-root"
id="stories-statusChange-ariainfo"
>
A dropdown to change the story status
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
`;
exports[`renders stories 1`] = `
<div
className="MainLayout-root Stories-root"
data-testid="stories-container"
>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<div
className="Flex-root Flex-flex Flex-doubleItemGutter"
>
<fieldset
className="FieldSet-root"
>
<legend
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary StoryTableFilter-legend"
>
Search
</legend>
<div
className="TextField-root"
>
<input
aria-label="Search by story title or author"
className="TextField-input StoryTableFilter-textField TextField-colorRegular"
placeholder="Search by story title or author..."
type="text"
/>
</div>
</fieldset>
<fieldset
className="FieldSet-root"
>
<legend
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary StoryTableFilter-legend"
>
Show Me
</legend>
<span
className="SelectField-root"
>
<select
aria-label="Search by status"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
>
<option
value=""
>
All Stories
</option>
<option
value="OPEN"
>
Open Stories
</option>
<option
value="CLOSED"
>
Closed Stories
</option>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</span>
</span>
</span>
</fieldset>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<table
className="Table-root Table-fullWidth"
>
<thead
className="TableHead-root"
>
<tr
className="TableRow-root"
>
<th
className="TableCell-root StoryTable-titleColumn TableCell-header"
>
Title
</th>
<th
className="TableCell-root StoryTable-authorColumn TableCell-header"
>
Author
</th>
<th
className="TableCell-root StoryTable-publishDateColumn TableCell-header"
>
Publish Date
</th>
<th
className="TableCell-root StoryTable-statusColumn TableCell-header"
>
Status
</th>
</tr>
</thead>
<tbody
className="TableBody-root"
>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root StoryRow-titleColumn TableCell-body"
>
Finally a Cure for Cancer
</td>
<td
className="TableCell-root StoryRow-authorColumn TableCell-body"
>
Vin Hoa
</td>
<td
className="TableCell-root StoryRow-publishDateColumn TableCell-body"
>
11/29/2018, 4:01 PM
</td>
<td
className="TableCell-root StoryRow-statusColumn TableCell-body"
>
<div
className="Popover-root"
>
<button
aria-label="Change status"
className="BaseButton-root Button-root StatusChange-button Button-sizeSmall Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
className="StatusText-root StatusText-open"
>
Open
</span>
<span
aria-hidden="true"
className="Icon-root Icon-lg ButtonIcon-root"
>
arrow_drop_down
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="stories-statusChange-ariainfo"
id="stories-statusChange"
role="popup"
>
<div
className="AriaInfo-root"
id="stories-statusChange-ariainfo"
>
A dropdown to change the story status
</div>
</div>
</div>
</td>
</tr>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root StoryRow-titleColumn TableCell-body"
>
First Colony on Mars
</td>
<td
className="TableCell-root StoryRow-authorColumn TableCell-body"
>
Linh Nguyen
</td>
<td
className="TableCell-root StoryRow-publishDateColumn TableCell-body"
>
11/29/2018, 4:01 PM
</td>
<td
className="TableCell-root StoryRow-statusColumn TableCell-body"
>
<div
className="Popover-root"
>
<button
aria-label="Change status"
className="BaseButton-root Button-root StatusChange-button Button-sizeSmall Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
className="StatusText-root StatusText-open"
>
Open
</span>
<span
aria-hidden="true"
className="Icon-root Icon-lg ButtonIcon-root"
>
arrow_drop_down
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="stories-statusChange-ariainfo"
id="stories-statusChange"
role="popup"
>
<div
className="AriaInfo-root"
id="stories-statusChange-ariainfo"
>
A dropdown to change the story status
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
`;
@@ -0,0 +1,204 @@
import { get, merge } from "lodash";
import TestRenderer from "react-test-renderer";
import sinon from "sinon";
import {
createSinonStub,
replaceHistoryLocation,
waitForElement,
waitUntilThrow,
within,
} from "talk-framework/testHelpers";
import { GQLSTORY_STATUS } from "talk-framework/schema";
import create from "../create";
import {
emptyStories,
settings,
stories,
storyConnection,
users,
} from "../fixtures";
beforeEach(async () => {
replaceHistoryLocation("http://localhost/admin/stories");
});
const createTestRenderer = async (resolver: any = {}) => {
const resolvers = {
...resolver,
Query: {
settings: sinon
.stub()
.returns(merge({}, settings, get(resolver, "Query.settings"))),
stories: sinon.stub().callsFake((_, data) => {
expectAndFail(data.status).toBeFalsy();
return storyConnection;
}),
viewer: sinon.stub().returns(users[0]),
...resolver.Query,
},
};
const { testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: localRecord => {
localRecord.setValue(true, "loggedIn");
},
});
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("stories-container")
);
return { testRenderer, container };
};
it("renders stories", async () => {
const { container } = await createTestRenderer();
expect(within(container).toJSON()).toMatchSnapshot();
});
it("renders empty stories", async () => {
const { container } = await createTestRenderer({
Query: {
users: sinon.stub().returns(emptyStories),
},
});
expect(within(container).toJSON()).toMatchSnapshot();
});
it("filter by status", async () => {
const { container } = await createTestRenderer({
Query: {
stories: createSinonStub(
s => s.onFirstCall().returns(storyConnection),
s =>
s.onSecondCall().callsFake((_, data) => {
expectAndFail(data.status).toBe(GQLSTORY_STATUS.CLOSED);
return emptyStories;
})
),
},
});
const selectField = within(container).getByLabelText("Search by status");
const closedOption = within(selectField).getByText("Closed Stories");
TestRenderer.act(() => {
selectField.props.onChange({
target: { value: closedOption.props.value.toString() },
});
// TODO: Fix act warnings until await Promise.resolve();
// or whatever comes out at https://github.com/facebook/react/issues/14769
});
await waitForElement(() =>
within(container).getByText("could not find any", { exact: false })
);
});
it("change story status", async () => {
const story = stories[1];
const openStory = sinon.stub().callsFake((_: any, data: any) => {
expectAndFail(data.input).toMatchObject({
id: story.id,
});
const storyRecord = merge({}, story, {
status: GQLSTORY_STATUS.OPEN,
createdAt: false,
isClosed: false,
});
return {
story: storyRecord,
clientMutationId: data.input.clientMutationId,
};
});
const closeStory = sinon.stub().callsFake((_: any, data: any) => {
expectAndFail(data.input).toMatchObject({
id: story.id,
});
const storyRecord = merge({}, story, {
status: GQLSTORY_STATUS.CLOSED,
createdAt: "2018-11-29T16:01:51.897Z",
isClosed: true,
});
return {
story: storyRecord,
clientMutationId: data.input.clientMutationId,
};
});
const { container } = await createTestRenderer({
Mutation: { openStory, closeStory },
});
const storyRow = within(container).getByText(story.metadata!.title!, {
selector: "tr",
});
const changeStatusButton = within(storyRow).getByLabelText("Change status");
const popup = within(storyRow).getByLabelText(
"A dropdown to change the story status"
);
/** CLOSE STORY */
TestRenderer.act(() => {
changeStatusButton.props.onClick();
});
TestRenderer.act(() => {
within(popup)
.getByText("Closed")
.props.onClick();
});
within(storyRow).getByText("Closed");
expect(closeStory.called).toBe(true);
/** OPEN STORY */
TestRenderer.act(() => {
changeStatusButton.props.onClick();
});
TestRenderer.act(() => {
within(popup)
.getByText("Open")
.props.onClick();
});
within(storyRow).getByText("Open");
expect(openStory.called).toBe(true);
});
it("load more", async () => {
const { container } = await createTestRenderer({
Query: {
stories: createSinonStub(
s =>
s.onFirstCall().returns({
edges: [
{ node: stories[0], cursor: stories[0].createdAt },
{ node: stories[1], cursor: stories[1].createdAt },
],
pageInfo: { endCursor: stories[1].createdAt, hasNextPage: true },
}),
s =>
s.onSecondCall().returns({
edges: [{ node: stories[2], cursor: stories[2].createdAt }],
pageInfo: { endCursor: stories[2].createdAt, hasNextPage: false },
})
),
},
});
const loadMore = within(container).getByText("Load More");
TestRenderer.act(() => {
loadMore.props.onClick();
});
// Wait for load more to disappear.
await waitUntilThrow(() => within(container).getByText("Load More"));
// Make sure third user was added.
within(container).getByText(stories[2].metadata!.title!);
});
@@ -23,7 +23,7 @@ const statusFilter = (
switch (status) {
case GQLSTORY_STATUS.OPEN:
return {
closedAt: null,
closedAt: { $in: [null, false] },
};
case GQLSTORY_STATUS.CLOSED:
return {
@@ -1,18 +1,26 @@
import { defaultsDeep } from "lodash";
import { GQLStoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import {
GQLSTORY_STATUS,
GQLStoryTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action/comment";
import * as story from "talk-server/models/story";
import { getStoryClosedAt } from "talk-server/services/stories";
import TenantContext from "../context";
import { storyModerationInputResolver } from "./ModerationQueues";
const isStoryClosed = (s: story.Story, ctx: TenantContext) => {
const closedAt = getStoryClosedAt(ctx.tenant, s) || null;
return !!closedAt && new Date() >= closedAt;
};
export const Story: GQLStoryTypeResolver<story.Story> = {
comments: (s, input, ctx) => ctx.loaders.Comments.forStory(s.id, input),
isClosed: (s, input, ctx) => {
const closedAt = getStoryClosedAt(ctx.tenant, s) || null;
return !!closedAt && new Date() >= closedAt;
},
status: (s, input, ctx) =>
isStoryClosed(s, ctx) ? GQLSTORY_STATUS.CLOSED : GQLSTORY_STATUS.OPEN,
isClosed: (s, input, ctx) => isStoryClosed(s, ctx),
closedAt: (s, input, ctx) => getStoryClosedAt(ctx.tenant, s) || null,
commentActionCounts: s => decodeActionCounts(s.commentCounts.action),
commentCounts: s => s.commentCounts.status,
@@ -1637,6 +1637,11 @@ type Story {
"""
id: ID!
"""
status is the status of the Story.
"""
status: STORY_STATUS!
"""
url is the url that the Story is located on.
"""
+33
View File
@@ -3,6 +3,11 @@
## General
general-brandName = { -product-name }
general-notAvailable = Not available
## Story Status
storyStatus-open = Open
storyStatus-closed = Closed
## Roles
role-admin = Admin
@@ -390,3 +395,31 @@ community-column-role = Role
community-role-popover =
.description = A dropdown to change the user role
## Stories
stories-emptyMessage = There are currently no published stories.
stories-noMatchMessage = We could not find any stories matching your criteria.
stories-filter-searchField =
.placeholder = Search by story title or author...
.aria-label = Search by story title or author
stories-filter-statusSelectField =
.aria-label = Search by status
stories-changeStatusButton =
.aria-label = Change status
stories-filter-search = Search
stories-filter-showMe = Show Me
stories-filter-allStories = All Stories
stories-filter-openStories = Open Stories
stories-filter-closedStories = Closed Stories
stories-column-title = Title
stories-column-author = Author
stories-column-publishDate = Publish Date
stories-column-status = Status
stories-status-popover =
.description = A dropdown to change the story status