mirror of
https://github.com/wassname/talk.git
synced 2026-08-11 11:27:10 +08:00
[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:
Generated
+34
-15
@@ -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);
|
||||
}
|
||||
+4
-2
@@ -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: {
|
||||
|
||||
@@ -45,3 +45,13 @@ export {
|
||||
withUpdateUserRoleMutation,
|
||||
UpdateUserRoleMutation,
|
||||
} from "./UpdateUserRoleMutation";
|
||||
export {
|
||||
OpenStoryInput,
|
||||
withOpenStoryMutation,
|
||||
OpenStoryMutation,
|
||||
} from "./OpenStoryMutation";
|
||||
export {
|
||||
CloseStoryInput,
|
||||
withCloseStoryMutation,
|
||||
CloseStoryMutation,
|
||||
} from "./CloseStoryMutation";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user