From 618646e71cc54a407ead70b5184719c3c50defec Mon Sep 17 00:00:00 2001 From: Kiwi Date: Sat, 23 Mar 2019 00:13:05 +0100 Subject: [PATCH] =?UTF-8?q?[CORL-138]=C2=A0Stories=20(#2242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- package-lock.json | 49 +- .../client/admin/components/Navigation.tsx | 9 +- .../client/admin/components/NotAvailable.css | 3 + .../components/NotAvailable.tsx | 6 +- .../components/TranslatedStoryStatus.tsx | 46 ++ .../__snapshots__/Navigation.spec.tsx.snap | 9 + .../admin/mutations/CloseStoryMutation.ts | 62 +++ .../admin/mutations/OpenStoryMutation.ts | 62 +++ .../admin/mutations/UpdateUserRoleMutation.ts | 2 +- src/core/client/admin/mutations/index.ts | 10 + src/core/client/admin/permissions.tsx | 3 + src/core/client/admin/routeConfig.tsx | 2 + .../community/components/RoleChange.tsx | 2 +- .../routes/community/components/UserRow.css | 20 + .../routes/community/components/UserRow.tsx | 16 +- .../community/containers/UserRowContainer.tsx | 2 +- .../stories/components/EmptyMessage.css | 3 + .../stories/components/EmptyMessage.tsx | 16 + .../stories/components/NoMatchMessage.css | 3 + .../stories/components/NoMatchMessage.tsx | 16 + .../stories/components/StatusChange.css | 9 + .../stories/components/StatusChange.tsx | 80 +++ .../routes/stories/components/StatusText.css | 9 + .../routes/stories/components/StatusText.tsx | 27 + .../routes/stories/components/Stories.css | 5 + .../stories/components/Stories.spec.tsx | 10 - .../routes/stories/components/Stories.tsx | 16 +- .../routes/stories/components/StoryRow.css | 20 + .../routes/stories/components/StoryRow.tsx | 42 ++ .../routes/stories/components/StoryTable.css | 12 + .../routes/stories/components/StoryTable.tsx | 84 +++ .../stories/components/StoryTableFilter.css | 8 + .../stories/components/StoryTableFilter.tsx | 78 +++ .../__snapshots__/Stories.spec.tsx.snap | 11 - .../containers/StatusChangeContainer.tsx | 43 ++ .../stories/containers/StoriesContainer.tsx | 28 + .../stories/containers/StoryRowContainer.tsx | 69 +++ .../containers/StoryTableContainer.tsx | 166 ++++++ .../__snapshots__/community.spec.tsx.snap | 26 +- .../admin/test/community/community.spec.tsx | 5 +- src/core/client/admin/test/fixtures.ts | 54 ++ .../__snapshots__/stories.spec.tsx.snap | 499 ++++++++++++++++++ .../admin/test/stories/stories.spec.tsx | 204 +++++++ .../server/graph/tenant/loaders/Stories.ts | 2 +- .../server/graph/tenant/resolvers/Story.ts | 18 +- .../server/graph/tenant/schema/schema.graphql | 5 + src/locales/en-US/admin.ftl | 33 ++ 47 files changed, 1827 insertions(+), 77 deletions(-) create mode 100644 src/core/client/admin/components/NotAvailable.css rename src/core/client/admin/{routes/community => }/components/NotAvailable.tsx (59%) create mode 100644 src/core/client/admin/components/TranslatedStoryStatus.tsx create mode 100644 src/core/client/admin/mutations/CloseStoryMutation.ts create mode 100644 src/core/client/admin/mutations/OpenStoryMutation.ts create mode 100644 src/core/client/admin/routes/community/components/UserRow.css create mode 100644 src/core/client/admin/routes/stories/components/EmptyMessage.css create mode 100644 src/core/client/admin/routes/stories/components/EmptyMessage.tsx create mode 100644 src/core/client/admin/routes/stories/components/NoMatchMessage.css create mode 100644 src/core/client/admin/routes/stories/components/NoMatchMessage.tsx create mode 100644 src/core/client/admin/routes/stories/components/StatusChange.css create mode 100644 src/core/client/admin/routes/stories/components/StatusChange.tsx create mode 100644 src/core/client/admin/routes/stories/components/StatusText.css create mode 100644 src/core/client/admin/routes/stories/components/StatusText.tsx create mode 100644 src/core/client/admin/routes/stories/components/Stories.css delete mode 100644 src/core/client/admin/routes/stories/components/Stories.spec.tsx create mode 100644 src/core/client/admin/routes/stories/components/StoryRow.css create mode 100644 src/core/client/admin/routes/stories/components/StoryRow.tsx create mode 100644 src/core/client/admin/routes/stories/components/StoryTable.css create mode 100644 src/core/client/admin/routes/stories/components/StoryTable.tsx create mode 100644 src/core/client/admin/routes/stories/components/StoryTableFilter.css create mode 100644 src/core/client/admin/routes/stories/components/StoryTableFilter.tsx delete mode 100644 src/core/client/admin/routes/stories/components/__snapshots__/Stories.spec.tsx.snap create mode 100644 src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx create mode 100644 src/core/client/admin/routes/stories/containers/StoriesContainer.tsx create mode 100644 src/core/client/admin/routes/stories/containers/StoryRowContainer.tsx create mode 100644 src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx create mode 100644 src/core/client/admin/test/stories/__snapshots__/stories.spec.tsx.snap create mode 100644 src/core/client/admin/test/stories/stories.spec.tsx diff --git a/package-lock.json b/package-lock.json index cfc280039..e5316eca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 } diff --git a/src/core/client/admin/components/Navigation.tsx b/src/core/client/admin/components/Navigation.tsx index 67b2135c9..791c6c2ca 100644 --- a/src/core/client/admin/components/Navigation.tsx +++ b/src/core/client/admin/components/Navigation.tsx @@ -5,17 +5,14 @@ import { AppBarNavigation } from "talk-ui/components"; import NavigationLink from "./NavigationLink"; -/* TODO: - - Stories - - */ - const Navigation: StatelessComponent = () => ( Moderate + + Stories + Community diff --git a/src/core/client/admin/components/NotAvailable.css b/src/core/client/admin/components/NotAvailable.css new file mode 100644 index 000000000..4eef434ca --- /dev/null +++ b/src/core/client/admin/components/NotAvailable.css @@ -0,0 +1,3 @@ +.root { + color: var(--palette-grey-light); +} diff --git a/src/core/client/admin/routes/community/components/NotAvailable.tsx b/src/core/client/admin/components/NotAvailable.tsx similarity index 59% rename from src/core/client/admin/routes/community/components/NotAvailable.tsx rename to src/core/client/admin/components/NotAvailable.tsx index e1fee8d36..73f9c23fc 100644 --- a/src/core/client/admin/routes/community/components/NotAvailable.tsx +++ b/src/core/client/admin/components/NotAvailable.tsx @@ -1,9 +1,11 @@ import { Localized } from "fluent-react/compat"; import React, { StatelessComponent } from "react"; +import styles from "./NotAvailable.css"; + const NotAvailable: StatelessComponent = props => ( - - Not available + + Not available ); diff --git a/src/core/client/admin/components/TranslatedStoryStatus.tsx b/src/core/client/admin/components/TranslatedStoryStatus.tsx new file mode 100644 index 000000000..673071375 --- /dev/null +++ b/src/core/client/admin/components/TranslatedStoryStatus.tsx @@ -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 | React.ComponentType | string; + children: GQLSTORY_STATUS_RL; +} + +function createElement( + Container: React.ReactElement | React.ComponentType | string, + children: React.ReactNode +) { + if (React.isValidElement(Container)) { + return React.cloneElement(Container, { children }); + } else { + return {children}; + } +} + +const TranslatedRole: React.StatelessComponent = props => { + switch (props.children) { + case GQLSTORY_STATUS.OPEN: + return ( + + {createElement(props.container!, "Open")} + + ); + case GQLSTORY_STATUS.CLOSED: + return ( + + {createElement(props.container!, "Closed")} + + ); + default: + // Unknown role, just use untranslated string. + return createElement(props.container!, props.children); + } +}; + +TranslatedRole.defaultProps = { + container: "span", +}; + +export default TranslatedRole; diff --git a/src/core/client/admin/components/__snapshots__/Navigation.spec.tsx.snap b/src/core/client/admin/components/__snapshots__/Navigation.spec.tsx.snap index 2887e3184..cea050561 100644 --- a/src/core/client/admin/components/__snapshots__/Navigation.spec.tsx.snap +++ b/src/core/client/admin/components/__snapshots__/Navigation.spec.tsx.snap @@ -11,6 +11,15 @@ exports[`renders correctly 1`] = ` Moderate + + + Stories + + diff --git a/src/core/client/admin/mutations/CloseStoryMutation.ts b/src/core/client/admin/mutations/CloseStoryMutation.ts new file mode 100644 index 000000000..be7c689d9 --- /dev/null +++ b/src/core/client/admin/mutations/CloseStoryMutation.ts @@ -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; + +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(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; diff --git a/src/core/client/admin/mutations/OpenStoryMutation.ts b/src/core/client/admin/mutations/OpenStoryMutation.ts new file mode 100644 index 000000000..7698d0f29 --- /dev/null +++ b/src/core/client/admin/mutations/OpenStoryMutation.ts @@ -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; + +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(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; diff --git a/src/core/client/admin/mutations/UpdateUserRoleMutation.ts b/src/core/client/admin/mutations/UpdateUserRoleMutation.ts index 9a98da2a3..00626a69a 100644 --- a/src/core/client/admin/mutations/UpdateUserRoleMutation.ts +++ b/src/core/client/admin/mutations/UpdateUserRoleMutation.ts @@ -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: { diff --git a/src/core/client/admin/mutations/index.ts b/src/core/client/admin/mutations/index.ts index fd949c16a..72cbc3467 100644 --- a/src/core/client/admin/mutations/index.ts +++ b/src/core/client/admin/mutations/index.ts @@ -45,3 +45,13 @@ export { withUpdateUserRoleMutation, UpdateUserRoleMutation, } from "./UpdateUserRoleMutation"; +export { + OpenStoryInput, + withOpenStoryMutation, + OpenStoryMutation, +} from "./OpenStoryMutation"; +export { + CloseStoryInput, + withCloseStoryMutation, + CloseStoryMutation, +} from "./CloseStoryMutation"; diff --git a/src/core/client/admin/permissions.tsx b/src/core/client/admin/permissions.tsx index 58511f737..af79f43bc 100644 --- a/src/core/client/admin/permissions.tsx +++ b/src/core/client/admin/permissions.tsx @@ -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; diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index e40fd7def..3ec46a4fd 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -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( @@ -41,6 +42,7 @@ export default makeRouteConfig( /> + diff --git a/src/core/client/admin/routes/community/components/RoleChange.tsx b/src/core/client/admin/routes/community/components/RoleChange.tsx index d2de8cc78..efc37f4f4 100644 --- a/src/core/client/admin/routes/community/components/RoleChange.tsx +++ b/src/core/client/admin/routes/community/components/RoleChange.tsx @@ -23,7 +23,7 @@ interface Props { const RoleChange: StatelessComponent = props => ( ( diff --git a/src/core/client/admin/routes/community/components/UserRow.css b/src/core/client/admin/routes/community/components/UserRow.css new file mode 100644 index 000000000..08754354e --- /dev/null +++ b/src/core/client/admin/routes/community/components/UserRow.css @@ -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; +} diff --git a/src/core/client/admin/routes/community/components/UserRow.tsx b/src/core/client/admin/routes/community/components/UserRow.tsx index d24cb10c0..baabc75fe 100644 --- a/src/core/client/admin/routes/community/components/UserRow.tsx +++ b/src/core/client/admin/routes/community/components/UserRow.tsx @@ -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.username || } - + + {props.username || } + + {{props.email} || ( )} - {props.memberSince} - + + {props.memberSince} + + {props.canChangeRole ? ( ) : ( diff --git a/src/core/client/admin/routes/community/containers/UserRowContainer.tsx b/src/core/client/admin/routes/community/containers/UserRowContainer.tsx index bc6865cc3..63525c8db 100644 --- a/src/core/client/admin/routes/community/containers/UserRowContainer.tsx +++ b/src/core/client/admin/routes/community/containers/UserRowContainer.tsx @@ -24,7 +24,7 @@ const UserRowContainer: StatelessComponent = 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={ diff --git a/src/core/client/admin/routes/stories/components/EmptyMessage.css b/src/core/client/admin/routes/stories/components/EmptyMessage.css new file mode 100644 index 000000000..7b933aa00 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/EmptyMessage.css @@ -0,0 +1,3 @@ +.root { + font-size: calc(18rem / var(--rem-base)); +} diff --git a/src/core/client/admin/routes/stories/components/EmptyMessage.tsx b/src/core/client/admin/routes/stories/components/EmptyMessage.tsx new file mode 100644 index 000000000..fb95cfc14 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/EmptyMessage.tsx @@ -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 => ( + + + There are currently no published stories. + + +); + +export default EmptyMessage; diff --git a/src/core/client/admin/routes/stories/components/NoMatchMessage.css b/src/core/client/admin/routes/stories/components/NoMatchMessage.css new file mode 100644 index 000000000..7b933aa00 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/NoMatchMessage.css @@ -0,0 +1,3 @@ +.root { + font-size: calc(18rem / var(--rem-base)); +} diff --git a/src/core/client/admin/routes/stories/components/NoMatchMessage.tsx b/src/core/client/admin/routes/stories/components/NoMatchMessage.tsx new file mode 100644 index 000000000..ddcff762b --- /dev/null +++ b/src/core/client/admin/routes/stories/components/NoMatchMessage.tsx @@ -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 => ( + + + We could not find any stories matching your criteria. + + +); + +export default NoMatchMessage; diff --git a/src/core/client/admin/routes/stories/components/StatusChange.css b/src/core/client/admin/routes/stories/components/StatusChange.css new file mode 100644 index 000000000..6e953b31e --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StatusChange.css @@ -0,0 +1,9 @@ +.button { + width: 100%; + justify-content: space-between; + padding: 0; +} + +.dropdownButton { + min-width: 80px; +} diff --git a/src/core/client/admin/routes/stories/components/StatusChange.tsx b/src/core/client/admin/routes/stories/components/StatusChange.tsx new file mode 100644 index 000000000..d720670df --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StatusChange.tsx @@ -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 => ( + + ( + + + {Object.keys(GQLSTORY_STATUS).map((r: GQLSTORY_STATUS_RL) => ( + { + props.onChangeStatus(r); + toggleVisibility(); + }} + > + dummy + + } + > + {r} + + ))} + + + )} + > + {({ toggleVisibility, ref, visible }) => ( + + + + )} + + +); + +export default StatusChange; diff --git a/src/core/client/admin/routes/stories/components/StatusText.css b/src/core/client/admin/routes/stories/components/StatusText.css new file mode 100644 index 000000000..adebfb388 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StatusText.css @@ -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); +} diff --git a/src/core/client/admin/routes/stories/components/StatusText.tsx b/src/core/client/admin/routes/stories/components/StatusText.tsx new file mode 100644 index 000000000..a3181797f --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StatusText.tsx @@ -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.children} + +); + +export default StatusText; diff --git a/src/core/client/admin/routes/stories/components/Stories.css b/src/core/client/admin/routes/stories/components/Stories.css new file mode 100644 index 000000000..f27e73931 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/Stories.css @@ -0,0 +1,5 @@ +.root { + max-width: 950px; + margin-top: calc(3 * var(--spacing-unit)); + margin-bottom: calc(3 * var(--spacing-unit)); +} diff --git a/src/core/client/admin/routes/stories/components/Stories.spec.tsx b/src/core/client/admin/routes/stories/components/Stories.spec.tsx deleted file mode 100644 index e9a39e3ab..000000000 --- a/src/core/client/admin/routes/stories/components/Stories.spec.tsx +++ /dev/null @@ -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(); - expect(renderer.getRenderOutput()).toMatchSnapshot(); -}); diff --git a/src/core/client/admin/routes/stories/components/Stories.tsx b/src/core/client/admin/routes/stories/components/Stories.tsx index a12da7d25..19533cc05 100644 --- a/src/core/client/admin/routes/stories/components/Stories.tsx +++ b/src/core/client/admin/routes/stories/components/Stories.tsx @@ -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 }) => ( - - Stories +import StoryTableContainer from "../containers/StoryTableContainer"; + +import styles from "./Stories.css"; + +interface Props { + query: PropTypesOf["query"]; +} + +const Stories: StatelessComponent = props => ( + + ); diff --git a/src/core/client/admin/routes/stories/components/StoryRow.css b/src/core/client/admin/routes/stories/components/StoryRow.css new file mode 100644 index 000000000..7e92327d1 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StoryRow.css @@ -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; +} diff --git a/src/core/client/admin/routes/stories/components/StoryRow.tsx b/src/core/client/admin/routes/stories/components/StoryRow.tsx new file mode 100644 index 000000000..d2d208b92 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StoryRow.tsx @@ -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["status"]; +} + +const UserRow: StatelessComponent = props => ( + + + {props.title || } + + + {props.author || } + + + {props.publishDate || } + + + {props.canChangeStatus ? ( + + ) : ( + {props.status} + )} + + +); + +export default UserRow; diff --git a/src/core/client/admin/routes/stories/components/StoryTable.css b/src/core/client/admin/routes/stories/components/StoryTable.css new file mode 100644 index 000000000..f4abdebf3 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StoryTable.css @@ -0,0 +1,12 @@ +.titleColumn { + width: 50%; +} +.authorColumn { + width: 17.5%; +} +.publishDateColumn { + width: 17.5%; +} +.statusColumn { + width: 15%; +} diff --git a/src/core/client/admin/routes/stories/components/StoryTable.tsx b/src/core/client/admin/routes/stories/components/StoryTable.tsx new file mode 100644 index 000000000..88a995249 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StoryTable.tsx @@ -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["viewer"] | null; + stories: Array< + { id: string } & PropTypesOf["story"] + >; + onLoadMore: () => void; + hasMore: boolean; + disableLoadMore: boolean; + loading: boolean; + isSearching: boolean; +} + +const StoryTable: StatelessComponent = props => ( + <> + + + + + + Title + + + Author + + + + Publish Date + + + + Status + + + + + {!props.loading && + props.stories.map(u => ( + + ))} + +
+ {!props.loading && + props.stories.length === 0 && + (props.isSearching ? : )} + {props.loading && ( + + + + )} + {props.hasMore && ( + + + + )} +
+ +); + +export default StoryTable; diff --git a/src/core/client/admin/routes/stories/components/StoryTableFilter.css b/src/core/client/admin/routes/stories/components/StoryTableFilter.css new file mode 100644 index 000000000..06c450778 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StoryTableFilter.css @@ -0,0 +1,8 @@ +.legend { + margin-bottom: 2px; + text-transform: uppercase; +} + +.textField { + height: 31px; +} diff --git a/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx b/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx new file mode 100644 index 000000000..4ad42f4f0 --- /dev/null +++ b/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx @@ -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 => ( + +
+ + + Search + + + + + +
+
+ + + Show Me + + + + props.onSetStatusFilter(e.target.value as any)} + > + + + + + + + + + + + +
+
+); + +export default StoryTableFilter; diff --git a/src/core/client/admin/routes/stories/components/__snapshots__/Stories.spec.tsx.snap b/src/core/client/admin/routes/stories/components/__snapshots__/Stories.spec.tsx.snap deleted file mode 100644 index 6199d878d..000000000 --- a/src/core/client/admin/routes/stories/components/__snapshots__/Stories.spec.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders correctly 1`] = ` - - - Stories - - -`; diff --git a/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx b/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx new file mode 100644 index 000000000..25d1a5500 --- /dev/null +++ b/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx @@ -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 => { + 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 ( + + ); +}; + +const enhanced = withOpenStoryMutation( + withCloseStoryMutation(StatusChangeContainer) +); + +export default enhanced; diff --git a/src/core/client/admin/routes/stories/containers/StoriesContainer.tsx b/src/core/client/admin/routes/stories/containers/StoriesContainer.tsx new file mode 100644 index 000000000..275ba02f5 --- /dev/null +++ b/src/core/client/admin/routes/stories/containers/StoriesContainer.tsx @@ -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 => { + return ; +}; + +const enhanced = withRouteConfig({ + query: graphql` + query StoriesContainerQuery { + ...StoryTableContainer_query + } + `, + cacheConfig: { force: true }, +})(StoriesContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/stories/containers/StoryRowContainer.tsx b/src/core/client/admin/routes/stories/containers/StoryRowContainer.tsx new file mode 100644 index 000000000..ec162fd1d --- /dev/null +++ b/src/core/client/admin/routes/stories/containers/StoryRowContainer.tsx @@ -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 => { + 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 ( + + ); +}; + +const enhanced = withFragmentContainer({ + 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; diff --git a/src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx b/src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx new file mode 100644 index 000000000..3790e4551 --- /dev/null +++ b/src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx @@ -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 => { + 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( + 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 ( + + + + setStatusFilterAndRefetch(status || null) + } + statusFilter={statusFilter} + /> + + + + ); +}; + +// 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; diff --git a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap index 1262ccaa7..3101a8e84 100644 --- a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap +++ b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap @@ -138,12 +138,12 @@ exports[`renders community 1`] = ` className="TableRow-root TableRow-body" > Markus - 07/06/18 + 07/06/2018 Lukas - 07/06/18 + 07/06/2018
A dropdown to change the user role
diff --git a/src/core/client/admin/test/community/community.spec.tsx b/src/core/client/admin/test/community/community.spec.tsx index 03d4f418a..d8a12aeb1 100644 --- a/src/core/client/admin/test/community/community.spec.tsx +++ b/src/core/client/admin/test/community/community.spec.tsx @@ -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 { diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 8f3037c12..9e793aa8f 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -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> = [ + { + 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 }, +}; diff --git a/src/core/client/admin/test/stories/__snapshots__/stories.spec.tsx.snap b/src/core/client/admin/test/stories/__snapshots__/stories.spec.tsx.snap new file mode 100644 index 000000000..9ea9fb458 --- /dev/null +++ b/src/core/client/admin/test/stories/__snapshots__/stories.spec.tsx.snap @@ -0,0 +1,499 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders empty stories 1`] = ` +
+
+
+
+ + Search + +
+ +
+
+
+ + Show Me + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ Title + + Author + + Publish Date + + Status +
+ Finally a Cure for Cancer + + Vin Hoa + + 11/29/2018, 4:01 PM + +
+ +
+
+ A dropdown to change the story status +
+
+
+
+ First Colony on Mars + + Linh Nguyen + + 11/29/2018, 4:01 PM + +
+ +
+
+ A dropdown to change the story status +
+
+
+
+
+
+
+`; + +exports[`renders stories 1`] = ` +
+
+
+
+ + Search + +
+ +
+
+
+ + Show Me + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ Title + + Author + + Publish Date + + Status +
+ Finally a Cure for Cancer + + Vin Hoa + + 11/29/2018, 4:01 PM + +
+ +
+
+ A dropdown to change the story status +
+
+
+
+ First Colony on Mars + + Linh Nguyen + + 11/29/2018, 4:01 PM + +
+ +
+
+ A dropdown to change the story status +
+
+
+
+
+
+
+`; diff --git a/src/core/client/admin/test/stories/stories.spec.tsx b/src/core/client/admin/test/stories/stories.spec.tsx new file mode 100644 index 000000000..1c832f7fb --- /dev/null +++ b/src/core/client/admin/test/stories/stories.spec.tsx @@ -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!); +}); diff --git a/src/core/server/graph/tenant/loaders/Stories.ts b/src/core/server/graph/tenant/loaders/Stories.ts index e1d28020d..c4de49178 100644 --- a/src/core/server/graph/tenant/loaders/Stories.ts +++ b/src/core/server/graph/tenant/loaders/Stories.ts @@ -23,7 +23,7 @@ const statusFilter = ( switch (status) { case GQLSTORY_STATUS.OPEN: return { - closedAt: null, + closedAt: { $in: [null, false] }, }; case GQLSTORY_STATUS.CLOSED: return { diff --git a/src/core/server/graph/tenant/resolvers/Story.ts b/src/core/server/graph/tenant/resolvers/Story.ts index 0729e8910..0fb4e7cf7 100644 --- a/src/core/server/graph/tenant/resolvers/Story.ts +++ b/src/core/server/graph/tenant/resolvers/Story.ts @@ -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 = { 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, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 92e5496ac..033889992 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -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. """ diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index b02c0610c..a6bfd1905 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -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