From ab938985e43e897a7ea40070d71cc24ab7465f9a Mon Sep 17 00:00:00 2001 From: Kiwi Date: Fri, 26 Apr 2019 16:23:46 +0200 Subject: [PATCH] [CORL-294] Moderate a single story + quick search (#2286) * feat: allow passing a `storyID` to `Query.moderationQueues` * feat: moderate by story * feat: implement search story combobox * feat: add translations * fix: tests * fix: duplicate id * fix: rename file * chore: add more comments * fix: add missing translation * review: use query parameter "q" instead of url path * chore: move placeholder logic inside, maybe this makes it clearer :-D --- package-lock.json | 55 +++- package.json | 2 + .../fetches/DiscoverOIDCConfigurationQuery.ts | 55 ++-- .../client/admin/fetches/SearchStoryQuery.ts | 40 +++ src/core/client/admin/fetches/index.ts | 4 +- .../client/admin/helpers/getModerationLink.ts | 10 + .../admin/helpers/getQueueConnection.ts | 6 +- src/core/client/admin/helpers/index.ts | 1 + .../admin/mutations/AcceptCommentMutation.ts | 23 +- .../admin/mutations/RejectCommentMutation.ts | 21 +- src/core/client/admin/routeConfig.tsx | 17 ++ .../auth/containers/OIDCConfigContainer.tsx | 13 +- .../moderate/components/Moderate.spec.tsx | 22 +- .../routes/moderate/components/Moderate.tsx | 27 +- .../routes/moderate/components/Navigation.tsx | 12 +- .../routes/moderate/components/Search/Bar.css | 20 ++ .../routes/moderate/components/Search/Bar.tsx | 156 +++++++++++ .../moderate/components/Search/Field.css | 87 ++++++ .../moderate/components/Search/Field.tsx | 95 +++++++ .../components/Search/GoToAriaInfo.tsx | 12 + .../moderate/components/Search/Group.css | 24 ++ .../moderate/components/Search/Group.tsx | 35 +++ .../components/Search/ModerateAllOption.css | 22 ++ .../components/Search/ModerateAllOption.tsx | 42 +++ .../moderate/components/Search/Option.css | 42 +++ .../moderate/components/Search/Option.tsx | 49 ++++ .../components/Search/SeeAllOption.css | 33 +++ .../components/Search/SeeAllOption.tsx | 33 +++ .../moderate/components/Search/index.ts | 5 + .../__snapshots__/Moderate.spec.tsx.snap | 31 +- .../containers/ModerateCardContainer.tsx | 19 +- .../moderate/containers/ModerateContainer.tsx | 52 ++-- .../ModerateNavigationContainer.tsx | 50 ++++ .../containers/ModerateSearchBarContainer.tsx | 264 ++++++++++++++++++ .../moderate/containers/QueueContainer.tsx | 32 ++- .../containers/RejectedQueueContainer.tsx | 17 +- .../routes/stories/components/Stories.tsx | 6 +- .../routes/stories/components/StoryRow.tsx | 11 +- .../routes/stories/components/StoryTable.css | 3 + .../routes/stories/components/StoryTable.tsx | 16 +- .../stories/components/StoryTableFilter.tsx | 1 + .../stories/containers/StoriesContainer.tsx | 20 +- .../containers/StoryTableContainer.tsx | 5 +- .../admin/test/moderate/moderate.spec.tsx | 18 +- .../__snapshots__/stories.spec.tsx.snap | 60 +++- src/core/client/embed/story.html | 2 +- src/core/client/embed/storyButton.html | 2 +- src/core/client/framework/hooks/index.ts | 2 + .../framework/hooks/useEffectWhenChanged.ts | 21 ++ .../client/framework/hooks/usePrevious.ts | 13 + src/core/client/framework/lib/relay/fetch.tsx | 117 ++++++++ .../client/framework/lib/relay/fetchQuery.ts | 24 -- src/core/client/framework/lib/relay/index.ts | 9 +- .../client/framework/lib/relay/useRefetch.ts | 6 +- .../framework/lib/router/withRouteConfig.ts | 35 ++- .../stream/fetches/RefreshSettingsQuery.ts | 58 ++-- src/core/client/stream/fetches/index.ts | 5 +- .../__snapshots__/Stream.spec.tsx.snap | 8 +- .../containers/EditCommentFormContainer.tsx | 15 +- .../containers/PostCommentFormContainer.tsx | 11 +- .../containers/ReplyCommentFormContainer.tsx | 15 +- .../ui/components/BaseButton/BaseButton.css | 5 +- .../ui/components/CheckBox/CheckBox.css | 5 +- .../client/ui/components/Popover/Popover.tsx | 31 +- .../ui/components/RadioButton/RadioButton.css | 5 +- .../ui/components/SelectField/SelectField.css | 5 +- .../client/ui/components/Spinner/Spinner.tsx | 4 +- .../client/ui/components/SubBar/SubBar.tsx | 4 +- .../client/ui/components/Table/TableCell.css | 6 +- src/core/client/ui/components/index.ts | 1 + src/core/client/ui/helpers/blur.ts | 6 + .../client/ui/helpers/combineEventHandlers.ts | 35 +++ src/core/client/ui/helpers/index.ts | 2 + src/core/client/ui/hooks/index.ts | 4 + src/core/client/ui/hooks/useBlurOnEsc.ts | 17 ++ src/core/client/ui/hooks/useComboBox.ts | 114 ++++++++ src/core/client/ui/hooks/useFocus.ts | 12 + .../client/ui/hooks/usePreventFocusLoss.ts | 16 ++ src/core/client/ui/shared/typography.css | 16 ++ src/core/client/ui/theme/mixins.css | 6 + .../tenant/resolvers/AcceptCommentPayload.ts | 4 +- .../tenant/resolvers/ModerationQueues.ts | 9 +- .../server/graph/tenant/resolvers/Query.ts | 4 +- .../tenant/resolvers/RejectCommentPayload.ts | 4 +- .../server/graph/tenant/schema/schema.graphql | 5 +- src/locales/en-US/admin.ftl | 22 ++ 86 files changed, 1934 insertions(+), 319 deletions(-) create mode 100644 src/core/client/admin/fetches/SearchStoryQuery.ts create mode 100644 src/core/client/admin/helpers/getModerationLink.ts create mode 100644 src/core/client/admin/routes/moderate/components/Search/Bar.css create mode 100644 src/core/client/admin/routes/moderate/components/Search/Bar.tsx create mode 100644 src/core/client/admin/routes/moderate/components/Search/Field.css create mode 100644 src/core/client/admin/routes/moderate/components/Search/Field.tsx create mode 100644 src/core/client/admin/routes/moderate/components/Search/GoToAriaInfo.tsx create mode 100644 src/core/client/admin/routes/moderate/components/Search/Group.css create mode 100644 src/core/client/admin/routes/moderate/components/Search/Group.tsx create mode 100644 src/core/client/admin/routes/moderate/components/Search/ModerateAllOption.css create mode 100644 src/core/client/admin/routes/moderate/components/Search/ModerateAllOption.tsx create mode 100644 src/core/client/admin/routes/moderate/components/Search/Option.css create mode 100644 src/core/client/admin/routes/moderate/components/Search/Option.tsx create mode 100644 src/core/client/admin/routes/moderate/components/Search/SeeAllOption.css create mode 100644 src/core/client/admin/routes/moderate/components/Search/SeeAllOption.tsx create mode 100644 src/core/client/admin/routes/moderate/components/Search/index.ts create mode 100644 src/core/client/admin/routes/moderate/containers/ModerateNavigationContainer.tsx create mode 100644 src/core/client/admin/routes/moderate/containers/ModerateSearchBarContainer.tsx create mode 100644 src/core/client/framework/hooks/useEffectWhenChanged.ts create mode 100644 src/core/client/framework/hooks/usePrevious.ts create mode 100644 src/core/client/framework/lib/relay/fetch.tsx delete mode 100644 src/core/client/framework/lib/relay/fetchQuery.ts create mode 100644 src/core/client/ui/helpers/blur.ts create mode 100644 src/core/client/ui/helpers/combineEventHandlers.ts create mode 100644 src/core/client/ui/helpers/index.ts create mode 100644 src/core/client/ui/hooks/index.ts create mode 100644 src/core/client/ui/hooks/useBlurOnEsc.ts create mode 100644 src/core/client/ui/hooks/useComboBox.ts create mode 100644 src/core/client/ui/hooks/useFocus.ts create mode 100644 src/core/client/ui/hooks/usePreventFocusLoss.ts diff --git a/package-lock.json b/package-lock.json index 04a06e0cd..aac57bad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2928,6 +2928,12 @@ "@types/mime": "*" } }, + "@types/shallow-equals": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/shallow-equals/-/shallow-equals-1.0.0.tgz", + "integrity": "sha512-XtGSj7GYPfJwaklDtMEONj+kmpyCP8OLYoPqp/ROM8BL1VaF2IgYbxiEKfLvOyHN7c2d1KAFYzy6EIu8CSFt1A==", + "dev": true + }, "@types/simplemde": { "version": "1.11.7", "resolved": "https://registry.npmjs.org/@types/simplemde/-/simplemde-1.11.7.tgz", @@ -12117,7 +12123,7 @@ "integrity": "sha1-ETOUSrJHeINHOZVZaIPg05z4hc8=", "dev": true, "requires": { - "intl-pluralrules": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b" + "intl-pluralrules": "github:projectfluent/IntlPluralRules#module" } }, "fluent-langneg": { @@ -12427,7 +12433,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", @@ -12448,12 +12455,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" @@ -12468,17 +12477,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", @@ -12595,7 +12607,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", @@ -12607,6 +12620,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" } @@ -12621,6 +12635,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" } @@ -12628,12 +12643,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" @@ -12652,6 +12669,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" } @@ -12732,7 +12750,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", @@ -12744,6 +12763,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "optional": true, "requires": { "wrappy": "1" } @@ -12829,7 +12849,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", @@ -12865,6 +12886,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", @@ -12884,6 +12906,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" } @@ -12927,12 +12950,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 } } }, @@ -25820,6 +25845,12 @@ } } }, + "shallow-equals": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shallow-equals/-/shallow-equals-1.0.0.tgz", + "integrity": "sha1-JLdL8cY0wR7Uxxgqbfb7MA3OQ5A=", + "dev": true + }, "shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", diff --git a/package.json b/package.json index 65e455e20..e895bbae6 100644 --- a/package.json +++ b/package.json @@ -182,6 +182,7 @@ "@types/recompose": "^0.26.5", "@types/relay-runtime": "^1.3.6", "@types/sane": "^2.0.0", + "@types/shallow-equals": "^1.0.0", "@types/simplemde": "^1.11.7", "@types/sinon": "^5.0.1", "@types/source-map-support": "^0.4.1", @@ -284,6 +285,7 @@ "relay-local-schema": "^0.7.0", "relay-runtime": "^1.7.0-rc.1", "sane": "^4.0.2", + "shallow-equals": "^1.0.0", "simplemde": "^1.11.2", "simulant": "^0.2.2", "sinon": "^6.1.5", diff --git a/src/core/client/admin/fetches/DiscoverOIDCConfigurationQuery.ts b/src/core/client/admin/fetches/DiscoverOIDCConfigurationQuery.ts index 9dbf89c61..ad8602b84 100644 --- a/src/core/client/admin/fetches/DiscoverOIDCConfigurationQuery.ts +++ b/src/core/client/admin/fetches/DiscoverOIDCConfigurationQuery.ts @@ -2,38 +2,31 @@ import { graphql } from "react-relay"; import { Environment } from "relay-runtime"; import { DiscoverOIDCConfigurationQuery as QueryTypes } from "talk-admin/__generated__/DiscoverOIDCConfigurationQuery.graphql"; -import { createFetchContainer, fetchQuery } from "talk-framework/lib/relay"; +import { + createFetch, + fetchQuery, + FetchVariables, +} from "talk-framework/lib/relay"; -export type DiscoverOIDCConfigurationVariables = QueryTypes["variables"]; - -const query = graphql` - query DiscoverOIDCConfigurationQuery($issuer: String!) { - discoverOIDCConfiguration(issuer: $issuer) { - issuer - authorizationURL - tokenURL - jwksURI - } - } -`; - -function fetch( - environment: Environment, - variables: DiscoverOIDCConfigurationVariables -) { - return fetchQuery( - environment, - query, - variables, - { force: true } - ); -} - -export const withDiscoverOIDCConfigurationFetch = createFetchContainer( +const DiscoverOIDCConfigurationFetch = createFetch( "discoverOIDCConfiguration", - fetch + (environment: Environment, variables: FetchVariables) => { + return fetchQuery( + environment, + graphql` + query DiscoverOIDCConfigurationQuery($issuer: String!) { + discoverOIDCConfiguration(issuer: $issuer) { + issuer + authorizationURL + tokenURL + jwksURI + } + } + `, + variables, + { force: true } + ); + } ); -export type DiscoverOIDCConfigurationFetch = ( - variables: DiscoverOIDCConfigurationVariables -) => Promise; +export default DiscoverOIDCConfigurationFetch; diff --git a/src/core/client/admin/fetches/SearchStoryQuery.ts b/src/core/client/admin/fetches/SearchStoryQuery.ts new file mode 100644 index 000000000..20c8c8904 --- /dev/null +++ b/src/core/client/admin/fetches/SearchStoryQuery.ts @@ -0,0 +1,40 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { SearchStoryQuery as QueryTypes } from "talk-admin/__generated__/SearchStoryQuery.graphql"; +import { + createFetch, + fetchQuery, + FetchVariables, +} from "talk-framework/lib/relay"; + +const SearchStoryFetch = createFetch( + "searchStory", + (environment: Environment, variables: FetchVariables) => { + return fetchQuery( + environment, + graphql` + query SearchStoryQuery($query: String!, $limit: Int!) { + stories(query: $query, first: $limit) { + edges { + node { + id + metadata { + title + author + } + } + } + pageInfo { + hasNextPage + } + } + } + `, + variables, + { force: true } + ); + } +); + +export default SearchStoryFetch; diff --git a/src/core/client/admin/fetches/index.ts b/src/core/client/admin/fetches/index.ts index f286876b6..e11336e0a 100644 --- a/src/core/client/admin/fetches/index.ts +++ b/src/core/client/admin/fetches/index.ts @@ -1,4 +1,4 @@ export { - withDiscoverOIDCConfigurationFetch, - DiscoverOIDCConfigurationFetch, + default as DiscoverOIDCConfigurationFetch, } from "./DiscoverOIDCConfigurationQuery"; +export { default as SearchStoryFetch } from "./SearchStoryQuery"; diff --git a/src/core/client/admin/helpers/getModerationLink.ts b/src/core/client/admin/helpers/getModerationLink.ts new file mode 100644 index 000000000..6fe59ca2b --- /dev/null +++ b/src/core/client/admin/helpers/getModerationLink.ts @@ -0,0 +1,10 @@ +const basePath = "/admin/moderate"; + +export default function getModerationLink( + queue?: "default" | "reported" | "pending" | "unmoderated" | "rejected", + storyID?: string | null +) { + const queuePart = queue && queue !== "default" ? `/${queue}` : ""; + const storyPart = storyID ? `/${storyID}` : ""; + return `${basePath}${queuePart}${storyPart}`; +} diff --git a/src/core/client/admin/helpers/getQueueConnection.ts b/src/core/client/admin/helpers/getQueueConnection.ts index 08f0faefa..f8191ff1f 100644 --- a/src/core/client/admin/helpers/getQueueConnection.ts +++ b/src/core/client/admin/helpers/getQueueConnection.ts @@ -3,16 +3,18 @@ import { ConnectionHandler, RecordSourceSelectorProxy } from "relay-runtime"; type Queue = "reported" | "pending" | "unmoderated" | "rejected"; export default function getQueueConnection( + store: RecordSourceSelectorProxy, queue: Queue, - store: RecordSourceSelectorProxy + storyID?: string ) { const root = store.getRoot(); if (queue === "rejected") { return ConnectionHandler.getConnection(root, "RejectedQueue_comments", { status: "REJECTED", + storyID, }); } - const queuesRecord = root.getLinkedRecord("moderationQueues")!; + const queuesRecord = root.getLinkedRecord("moderationQueues", { storyID })!; if (!queuesRecord) { return null; } diff --git a/src/core/client/admin/helpers/index.ts b/src/core/client/admin/helpers/index.ts index 1555be6e2..56b0ff727 100644 --- a/src/core/client/admin/helpers/index.ts +++ b/src/core/client/admin/helpers/index.ts @@ -1 +1,2 @@ export { default as getQueueConnection } from "./getQueueConnection"; +export { default as getModerationLink } from "./getModerationLink"; diff --git a/src/core/client/admin/mutations/AcceptCommentMutation.ts b/src/core/client/admin/mutations/AcceptCommentMutation.ts index 39544c2a4..ac20bc4f6 100644 --- a/src/core/client/admin/mutations/AcceptCommentMutation.ts +++ b/src/core/client/admin/mutations/AcceptCommentMutation.ts @@ -13,16 +13,22 @@ let clientMutationId = 0; const AcceptCommentMutation = createMutation( "acceptComment", - (environment: Environment, input: MutationInput) => + ( + environment: Environment, + input: MutationInput & { storyID?: string } + ) => commitMutationPromiseNormalized(environment, { mutation: graphql` - mutation AcceptCommentMutation($input: AcceptCommentInput!) { + mutation AcceptCommentMutation( + $input: AcceptCommentInput! + $storyID: ID + ) { acceptComment(input: $input) { comment { id status } - moderationQueues { + moderationQueues(storyID: $storyID) { unmoderated { count } @@ -39,7 +45,8 @@ const AcceptCommentMutation = createMutation( `, variables: { input: { - ...input, + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, clientMutationId: clientMutationId.toString(), }, }, @@ -54,10 +61,10 @@ const AcceptCommentMutation = createMutation( }, updater: store => { const connections = [ - getQueueConnection("reported", store), - getQueueConnection("pending", store), - getQueueConnection("unmoderated", store), - getQueueConnection("rejected", store), + getQueueConnection(store, "reported", input.storyID), + getQueueConnection(store, "pending", input.storyID), + getQueueConnection(store, "unmoderated", input.storyID), + getQueueConnection(store, "rejected", input.storyID), ].filter(c => c); connections.forEach(con => ConnectionHandler.deleteNode(con, input.commentID) diff --git a/src/core/client/admin/mutations/RejectCommentMutation.ts b/src/core/client/admin/mutations/RejectCommentMutation.ts index 42e9b0673..edaaa4afa 100644 --- a/src/core/client/admin/mutations/RejectCommentMutation.ts +++ b/src/core/client/admin/mutations/RejectCommentMutation.ts @@ -13,16 +13,22 @@ let clientMutationId = 0; const RejectCommentMutation = createMutation( "rejectComment", - (environment: Environment, input: MutationInput) => + ( + environment: Environment, + input: MutationInput & { storyID?: string } + ) => commitMutationPromiseNormalized(environment, { mutation: graphql` - mutation RejectCommentMutation($input: RejectCommentInput!) { + mutation RejectCommentMutation( + $input: RejectCommentInput! + $storyID: ID + ) { rejectComment(input: $input) { comment { id status } - moderationQueues { + moderationQueues(storyID: $storyID) { unmoderated { count } @@ -39,7 +45,8 @@ const RejectCommentMutation = createMutation( `, variables: { input: { - ...input, + commentID: input.commentID, + commentRevisionID: input.commentRevisionID, clientMutationId: clientMutationId.toString(), }, }, @@ -54,9 +61,9 @@ const RejectCommentMutation = createMutation( }, updater: store => { const connections = [ - getQueueConnection("reported", store), - getQueueConnection("pending", store), - getQueueConnection("unmoderated", store), + getQueueConnection(store, "reported", input.storyID), + getQueueConnection(store, "pending", input.storyID), + getQueueConnection(store, "unmoderated", input.storyID), ].filter(c => c); connections.forEach(con => ConnectionHandler.deleteNode(con, input.commentID) diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index 871689a83..0e9040774 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -36,12 +36,29 @@ export default makeRouteConfig( + + + + + diff --git a/src/core/client/admin/routes/configure/sections/auth/containers/OIDCConfigContainer.tsx b/src/core/client/admin/routes/configure/sections/auth/containers/OIDCConfigContainer.tsx index 79fbd211d..9131dd35c 100644 --- a/src/core/client/admin/routes/configure/sections/auth/containers/OIDCConfigContainer.tsx +++ b/src/core/client/admin/routes/configure/sections/auth/containers/OIDCConfigContainer.tsx @@ -5,11 +5,12 @@ import { graphql } from "react-relay"; import { OIDCConfigContainer_auth as AuthData } from "talk-admin/__generated__/OIDCConfigContainer_auth.graphql"; import { OIDCConfigContainer_authReadOnly as AuthReadOnlyData } from "talk-admin/__generated__/OIDCConfigContainer_authReadOnly.graphql"; +import { DiscoverOIDCConfigurationFetch } from "talk-admin/fetches"; import { - DiscoverOIDCConfigurationFetch, - withDiscoverOIDCConfigurationFetch, -} from "talk-admin/fetches"; -import { withFragmentContainer } from "talk-framework/lib/relay"; + FetchProp, + withFetch, + withFragmentContainer, +} from "talk-framework/lib/relay"; import OIDCConfig from "../components/OIDCConfig"; @@ -18,7 +19,7 @@ interface Props { authReadOnly: AuthReadOnlyData; onInitValues: (values: AuthData) => void; disabled?: boolean; - discoverOIDCConfiguration: DiscoverOIDCConfigurationFetch; + discoverOIDCConfiguration: FetchProp; } interface State { @@ -74,7 +75,7 @@ class OIDCConfigContainer extends React.Component { } } -const enhanced = withDiscoverOIDCConfigurationFetch( +const enhanced = withFetch(DiscoverOIDCConfigurationFetch)( withFragmentContainer({ auth: graphql` fragment OIDCConfigContainer_auth on Auth { diff --git a/src/core/client/admin/routes/moderate/components/Moderate.spec.tsx b/src/core/client/admin/routes/moderate/components/Moderate.spec.tsx index 9f4ca86a5..135f0a4d9 100644 --- a/src/core/client/admin/routes/moderate/components/Moderate.spec.tsx +++ b/src/core/client/admin/routes/moderate/components/Moderate.spec.tsx @@ -1,22 +1,20 @@ import React from "react"; import { createRenderer } from "react-test-renderer/shallow"; +import { removeFragmentRefs } from "talk-framework/testHelpers"; +import { PropTypesOf } from "talk-framework/types"; + import Moderate from "./Moderate"; -import { PropTypesOf } from "talk-framework/types"; -it("renders correctly", () => { - const renderer = createRenderer(); - renderer.render(); - expect(renderer.getRenderOutput()).toMatchSnapshot(); -}); +const ModerateN = removeFragmentRefs(Moderate); -it("renders correctly with counts", () => { - const props: PropTypesOf = { - unmoderatedCount: 3, - reportedCount: 4, - pendingCount: 0, +it("renders correctly", () => { + const props: PropTypesOf = { + allStories: true, + moderationQueues: {}, + story: {}, }; const renderer = createRenderer(); - renderer.render(); + renderer.render(); expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/routes/moderate/components/Moderate.tsx b/src/core/client/admin/routes/moderate/components/Moderate.tsx index ffc52ada9..49fe6d263 100644 --- a/src/core/client/admin/routes/moderate/components/Moderate.tsx +++ b/src/core/client/admin/routes/moderate/components/Moderate.tsx @@ -1,31 +1,36 @@ import React, { StatelessComponent } from "react"; import MainLayout from "talk-admin/components/MainLayout"; +import { PropTypesOf } from "talk-framework/types"; import { SubBar } from "talk-ui/components/SubBar"; -import Navigation from "./Navigation"; +import ModerateNavigationContainer from "../containers/ModerateNavigationContainer"; +import ModerateSearchBarContainer from "../containers/ModerateSearchBarContainer"; import styles from "./Moderate.css"; interface Props { - unmoderatedCount?: number; - reportedCount?: number; - pendingCount?: number; + story: PropTypesOf["story"] & + PropTypesOf["story"]; + moderationQueues: PropTypesOf< + typeof ModerateNavigationContainer + >["moderationQueues"]; + allStories: boolean; children?: React.ReactNode; } const Moderate: StatelessComponent = ({ - unmoderatedCount, - reportedCount, - pendingCount, + moderationQueues, + story, + allStories, children, }) => (
+ -
diff --git a/src/core/client/admin/routes/moderate/components/Navigation.tsx b/src/core/client/admin/routes/moderate/components/Navigation.tsx index 0b4fff099..eafcf5418 100644 --- a/src/core/client/admin/routes/moderate/components/Navigation.tsx +++ b/src/core/client/admin/routes/moderate/components/Navigation.tsx @@ -1,6 +1,7 @@ import { Localized } from "fluent-react/compat"; import React, { StatelessComponent } from "react"; +import { getModerationLink } from "talk-admin/helpers"; import { Counter, Icon, SubBarNavigation } from "talk-ui/components"; import NavigationLink from "./NavigationLink"; @@ -9,16 +10,17 @@ interface Props { unmoderatedCount?: number; reportedCount?: number; pendingCount?: number; - children?: React.ReactNode; + storyID?: string | null; } const Navigation: StatelessComponent = ({ unmoderatedCount, reportedCount, pendingCount, + storyID, }) => ( - + flag Reported @@ -29,7 +31,7 @@ const Navigation: StatelessComponent = ({ )} - + access_time Pending @@ -40,7 +42,7 @@ const Navigation: StatelessComponent = ({ )} - + forum Unmoderated @@ -51,7 +53,7 @@ const Navigation: StatelessComponent = ({ )} - + cancel Rejected diff --git a/src/core/client/admin/routes/moderate/components/Search/Bar.css b/src/core/client/admin/routes/moderate/components/Search/Bar.css new file mode 100644 index 000000000..49ad805e0 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/Bar.css @@ -0,0 +1,20 @@ +.root { + height: calc(5 * var(--spacing-unit)); + background-color: #013f68; + margin-top: -1px; +} + +.bumpZIndex { + z-index: 100; +} + +.popover { + width: calc(75 * var(--spacing-unit)); + border: 0; +} + +.listBox { + margin: 0; + padding: 0; + list-style: none; +} diff --git a/src/core/client/admin/routes/moderate/components/Search/Bar.tsx b/src/core/client/admin/routes/moderate/components/Search/Bar.tsx new file mode 100644 index 000000000..8d47dc780 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/Bar.tsx @@ -0,0 +1,156 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useCallback } from "react"; +import { Form } from "react-final-form"; + +import { Backdrop, Icon, Popover, SubBar } from "talk-ui/components"; +import { combineEventHandlers } from "talk-ui/helpers"; +import { + useBlurOnEsc, + useComboBox, + useFocus, + usePreventFocusLoss, +} from "talk-ui/hooks"; +import { ListBoxOption } from "talk-ui/hooks/useComboBox"; + +import Field from "./Field"; +import Group from "./Group"; + +import styles from "./Bar.css"; + +/** Group of listbox options. */ +type Group = "CONTEXT" | "SEARCH"; + +interface Props { + /** title of the current story */ + title: string; + /** options to show in the combobox listbox */ + options: Array; + /** onSearch will be called whenenver the user submits the search */ + onSearch?: (value: string) => void; +} + +/** + * Bar is the container of the whole search bar. + */ +const Bar: FunctionComponent = ({ title, options, onSearch }) => { + const [focused, focusHandlers] = useFocus(); + const preventFocusLossHandlers = usePreventFocusLoss(focused); + const submitHandler = useCallback( + ({ search }: { search: string }) => onSearch && onSearch(search), + [onSearch] + ); + const blurOnEscProps = useBlurOnEsc(focused); + const [ + mappedOptions, + activeDescendant, + keyboardNavigationHandlers, + ] = useComboBox("moderate-searchBar-listBoxOption", options); + + const contextOptions = mappedOptions + .filter(o => o.group === "CONTEXT") + .map(o => o.element); + const searchOptions = mappedOptions + .filter(o => o.group === "SEARCH") + .map(o => o.element); + + return ( + + + +
+ {({ handleSubmit }) => ( + + + ( +
    + {contextOptions.length > 0 && ( + + + {contextOptions} + + + )} + {searchOptions.length > 0 && ( + + search{" "} + + Search results (Most recent first) + + + } + id="moderate-searchBar-search" + light + > + {searchOptions} + + )} +
+ )} + > + {({ ref }) => ( +
+ +
+ )} +
+ +
+ )} + +
+
+ ); +}; + +export default Bar; diff --git a/src/core/client/admin/routes/moderate/components/Search/Field.css b/src/core/client/admin/routes/moderate/components/Search/Field.css new file mode 100644 index 000000000..81e0c8fd7 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/Field.css @@ -0,0 +1,87 @@ +.root { + width: calc(75 * var(--spacing-unit)); + height: calc(3 * var(--spacing-unit)); +} + +.begin { + background-color: var(--palette-primary-darkest); + border-top-left-radius: var(--round-corners); + border-bottom-left-radius: var(--round-corners); + min-width: calc(4 * var(--spacing-unit)); + flex-shrink: 0; + pointer-events: none; +} + +.beginStories { + font-size: calc(13rem / var(--rem-base)); + font-weight: var(--font-weight-medium); + font-family: var(--font-family-sans-serif); + line-height: 1.5; + letter-spacing: calc(0.2em / 13); + color: var(--palette-text-light); + text-transform: uppercase; + padding-right: calc(0.25 * var(--spacing-unit)); +} + +.searchIcon { + padding: 0 calc(0.5 * var(--spacing-unit)) 0 calc(0.75 * var(--spacing-unit)); + font-weight: var(--font-weight-medium); + color: var(--palette-text-light); +} + +.end { + min-width: calc(4 * var(--spacing-unit)); + background-color: var(--palette-primary-darkest); + border-top-right-radius: var(--round-corners); + border-bottom-right-radius: var(--round-corners); + flex-shrink: 0; +} + +.searchButton { + composes: button from "talk-ui/shared/typography.css"; + padding: 0 calc(1 * var(--spacing-unit)); + color: var(--palette-text-light); + border-left: 1px solid var(--palette-text-light); + height: calc(3 * var(--spacing-unit) - 4px); + + &:disabled { + cursor: pointer; + } +} + +.input { + composes: inputText placeholderPseudo from "talk-ui/shared/typography.css"; + position: relative; + display: block; + padding: calc(0.5 * var(--spacing-unit)); + box-sizing: border-box; + width: 100%; + line-height: 30px; + align-self: stretch; + color: var(--palette-text-light); + border: 0; + background-color: var(--palette-primary-darkest); + + &:focus { + outline: none; + } + + &::placeholder { + color: var(--palette-text-light); + opacity: 0.5; + } + + &:read-only { + opacity: 0.5; + } + &:disabled { + opacity: 0.5; + } +} + +.inputWithTitle { + text-align: center; + &::placeholder { + opacity: 1; + } +} diff --git a/src/core/client/admin/routes/moderate/components/Search/Field.tsx b/src/core/client/admin/routes/moderate/components/Search/Field.tsx new file mode 100644 index 000000000..ac3e4da04 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/Field.tsx @@ -0,0 +1,95 @@ +import cn from "classnames"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, HTMLAttributes } from "react"; +import { Field as FormField } from "react-final-form"; + +import { BaseButton, Flex, Icon } from "talk-ui/components"; + +import styles from "./Field.css"; + +interface Props extends HTMLAttributes { + /** title of the story */ + title: string; + className?: string; + focused?: boolean; +} + +/** + * Field is the TextField for the search entry. + */ +const Field: FunctionComponent = ({ + title, + focused, + className, + onBlur, + onChange, + ...rest +}) => { + return ( + + {({ input }) => ( + + + + search + + {focused && ( + +
Stories:
+
+ )} +
+ + { + if (onChange) { + onChange(evt); + } + input.onChange(evt); + }} + value={input.value} + className={cn(styles.input, { + [styles.inputWithTitle]: !focused, + })} + placeholder={ + focused + ? "Use quotation marks around each search term (e.g. “team”, “St. Louis”)" + : title + } + aria-label="Search or jump to story..." + autoComplete="off" + spellCheck={false} + onBlur={evt => { + // Reset value when blurring. + input.onChange(""); + if (onBlur) { + onBlur(evt); + } + }} + {...rest} + /> + + + {focused && ( + + + Search + + + )} + +
+ )} +
+ ); +}; + +export default Field; diff --git a/src/core/client/admin/routes/moderate/components/Search/GoToAriaInfo.tsx b/src/core/client/admin/routes/moderate/components/Search/GoToAriaInfo.tsx new file mode 100644 index 000000000..6400ccddf --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/GoToAriaInfo.tsx @@ -0,0 +1,12 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +import { AriaInfo } from "talk-ui/components"; + +const GoToAriaInfo: FunctionComponent = () => ( + + Go to + +); + +export default GoToAriaInfo; diff --git a/src/core/client/admin/routes/moderate/components/Search/Group.css b/src/core/client/admin/routes/moderate/components/Search/Group.css new file mode 100644 index 000000000..05ab6e10a --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/Group.css @@ -0,0 +1,24 @@ +.root { + margin: 0; + padding: 0; + list-style: none; +} + +.title { + display: flex; + align-items: center; + height: calc(3 * var(--spacing-unit)); + padding-left: calc(1.5 * var(--spacing-unit)); + background: var(--palette-text-primary); + + font-family: var(--font-family-sans-serif); + font-weight: var(--font-weight-medium); + font-size: calc(13rem / var(--rem-base)); + line-height: calc(16em / 14); + color: var(--palette-text-light); + text-transform: uppercase; +} + +.light { + background: var(--palette-grey-light); +} diff --git a/src/core/client/admin/routes/moderate/components/Search/Group.tsx b/src/core/client/admin/routes/moderate/components/Search/Group.tsx new file mode 100644 index 000000000..fc37eb348 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/Group.tsx @@ -0,0 +1,35 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import styles from "./Group.css"; + +interface Props { + id: string; + title: React.ReactNode; + light?: boolean; + children?: React.ReactNode; +} + +/** + * Group represents a ListBox Group + */ +const Group: FunctionComponent = ({ title, children, id, light }) => { + return ( +
    +
  • + {title} +
  • + {children} +
+ ); +}; + +export default Group; diff --git a/src/core/client/admin/routes/moderate/components/Search/ModerateAllOption.css b/src/core/client/admin/routes/moderate/components/Search/ModerateAllOption.css new file mode 100644 index 000000000..e9506f600 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/ModerateAllOption.css @@ -0,0 +1,22 @@ +.root { + &:not(:first-child) { + border-top: 1px solid var(--palette-divider); + } + &[aria-selected="true"] { + @mixin outline; + } +} + +.link { + justify-content: left; + min-height: calc(4 * var(--spacing-unit)); + font-family: var(--font-family-sans-serif); + font-weight: var(--font-weight-medium); + font-size: calc(16rem / var(--rem-base)); + line-height: calc(16em / 16); +} + +.icon { + font-weight: var(--font-weight-medium); + margin-top: -2px; +} diff --git a/src/core/client/admin/routes/moderate/components/Search/ModerateAllOption.tsx b/src/core/client/admin/routes/moderate/components/Search/ModerateAllOption.tsx new file mode 100644 index 000000000..417df6724 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/ModerateAllOption.tsx @@ -0,0 +1,42 @@ +import cn from "classnames"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, HTMLAttributes } from "react"; + +import { Button, Icon } from "talk-ui/components"; + +import styles from "./ModerateAllOption.css"; + +interface Props extends HTMLAttributes { + href?: string; +} + +/** + * ModerateAllOption is a listbox option that renders a moderate all button. + */ +const ModerateAllOption: FunctionComponent = ({ + className, + href, + ...rest +}) => { + return ( +
  • + +
  • + ); +}; + +export default ModerateAllOption; diff --git a/src/core/client/admin/routes/moderate/components/Search/Option.css b/src/core/client/admin/routes/moderate/components/Search/Option.css new file mode 100644 index 000000000..1c324e746 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/Option.css @@ -0,0 +1,42 @@ +.root { + &:not(:first-child) { + border-top: 1px solid var(--palette-divider); + } + &[aria-selected="true"] .container { + @mixin outline; + } +} +.container { + min-height: calc(4 * var(--spacing-unit)); + padding: var(--spacing-unit) calc(2.5 * var(--spacing-unit)); + box-sizing: border-box; + &:hover { + background: var(--palette-grey-lightest); + } +} + +.title { + font-family: var(--font-family-sans-serif); + font-weight: var(--font-weight-medium); + font-size: calc(16rem / var(--rem-base)); + line-height: calc(16em / 16); + color: var(--palette-text-primary); +} + +.titleWithDetails { + font-size: calc(14rem / var(--rem-base)); +} + +.details { + padding-top: 3px; + font-family: var(--font-family-sans-serif); + font-weight: var(--font-weight-regular); + font-size: calc(14rem / var(--rem-base)); + line-height: calc(14em / 14); + color: var(--palette-grey-dark); +} + +.link { + display: block; + text-decoration: none; +} diff --git a/src/core/client/admin/routes/moderate/components/Search/Option.tsx b/src/core/client/admin/routes/moderate/components/Search/Option.tsx new file mode 100644 index 000000000..34c9354f0 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/Option.tsx @@ -0,0 +1,49 @@ +import cn from "classnames"; +import React, { FunctionComponent, HTMLAttributes } from "react"; + +import styles from "./Option.css"; + +interface Props extends HTMLAttributes { + href?: string; + /** details contains additional information like the author */ + details?: React.ReactNode; + /** children contains e.g. the title of the option */ + children?: React.ReactNode; +} + +/** + * Group represents a generic listbox option + */ +const Option: FunctionComponent = ({ + details, + children, + className, + href, + ...rest +}) => { + const container = ( +
    +
    + {children} +
    +
    {details}
    +
    + ); + + return ( +
  • + {href && ( + + {container} + + )} + {!Boolean(href) && container} +
  • + ); +}; + +export default Option; diff --git a/src/core/client/admin/routes/moderate/components/Search/SeeAllOption.css b/src/core/client/admin/routes/moderate/components/Search/SeeAllOption.css new file mode 100644 index 000000000..4998ad56d --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/SeeAllOption.css @@ -0,0 +1,33 @@ +.root { + &[aria-selected="true"] { + @mixin outline; + } +} + +.link { + display: flex; + justify-content: center; + align-items: center; + + background: var(--palette-primary-dark); + min-height: calc(3 * var(--spacing-unit)); + + font-family: var(--font-family-sans-serif); + font-weight: var(--font-weight-medium); + font-size: calc(13rem / var(--rem-base)); + line-height: calc(16em / 13); + color: var(--palette-text-light); + + text-decoration: none; + text-transform: uppercase; + &:hover { + background: var(--palette-primary-darkest); + } +} + +.icon { + font-weight: var(--font-weight-medium); + padding-left: calc(0.5 * var(--spacing-unit)); + line-height: calc(16em / 13); + margin-top: -2px; +} diff --git a/src/core/client/admin/routes/moderate/components/Search/SeeAllOption.tsx b/src/core/client/admin/routes/moderate/components/Search/SeeAllOption.tsx new file mode 100644 index 000000000..61e6f2d60 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/SeeAllOption.tsx @@ -0,0 +1,33 @@ +import cn from "classnames"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, HTMLAttributes } from "react"; + +import { Icon } from "talk-ui/components"; + +import styles from "./SeeAllOption.css"; + +interface Props extends HTMLAttributes { + href?: string; +} + +/** + * SeeAllOption is a listbox option that renders a see all search results button. + */ +const SeeAllOption: FunctionComponent = ({ + className, + href, + ...rest +}) => { + return ( +
  • + + + See all results + + arrow_forward + +
  • + ); +}; + +export default SeeAllOption; diff --git a/src/core/client/admin/routes/moderate/components/Search/index.ts b/src/core/client/admin/routes/moderate/components/Search/index.ts new file mode 100644 index 000000000..3fe1de6d5 --- /dev/null +++ b/src/core/client/admin/routes/moderate/components/Search/index.ts @@ -0,0 +1,5 @@ +export { default as Bar } from "./Bar"; +export { default as Field } from "./Field"; +export { default as Option } from "./Option"; +export { default as ModerateAllOption } from "./ModerateAllOption"; +export { default as SeeAllOption } from "./SeeAllOption"; diff --git a/src/core/client/admin/routes/moderate/components/__snapshots__/Moderate.spec.tsx.snap b/src/core/client/admin/routes/moderate/components/__snapshots__/Moderate.spec.tsx.snap index 4e0a69c05..9cc65c9df 100644 --- a/src/core/client/admin/routes/moderate/components/__snapshots__/Moderate.spec.tsx.snap +++ b/src/core/client/admin/routes/moderate/components/__snapshots__/Moderate.spec.tsx.snap @@ -4,35 +4,16 @@ exports[`renders correctly 1`] = `
    - - - -
    - -
    - -
    -`; - -exports[`renders correctly with counts 1`] = ` -
    -
    ; rejectComment: MutationProp; danglingLogic: (status: COMMENT_STATUS) => boolean; + match: Match; + router: Router; } function getStatus(comment: CommentData) { @@ -35,13 +38,12 @@ function getStatus(comment: CommentData) { } } -class ModerateCardContainer extends React.Component< - ModerateCardContainerProps -> { +class ModerateCardContainer extends React.Component { private handleAccept = () => { this.props.acceptComment({ commentID: this.props.comment.id, commentRevisionID: this.props.comment.revision.id, + storyID: this.props.match.params.storyID, }); }; @@ -49,6 +51,7 @@ class ModerateCardContainer extends React.Component< this.props.rejectComment({ commentID: this.props.comment.id, commentRevisionID: this.props.comment.revision.id, + storyID: this.props.match.params.storyID, }); }; @@ -74,7 +77,7 @@ class ModerateCardContainer extends React.Component< } } -const enhanced = withFragmentContainer({ +const enhanced = withFragmentContainer({ comment: graphql` fragment ModerateCardContainer_comment on Comment { id @@ -105,8 +108,10 @@ const enhanced = withFragmentContainer({ } `, })( - withMutation(AcceptCommentMutation)( - withMutation(RejectCommentMutation)(ModerateCardContainer) + withRouter( + withMutation(AcceptCommentMutation)( + withMutation(RejectCommentMutation)(ModerateCardContainer) + ) ) ); diff --git a/src/core/client/admin/routes/moderate/containers/ModerateContainer.tsx b/src/core/client/admin/routes/moderate/containers/ModerateContainer.tsx index 9e1af842c..b56eb48cc 100644 --- a/src/core/client/admin/routes/moderate/containers/ModerateContainer.tsx +++ b/src/core/client/admin/routes/moderate/containers/ModerateContainer.tsx @@ -1,32 +1,40 @@ -import { RouteProps } from "found"; +import { Match, RouteProps, Router, withRouter } from "found"; import React from "react"; import { graphql } from "react-relay"; import { ModerateContainerQueryResponse } from "talk-admin/__generated__/ModerateContainerQuery.graphql"; import { withRouteConfig } from "talk-framework/lib/router"; +import { Spinner } from "talk-ui/components"; import Moderate from "../components/Moderate"; +interface RouteParams { + storyID?: string; +} + interface Props { data: ModerateContainerQueryResponse; + router: Router; + match: Match & { params: RouteParams }; } class ModerateContainer extends React.Component { public static routeConfig: RouteProps; public render() { + const allStories = !this.props.match.params.storyID; if (!this.props.data) { - return null; - } - - if (!this.props.data.moderationQueues) { - return ; + return ( + + + + ); } return ( {this.props.children} @@ -36,21 +44,23 @@ class ModerateContainer extends React.Component { const enhanced = withRouteConfig({ query: graphql` - query ModerateContainerQuery { - moderationQueues { - unmoderated { - count - } - reported { - count - } - pending { - count - } + query ModerateContainerQuery($storyID: ID, $includeStory: Boolean!) { + story(id: $storyID) @include(if: $includeStory) { + ...ModerateNavigationContainer_story + ...ModerateSearchBarContainer_story + } + moderationQueues(storyID: $storyID) { + ...ModerateNavigationContainer_moderationQueues } } `, cacheConfig: { force: true }, -})(ModerateContainer); + prepareVariables: (params, match) => { + return { + storyID: match.params.storyID, + includeStory: Boolean(match.params.storyID), + }; + }, +})(withRouter(ModerateContainer)); export default enhanced; diff --git a/src/core/client/admin/routes/moderate/containers/ModerateNavigationContainer.tsx b/src/core/client/admin/routes/moderate/containers/ModerateNavigationContainer.tsx new file mode 100644 index 000000000..6d6349d17 --- /dev/null +++ b/src/core/client/admin/routes/moderate/containers/ModerateNavigationContainer.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { graphql } from "react-relay"; + +import { ModerateNavigationContainer_moderationQueues as ModerationQueuesData } from "talk-admin/__generated__/ModerateNavigationContainer_moderationQueues.graphql"; +import { ModerateNavigationContainer_story as StoryData } from "talk-admin/__generated__/ModerateNavigationContainer_story.graphql"; +import { withFragmentContainer } from "talk-framework/lib/relay"; + +import Navigation from "../components/Navigation"; + +interface Props { + moderationQueues: ModerationQueuesData | null; + story: StoryData | null; +} + +const ModerateNavigationContainer: React.FunctionComponent = props => { + if (!props.moderationQueues) { + return ; + } + return ( + + ); +}; + +const enhanced = withFragmentContainer({ + story: graphql` + fragment ModerateNavigationContainer_story on Story { + id + } + `, + moderationQueues: graphql` + fragment ModerateNavigationContainer_moderationQueues on ModerationQueues { + unmoderated { + count + } + reported { + count + } + pending { + count + } + } + `, +})(ModerateNavigationContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/moderate/containers/ModerateSearchBarContainer.tsx b/src/core/client/admin/routes/moderate/containers/ModerateSearchBarContainer.tsx new file mode 100644 index 000000000..c1f4e8e44 --- /dev/null +++ b/src/core/client/admin/routes/moderate/containers/ModerateSearchBarContainer.tsx @@ -0,0 +1,264 @@ +import { Localized } from "fluent-react/compat"; +import { Match, Router, withRouter } from "found"; +import React, { + KeyboardEvent, + MouseEvent, + useCallback, + useRef, + useState, +} from "react"; +import { graphql } from "react-relay"; + +import { ModerateSearchBarContainer_story as ModerationQueuesData } from "talk-admin/__generated__/ModerateSearchBarContainer_story.graphql"; +import { SearchStoryFetch } from "talk-admin/fetches"; +import { useEffectWhenChanged } from "talk-framework/hooks"; +import { useFetch, withFragmentContainer } from "talk-framework/lib/relay"; +import { PropTypesOf } from "talk-framework/types"; +import { Spinner } from "talk-ui/components"; +import { blur } from "talk-ui/helpers"; +import { + ListBoxOptionClickOrEnterHandler, + ListBoxOptionElement, +} from "talk-ui/hooks/useComboBox"; + +import * as Search from "../components/Search"; +import GoToAriaInfo from "../components/Search/GoToAriaInfo"; + +interface Props { + router: Router; + match: Match; + story: ModerationQueuesData | null; + allStories: boolean; +} + +type SearchBarOptions = PropTypesOf["options"]; + +/** + * useLinkNavHandler returns a handler that navigates to `href` prop and blurs + * the TextField. + * @param router Router from the _found_ library + * @returns A handler for ListBoxOption + */ +function useLinkNavHandler(router: Router): ListBoxOptionClickOrEnterHandler { + return useCallback( + (evt: MouseEvent | KeyboardEvent, element: ListBoxOptionElement) => { + if (element.props.href) { + router.push(element.props.href); + if (evt.preventDefault) { + // We prevent default behavior because we handled navigation ourselves + // and the browser don't need to follow anchor hrefs natively. + evt.preventDefault(); + } + // Blur will inactivate the textfield and close the popover/listbox. + blur(); + } + }, + [router] + ); +} + +function getContextOptionsWhenModeratingAll( + onClickOrEnter: ListBoxOptionClickOrEnterHandler +): SearchBarOptions { + return [ + { + element: ( + + + + All stories + + + ), + onClickOrEnter, + group: "CONTEXT", + }, + ]; +} + +function getContextOptionsWhenModeratingStory( + onClickOrEnter: ListBoxOptionClickOrEnterHandler, + story: ModerationQueuesData | null +): SearchBarOptions { + if (story === null) { + return []; + } + return [ + { + element: ( + + {story.metadata && story.metadata.title} + + ), + onClickOrEnter, + group: "CONTEXT", + }, + { + element: , + onClickOrEnter, + group: "CONTEXT", + }, + ]; +} + +type OnSearchCallback = (search: string) => void; + +/** + * useSearchOptions + * @param onClickOrEnter A handler that reacts to click or enter for the search options + * @param story Current active story + */ +function useSearchOptions( + onClickOrEnter: ListBoxOptionClickOrEnterHandler, + story: ModerationQueuesData | null +): [SearchBarOptions, OnSearchCallback] { + const searchStory = useFetch(SearchStoryFetch); + + const [searchOptions, setSearchOptions] = useState([]); + + useEffectWhenChanged(() => { + setSearchOptions([]); + }, [story]); + + const searchCountRef = useRef(0); + + const onSearch = useCallback( + async (search: string) => { + const nextSearchOptions: SearchBarOptions = []; + + const searchCount = ++searchCountRef.current; + + setSearchOptions([ + { + element: ( + + + + ), + group: "SEARCH", + }, + ]); + + const stories = await searchStory({ query: search, limit: 5 }); + if (searchCount !== searchCountRef.current) { + // This result is old, so we can discard it. + return; + } + + if (stories.edges.length > 0) { + stories.edges.forEach(e => { + // Don't show current story in search results. + if (story && story.id === e.node.id) { + return; + } + nextSearchOptions.push({ + element: ( + + {e.node.metadata && e.node.metadata.title} + + ), + onClickOrEnter, + group: "SEARCH", + }); + }); + } else { + nextSearchOptions.push({ + element: ( + + + No results + + + ), + group: "SEARCH", + }); + } + if (stories.pageInfo.hasNextPage) { + nextSearchOptions.push({ + element: ( + + ), + onClickOrEnter, + group: "SEARCH", + }); + } + setSearchOptions(nextSearchOptions); + }, + [story, searchStory, setSearchOptions] + ); + + return [searchOptions, onSearch]; +} + +const ModerateSearchBarContainer: React.FunctionComponent = props => { + const linkNavHandler = useLinkNavHandler(props.router); + const contextOptions: PropTypesOf< + typeof Search.Bar + >["options"] = props.allStories + ? getContextOptionsWhenModeratingAll(linkNavHandler) + : getContextOptionsWhenModeratingStory(linkNavHandler, props.story); + + const [searchOptions, onSearch] = useSearchOptions( + linkNavHandler, + props.story + ); + + const options = [...contextOptions, ...searchOptions]; + + const childProps = { + options, + onSearch, + }; + + // Still loading the story.. + if (props.allStories) { + return ( + + + + ); + } + if (!props.story) { + return ; + } + const t = props.story!.metadata && props.story!.metadata.title; + if (t) { + return ; + } + return ( + + + + ); +}; + +const enhanced = withRouter( + withFragmentContainer({ + story: graphql` + fragment ModerateSearchBarContainer_story on Story { + id + metadata { + title + author + } + } + `, + })(ModerateSearchBarContainer) +); + +export default enhanced; diff --git a/src/core/client/admin/routes/moderate/containers/QueueContainer.tsx b/src/core/client/admin/routes/moderate/containers/QueueContainer.tsx index fd4c8575f..524684e5f 100644 --- a/src/core/client/admin/routes/moderate/containers/QueueContainer.tsx +++ b/src/core/client/admin/routes/moderate/containers/QueueContainer.tsx @@ -113,6 +113,7 @@ const createQueueContainer = ( }, getVariables(props, { count, cursor }, fragmentVariables) { return { + ...fragmentVariables, count, cursor, }; @@ -141,8 +142,8 @@ const createQueueContainer = ( export const PendingQueueContainer = createQueueContainer( graphql` - query QueueContainerPendingQuery { - moderationQueues { + query QueueContainerPendingQuery($storyID: ID) { + moderationQueues(storyID: $storyID) { pending { ...QueueContainer_queue } @@ -155,8 +156,12 @@ export const PendingQueueContainer = createQueueContainer( 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 QueueContainerPaginationPendingQuery($count: Int!, $cursor: Cursor) { - moderationQueues { + query QueueContainerPaginationPendingQuery( + $storyID: ID + $count: Int! + $cursor: Cursor + ) { + moderationQueues(storyID: $storyID) { pending { ...QueueContainer_queue @arguments(count: $count, cursor: $cursor) } @@ -167,8 +172,8 @@ export const PendingQueueContainer = createQueueContainer( export const ReportedQueueContainer = createQueueContainer( graphql` - query QueueContainerReportedQuery { - moderationQueues { + query QueueContainerReportedQuery($storyID: ID) { + moderationQueues(storyID: $storyID) { reported { ...QueueContainer_queue } @@ -181,8 +186,12 @@ export const ReportedQueueContainer = createQueueContainer( 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 QueueContainerPaginationReportedQuery($count: Int!, $cursor: Cursor) { - moderationQueues { + query QueueContainerPaginationReportedQuery( + $storyID: ID + $count: Int! + $cursor: Cursor + ) { + moderationQueues(storyID: $storyID) { reported { ...QueueContainer_queue @arguments(count: $count, cursor: $cursor) } @@ -193,8 +202,8 @@ export const ReportedQueueContainer = createQueueContainer( export const UnmoderatedQueueContainer = createQueueContainer( graphql` - query QueueContainerUnmoderatedQuery { - moderationQueues { + query QueueContainerUnmoderatedQuery($storyID: ID) { + moderationQueues(storyID: $storyID) { unmoderated { ...QueueContainer_queue } @@ -208,10 +217,11 @@ export const UnmoderatedQueueContainer = createQueueContainer( # 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 QueueContainerPaginationUnmoderatedQuery( + $storyID: ID $count: Int! $cursor: Cursor ) { - moderationQueues { + moderationQueues(storyID: $storyID) { unmoderated { ...QueueContainer_queue @arguments(count: $count, cursor: $cursor) } diff --git a/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx b/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx index 704774023..af46ecc3a 100644 --- a/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx +++ b/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx @@ -75,9 +75,14 @@ const enhanced = (withPaginationContainer< @argumentDefinitions( count: { type: "Int!", defaultValue: 5 } cursor: { type: "Cursor" } + storyID: { type: "ID" } ) { - comments(status: REJECTED, first: $count, after: $cursor) - @connection(key: "RejectedQueue_comments") { + comments( + status: REJECTED + storyID: $storyID + first: $count + after: $cursor + ) @connection(key: "RejectedQueue_comments") { edges { node { id @@ -105,6 +110,7 @@ const enhanced = (withPaginationContainer< }, getVariables(props, { count, cursor }, fragmentVariables) { return { + ...fragmentVariables, count, cursor, }; @@ -113,11 +119,12 @@ const enhanced = (withPaginationContainer< # 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 RejectedQueueContainerPaginationQuery( + $storyID: ID $count: Int! $cursor: Cursor ) { ...RejectedQueueContainer_query - @arguments(count: $count, cursor: $cursor) + @arguments(storyID: $storyID, count: $count, cursor: $cursor) } `, } @@ -126,8 +133,8 @@ const enhanced = (withPaginationContainer< enhanced.routeConfig = { Component: enhanced, query: graphql` - query RejectedQueueContainerQuery { - ...RejectedQueueContainer_query + query RejectedQueueContainerQuery($storyID: ID) { + ...RejectedQueueContainer_query @arguments(storyID: $storyID) } `, cacheConfig: { force: true }, diff --git a/src/core/client/admin/routes/stories/components/Stories.tsx b/src/core/client/admin/routes/stories/components/Stories.tsx index 19533cc05..2fdd76fc9 100644 --- a/src/core/client/admin/routes/stories/components/Stories.tsx +++ b/src/core/client/admin/routes/stories/components/Stories.tsx @@ -9,11 +9,15 @@ import styles from "./Stories.css"; interface Props { query: PropTypesOf["query"]; + initialSearchFilter?: string; } const Stories: StatelessComponent = props => ( - + ); diff --git a/src/core/client/admin/routes/stories/components/StoryRow.tsx b/src/core/client/admin/routes/stories/components/StoryRow.tsx index d2d208b92..49f1843a9 100644 --- a/src/core/client/admin/routes/stories/components/StoryRow.tsx +++ b/src/core/client/admin/routes/stories/components/StoryRow.tsx @@ -1,8 +1,10 @@ +import { Link } from "found"; import React, { StatelessComponent } from "react"; import NotAvailable from "talk-admin/components/NotAvailable"; +import { getModerationLink } from "talk-admin/helpers"; import { PropTypesOf } from "talk-framework/types"; -import { TableCell, TableRow } from "talk-ui/components"; +import { TableCell, TableRow, TextLink } from "talk-ui/components"; import StatusChangeContainer from "../containers/StatusChangeContainer"; import StatusText from "./StatusText"; @@ -21,7 +23,12 @@ interface Props { const UserRow: StatelessComponent = props => ( - {props.title || } + + {props.title || } + {props.author || } diff --git a/src/core/client/admin/routes/stories/components/StoryTable.css b/src/core/client/admin/routes/stories/components/StoryTable.css index f4abdebf3..064fbed8b 100644 --- a/src/core/client/admin/routes/stories/components/StoryTable.css +++ b/src/core/client/admin/routes/stories/components/StoryTable.css @@ -10,3 +10,6 @@ .statusColumn { width: 15%; } +.clickToModerate { + font-size: calc(12rem / var(--rem-base)); +} diff --git a/src/core/client/admin/routes/stories/components/StoryTable.tsx b/src/core/client/admin/routes/stories/components/StoryTable.tsx index 88a995249..97ab3609c 100644 --- a/src/core/client/admin/routes/stories/components/StoryTable.tsx +++ b/src/core/client/admin/routes/stories/components/StoryTable.tsx @@ -38,9 +38,19 @@ const StoryTable: StatelessComponent = props => ( - - Title - + + + Title + {" "} + + ( + + Click title to moderate story + + ) + + + Author diff --git a/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx b/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx index 7506f2a7a..7717e5dda 100644 --- a/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx +++ b/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx @@ -36,6 +36,7 @@ const StoryTableFilter: StatelessComponent = props => (
    props.onSetSearchFilter(search) } diff --git a/src/core/client/admin/routes/stories/containers/StoriesContainer.tsx b/src/core/client/admin/routes/stories/containers/StoriesContainer.tsx index 275ba02f5..0c892a83f 100644 --- a/src/core/client/admin/routes/stories/containers/StoriesContainer.tsx +++ b/src/core/client/admin/routes/stories/containers/StoriesContainer.tsx @@ -10,19 +10,33 @@ import Stories from "../components/Stories"; interface Props { data: StoriesContainerQueryResponse | null; form: FormApi; + initialSearchFilter?: string; } const StoriesContainer: StatelessComponent = props => { - return ; + return ( + + ); }; const enhanced = withRouteConfig({ query: graphql` - query StoriesContainerQuery { - ...StoryTableContainer_query + query StoriesContainerQuery($searchFilter: String) { + ...StoryTableContainer_query @arguments(searchFilter: $searchFilter) } `, cacheConfig: { force: true }, + prepareVariables: (params, match) => { + return { + searchFilter: match.location.query.q, + }; + }, + render: ({ match, Component, ...rest }) => ( + + ), })(StoriesContainer); 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 index 890bc194c..55d84a2be 100644 --- a/src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx +++ b/src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx @@ -16,6 +16,7 @@ import StoryTable from "../components/StoryTable"; import StoryTableFilter from "../components/StoryTableFilter"; interface Props { + initialSearchFilter?: string; query: QueryData | null; relay: RelayPaginationProp; } @@ -26,7 +27,9 @@ const StoryTableContainer: StatelessComponent = props => { : []; const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); - const [searchFilter, setSearchFilter] = useState(""); + const [searchFilter, setSearchFilter] = useState( + props.initialSearchFilter || "" + ); const [statusFilter, setStatusFilter] = useState( null ); diff --git a/src/core/client/admin/test/moderate/moderate.spec.tsx b/src/core/client/admin/test/moderate/moderate.spec.tsx index e922b3c4b..5fb3ed5fd 100644 --- a/src/core/client/admin/test/moderate/moderate.spec.tsx +++ b/src/core/client/admin/test/moderate/moderate.spec.tsx @@ -45,8 +45,18 @@ async function createTestRenderer( Query: { settings: () => settings, viewer: () => viewer, - moderationQueues: () => emptyModerationQueues, - comments: () => emptyRejectedComments, + moderationQueues: ({ variables }) => { + expectAndFail(variables).toEqual({ + storyID: null, + }); + return emptyModerationQueues; + }, + comments: ({ variables }) => { + expectAndFail(variables).toEqual({ + storyID: null, + }); + return emptyRejectedComments; + }, }, }), params.resolvers @@ -382,6 +392,7 @@ describe("rejected queue", () => { expectAndFail(variables).toEqual({ first: 5, status: "REJECTED", + storyID: null, }); return { edges: [ @@ -418,6 +429,7 @@ describe("rejected queue", () => { expectAndFail(variables).toEqual({ first: 5, status: GQLCOMMENT_STATUS.REJECTED, + storyID: null, }); return { edges: [ @@ -440,6 +452,7 @@ describe("rejected queue", () => { first: 10, after: rejectedComments[1].createdAt, status: GQLCOMMENT_STATUS.REJECTED, + storyID: null, }); return { edges: [ @@ -519,6 +532,7 @@ describe("rejected queue", () => { expectAndFail(variables).toEqual({ first: 5, status: "REJECTED", + storyID: null, }); return { edges: [ 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 index caaab2f07..d439b23f3 100644 --- a/src/core/client/admin/test/stories/__snapshots__/stories.spec.tsx.snap +++ b/src/core/client/admin/test/stories/__snapshots__/stories.spec.tsx.snap @@ -124,7 +124,19 @@ exports[`renders empty stories 1`] = `
    - Title + + Title + + + + ( + + Click title to moderate story + + ) + - Finally a Cure for Cancer + + Finally a Cure for Cancer + - First Colony on Mars + + First Colony on Mars + - Title + + Title + + + + ( + + Click title to moderate story + + ) + - Finally a Cure for Cancer + + Finally a Cure for Cancer + - First Colony on Mars + + First Colony on Mars + - Talk 5.0 – Embed Stream + Talk 5.0 – Embed Stream – Story diff --git a/src/core/client/embed/storyButton.html b/src/core/client/embed/storyButton.html index 83bb011e2..5d8e42e34 100644 --- a/src/core/client/embed/storyButton.html +++ b/src/core/client/embed/storyButton.html @@ -1,7 +1,7 @@ - Talk 5.0 – Embed Stream + Talk 5.0 – Embed Stream – Story with Button diff --git a/src/core/client/framework/hooks/index.ts b/src/core/client/framework/hooks/index.ts index 4a772d9df..d99853f1d 100644 --- a/src/core/client/framework/hooks/index.ts +++ b/src/core/client/framework/hooks/index.ts @@ -1 +1,3 @@ export { default as useEffectAfterMount } from "./useEffectAfterMount"; +export { default as usePrevious } from "./usePrevious"; +export { default as useEffectWhenChanged } from "./useEffectWhenChanged"; diff --git a/src/core/client/framework/hooks/useEffectWhenChanged.ts b/src/core/client/framework/hooks/useEffectWhenChanged.ts new file mode 100644 index 000000000..451c0ca27 --- /dev/null +++ b/src/core/client/framework/hooks/useEffectWhenChanged.ts @@ -0,0 +1,21 @@ +import equals from "shallow-equals"; + +import useEffectAfterMount from "./useEffectAfterMount"; +import usePrevious from "./usePrevious"; + +/** + * useEffectWhenChanged is a react hook that will run effects + * when value changed. + */ +export default function useEffectWhenChanged( + callback: () => void, + deps: ReadonlyArray +) { + const previous = usePrevious(deps); + // We use `useEffectAfterMount` to make sure `previous` has an assigned value. + useEffectAfterMount(() => { + if (!equals(deps, previous)) { + callback(); + } + }, deps); +} diff --git a/src/core/client/framework/hooks/usePrevious.ts b/src/core/client/framework/hooks/usePrevious.ts new file mode 100644 index 000000000..7217d08f4 --- /dev/null +++ b/src/core/client/framework/hooks/usePrevious.ts @@ -0,0 +1,13 @@ +import { useEffect, useRef } from "react"; + +/** + * usePrevious is a react hook that will return the + * previous value. + */ +export default function usePrevious(value: T): T { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current as T; +} diff --git a/src/core/client/framework/lib/relay/fetch.tsx b/src/core/client/framework/lib/relay/fetch.tsx new file mode 100644 index 000000000..474a78973 --- /dev/null +++ b/src/core/client/framework/lib/relay/fetch.tsx @@ -0,0 +1,117 @@ +import React, { useCallback } from "react"; +import { fetchQuery as relayFetchQuery } from "react-relay"; +import { + compose, + hoistStatics, + InferableComponentEnhancer, + wrapDisplayName, +} from "recompose"; +import { + CacheConfig, + Environment, + GraphQLTaggedNode, + Variables, +} from "relay-runtime"; + +import { TalkContext, useTalkContext, withContext } from "../bootstrap"; +import extractPayload from "./extractPayload"; + +export interface Fetch { + name: N; + fetch: (environment: Environment, variables: V, context: TalkContext) => R; +} + +export type FetchVariables = T["variables"]; +export type FetchProp> = T extends Fetch< + any, + infer V, + infer R +> + ? Parameters[1] extends undefined + ? () => R + : keyof Parameters[1] extends never + ? () => R + : (variables: V) => R + : never; + +export function createFetch( + name: N, + fetch: (environment: Environment, variables: V, context: TalkContext) => R +): Fetch { + return { + name, + fetch, + } as any; +} + +export async function fetchQuery( + environment: Environment, + taggedNode: GraphQLTaggedNode, + variables: Variables, + cacheConfig?: CacheConfig +): Promise { + const result = await relayFetchQuery( + environment, + taggedNode, + variables, + cacheConfig + ); + return extractPayload(result); +} + +/** + * useFetch is a React Hook that + * returns a callback to call the fetch. + */ +export function useFetch( + fetch: Fetch +): FetchProp { + const context = useTalkContext(); + return useCallback>( + ((variables: V) => { + context.eventEmitter.emit(`fetch.${fetch.name}`, variables); + return fetch.fetch(context.relayEnvironment, variables, context); + }) as any, + [context] + ); +} + +/** + * withFetch creates a HOC that injects the fetch as + * a property. + */ +export function withFetch( + fetch: Fetch +): InferableComponentEnhancer<{ [P in N]: FetchProp }> { + return compose( + withContext(context => ({ context })), + hoistStatics((BaseComponent: React.ComponentType) => { + class WithFetch extends React.Component<{ + context: TalkContext; + }> { + public static displayName = wrapDisplayName(BaseComponent, "withFetch"); + + private fetch = (variables: V) => { + this.props.context.eventEmitter.emit( + `fetch.${fetch.name}`, + variables + ); + return fetch.fetch( + this.props.context.relayEnvironment, + variables, + this.props.context + ); + }; + + public render() { + const { context: _, ...rest } = this.props; + const inject = { + [fetch.name]: this.fetch, + }; + return ; + } + } + return WithFetch as React.ComponentType; + }) + ); +} diff --git a/src/core/client/framework/lib/relay/fetchQuery.ts b/src/core/client/framework/lib/relay/fetchQuery.ts deleted file mode 100644 index c94e826a0..000000000 --- a/src/core/client/framework/lib/relay/fetchQuery.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { fetchQuery as relayFetchQuery } from "react-relay"; -import { - CacheConfig, - Environment, - GraphQLTaggedNode, - Variables, -} from "relay-runtime"; - -import extractPayload from "./extractPayload"; - -export default async function fetchQuery( - environment: Environment, - taggedNode: GraphQLTaggedNode, - variables: Variables, - cacheConfig?: CacheConfig -): Promise { - const result = await relayFetchQuery( - environment, - taggedNode, - variables, - cacheConfig - ); - return extractPayload(result); -} diff --git a/src/core/client/framework/lib/relay/index.ts b/src/core/client/framework/lib/relay/index.ts index 857f6c85b..980b3d439 100644 --- a/src/core/client/framework/lib/relay/index.ts +++ b/src/core/client/framework/lib/relay/index.ts @@ -27,7 +27,14 @@ export { default as commitLocalUpdatePromisified, } from "./commitLocalUpdatePromisified"; export { initLocalBaseState, setAccessTokenInLocalState } from "./localState"; -export { default as fetchQuery } from "./fetchQuery"; +export { + fetchQuery, + createFetch, + FetchVariables, + FetchProp, + withFetch, + useFetch, +} from "./fetch"; export { default as useRefetch } from "./useRefetch"; export { default as useLoadMore } from "./useLoadMore"; export { default as lookup } from "./lookup"; diff --git a/src/core/client/framework/lib/relay/useRefetch.ts b/src/core/client/framework/lib/relay/useRefetch.ts index 640e29bc7..18b3a5b0c 100644 --- a/src/core/client/framework/lib/relay/useRefetch.ts +++ b/src/core/client/framework/lib/relay/useRefetch.ts @@ -2,7 +2,7 @@ import { useState } from "react"; import { RelayPaginationProp } from "react-relay"; import { Variables } from "relay-runtime"; -import { useEffectAfterMount } from "talk-framework/hooks"; +import { useEffectWhenChanged } from "talk-framework/hooks"; /** * useRefetch is a react hook that returns a `refetch` callback @@ -15,7 +15,7 @@ export default function useRefetch( ): [() => void, boolean] { const [manualRefetchCount, setManualRefetchCount] = useState(0); const [refetching, setRefetching] = useState(false); - useEffectAfterMount(() => { + useEffectWhenChanged(() => { setRefetching(true); const disposable = relay.refetchConnection( 10, @@ -34,7 +34,7 @@ export default function useRefetch( } }; }, [ - relay, + relay.environment, manualRefetchCount, ...Object.keys(variables).reduce((a, k) => { a.push((variables as any)[k]); diff --git a/src/core/client/framework/lib/router/withRouteConfig.ts b/src/core/client/framework/lib/router/withRouteConfig.ts index 7fc158b30..91c559083 100644 --- a/src/core/client/framework/lib/router/withRouteConfig.ts +++ b/src/core/client/framework/lib/router/withRouteConfig.ts @@ -1,4 +1,4 @@ -import { RouteProps } from "found"; +import { RouteMatch, RouteProps } from "found"; import * as React from "react"; interface InjectedProps { @@ -7,25 +7,48 @@ interface InjectedProps { retry?: Error | null; } -type RouteConfig = Partial> & +type RouteConfig = Partial< + Pick +> & Partial> & { cacheConfig?: { force?: boolean; }; + prepareVariables?: ( + params: Record, + match: RouteMatch + ) => Record; + render?: (args: { + error: Error; + data: QueryResponse | null; + retry: () => void; + match: RouteMatch; + Component: React.ComponentType; + }) => React.ReactElement; }; -function withRouteConfig(config: RouteConfig) { +function withRouteConfig(config: RouteConfig) { const hoc = >( component: React.ComponentType ) => { (component as any).routeConfig = { ...config, Component: component, - render: ({ error, props, retry, Component }: any) => { - return React.createElement(Component, { error, data: props, retry }); + render: ({ error, props: data, retry, match, Component }: any) => { + if (config.render) { + return config.render({ error, data, retry, match, Component }); + } + return React.createElement(Component, { + error, + data, + retry, + match, + }); }, }; - return component as React.ComponentClass & { routeConfig: RouteConfig }; + return component as React.ComponentClass & { + routeConfig: RouteConfig; + }; }; return hoc; } diff --git a/src/core/client/stream/fetches/RefreshSettingsQuery.ts b/src/core/client/stream/fetches/RefreshSettingsQuery.ts index 708fbaaee..2be18369a 100644 --- a/src/core/client/stream/fetches/RefreshSettingsQuery.ts +++ b/src/core/client/stream/fetches/RefreshSettingsQuery.ts @@ -1,39 +1,35 @@ import { graphql } from "react-relay"; import { Environment } from "relay-runtime"; -import { createFetchContainer, fetchQuery } from "talk-framework/lib/relay"; +import { + createFetch, + fetchQuery, + FetchVariables, +} from "talk-framework/lib/relay"; import { RefreshSettingsQuery as QueryTypes } from "talk-stream/__generated__/RefreshSettingsQuery.graphql"; -export type RefreshSettingsVariables = QueryTypes["variables"]; - -const query = graphql` - query RefreshSettingsQuery($storyID: ID!) { - settings { - ...StreamContainer_settings - } - # We also refrech story props that are - # dependent on the settings. - story(id: $storyID) { - closedAt - isClosed - } - } -`; - -function fetch(environment: Environment, variables: RefreshSettingsVariables) { - return fetchQuery( - environment, - query, - variables, - { force: true } - ); -} - -export const withRefreshSettingsFetch = createFetchContainer( +const RefreshSettingsFetch = createFetch( "refreshSettings", - fetch + (environment: Environment, variables: FetchVariables) => { + return fetchQuery( + environment, + graphql` + query RefreshSettingsQuery($storyID: ID!) { + settings { + ...StreamContainer_settings + } + # We also refrech story props that are + # dependent on the settings. + story(id: $storyID) { + closedAt + isClosed + } + } + `, + variables, + { force: true } + ); + } ); -export type RefreshSettingsFetch = ( - variables: RefreshSettingsVariables -) => Promise; +export default RefreshSettingsFetch; diff --git a/src/core/client/stream/fetches/index.ts b/src/core/client/stream/fetches/index.ts index dfa5308c4..3520de2a8 100644 --- a/src/core/client/stream/fetches/index.ts +++ b/src/core/client/stream/fetches/index.ts @@ -1,4 +1 @@ -export { - withRefreshSettingsFetch, - RefreshSettingsFetch, -} from "./RefreshSettingsQuery"; +export { default as RefreshSettingsFetch } from "./RefreshSettingsQuery"; diff --git a/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap index f4a073b52..aa6f149a1 100644 --- a/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap @@ -24,7 +24,7 @@ exports[`renders correctly 1`] = ` } } /> - - - - void; autofocus: boolean; - refreshSettings: RefreshSettingsFetch; + refreshSettings: FetchProp; } interface State { @@ -156,7 +157,7 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({ // Disable autofocus on ios and enable for the rest. autofocus: !browserInfo.ios, }))( - withRefreshSettingsFetch( + withFetch(RefreshSettingsFetch)( withEditCommentMutation( withFragmentContainer({ comment: graphql` diff --git a/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx b/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx index e8e810d93..0fc4c44c0 100644 --- a/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx @@ -7,7 +7,9 @@ import { ModerationNudgeError, } from "talk-framework/lib/errors"; import { + FetchProp, graphql, + withFetch, withFragmentContainer, withLocalStateContainer, } from "talk-framework/lib/relay"; @@ -16,10 +18,7 @@ import { PropTypesOf } from "talk-framework/types"; import { PostCommentFormContainer_settings as SettingsData } from "talk-stream/__generated__/PostCommentFormContainer_settings.graphql"; import { PostCommentFormContainer_story as StoryData } from "talk-stream/__generated__/PostCommentFormContainer_story.graphql"; import { PostCommentFormContainerLocal as Local } from "talk-stream/__generated__/PostCommentFormContainerLocal.graphql"; -import { - RefreshSettingsFetch, - withRefreshSettingsFetch, -} from "talk-stream/fetches"; +import { RefreshSettingsFetch } from "talk-stream/fetches"; import { CreateCommentMutation, withCreateCommentMutation, @@ -37,7 +36,7 @@ import { interface Props { createComment: CreateCommentMutation; - refreshSettings: RefreshSettingsFetch; + refreshSettings: FetchProp; sessionStorage: PromisifiedStorage; settings: SettingsData; local: Local; @@ -214,7 +213,7 @@ const enhanced = withContext(({ sessionStorage }) => ({ sessionStorage, }))( withCreateCommentMutation( - withRefreshSettingsFetch( + withFetch(RefreshSettingsFetch)( withLocalStateContainer( graphql` fragment PostCommentFormContainerLocal on Local { diff --git a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx index 9f4ea8bb1..9455030e5 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx @@ -8,16 +8,17 @@ import { InvalidRequestError, ModerationNudgeError, } from "talk-framework/lib/errors"; -import { withFragmentContainer } from "talk-framework/lib/relay"; +import { + FetchProp, + withFetch, + withFragmentContainer, +} from "talk-framework/lib/relay"; import { PromisifiedStorage } from "talk-framework/lib/storage"; import { PropTypesOf } from "talk-framework/types"; import { ReplyCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/ReplyCommentFormContainer_comment.graphql"; import { ReplyCommentFormContainer_settings as SettingsData } from "talk-stream/__generated__/ReplyCommentFormContainer_settings.graphql"; import { ReplyCommentFormContainer_story as StoryData } from "talk-stream/__generated__/ReplyCommentFormContainer_story.graphql"; -import { - RefreshSettingsFetch, - withRefreshSettingsFetch, -} from "talk-stream/fetches"; +import { RefreshSettingsFetch } from "talk-stream/fetches"; import { CreateCommentReplyMutation, withCreateCommentReplyMutation, @@ -42,7 +43,7 @@ interface Props { onClose?: () => void; autofocus: boolean; localReply?: boolean; - refreshSettings: RefreshSettingsFetch; + refreshSettings: FetchProp; } interface State { @@ -204,7 +205,7 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({ // Disable autofocus on ios and enable for the rest. autofocus: !browserInfo.ios, }))( - withRefreshSettingsFetch( + withFetch(RefreshSettingsFetch)( withCreateCommentReplyMutation( withFragmentContainer({ settings: graphql` diff --git a/src/core/client/ui/components/BaseButton/BaseButton.css b/src/core/client/ui/components/BaseButton/BaseButton.css index 14b111258..122ff7556 100644 --- a/src/core/client/ui/components/BaseButton/BaseButton.css +++ b/src/core/client/ui/components/BaseButton/BaseButton.css @@ -3,10 +3,7 @@ } .keyboardFocus { - outline-width: 3px; - outline-color: Highlight; - outline-color: -webkit-focus-ring-color; - outline-style: auto; + @mixin outline; } .mouseHover { diff --git a/src/core/client/ui/components/CheckBox/CheckBox.css b/src/core/client/ui/components/CheckBox/CheckBox.css index 37fca7f97..e3e0e86f3 100644 --- a/src/core/client/ui/components/CheckBox/CheckBox.css +++ b/src/core/client/ui/components/CheckBox/CheckBox.css @@ -40,10 +40,7 @@ /* Box focus */ .label.focus:before { - outline-width: 3px; - outline-color: Highlight; - outline-color: -webkit-focus-ring-color; - outline-style: auto; + @mixin outline; } /* Box checked */ diff --git a/src/core/client/ui/components/Popover/Popover.tsx b/src/core/client/ui/components/Popover/Popover.tsx index 373b62eba..b34fb7694 100644 --- a/src/core/client/ui/components/Popover/Popover.tsx +++ b/src/core/client/ui/components/Popover/Popover.tsx @@ -7,6 +7,7 @@ import { withStyles } from "talk-ui/hocs"; import AriaInfo from "../AriaInfo"; +import { PropTypesOf } from "talk-ui/types"; import styles from "./Popover.css"; type Placement = @@ -40,15 +41,19 @@ interface ChildrenRenderProps { interface PopoverProps { body: (props: BodyRenderProps) => React.ReactNode | React.ReactElement; children: (props: ChildrenRenderProps) => React.ReactNode; - description: string; + description?: string; id: string; className?: string; placement?: Placement; + visible?: boolean; classes: typeof styles; + modifiers?: PropTypesOf["modifiers"]; + eventsEnabled?: PropTypesOf["eventsEnabled"]; + positionFixed?: PropTypesOf["positionFixed"]; } interface State { - visible: false; + visible: boolean; } class Popover extends React.Component { @@ -109,10 +114,15 @@ class Popover extends React.Component { className, placement, classes, + visible: controlledVisible, + positionFixed, + modifiers, + eventsEnabled, ...rest } = this.props; - const { visible } = this.state; + const visible = + controlledVisible !== undefined ? controlledVisible : this.state.visible; const popoverClassName = cn(classes.popover, { [classes.top]: placement!.startsWith("top"), [classes.left]: placement!.startsWith("left"), @@ -128,11 +138,16 @@ class Popover extends React.Component { children({ ref: props.ref, toggleVisibility: this.toggleVisibility, - visible: this.state.visible, + visible, }) } - + {props => (
    { aria-labelledby={`${id}-ariainfo`} aria-hidden={!visible} > - {description} + {description && ( + {description} + )} {visible && (
    { ? body({ scheduleUpdate: props.scheduleUpdate, toggleVisibility: this.toggleVisibility, - visible: this.state.visible, + visible, }) : body}
    diff --git a/src/core/client/ui/components/RadioButton/RadioButton.css b/src/core/client/ui/components/RadioButton/RadioButton.css index cbc2e201a..c751d4d23 100644 --- a/src/core/client/ui/components/RadioButton/RadioButton.css +++ b/src/core/client/ui/components/RadioButton/RadioButton.css @@ -35,10 +35,7 @@ /* Box focus */ .label.focus:before { - outline-width: 3px; - outline-color: Highlight; - outline-color: -webkit-focus-ring-color; - outline-style: auto; + @mixin outline; } /* Box checked */ diff --git a/src/core/client/ui/components/SelectField/SelectField.css b/src/core/client/ui/components/SelectField/SelectField.css index e85b5f8c7..ab22e9f15 100644 --- a/src/core/client/ui/components/SelectField/SelectField.css +++ b/src/core/client/ui/components/SelectField/SelectField.css @@ -15,10 +15,7 @@ } .keyboardFocus:focus { - outline-width: 3px; - outline-color: Highlight; - outline-color: -webkit-focus-ring-color; - outline-style: auto; + @mixin outline; } .select { diff --git a/src/core/client/ui/components/Spinner/Spinner.tsx b/src/core/client/ui/components/Spinner/Spinner.tsx index ef46ea31c..abfe2cc04 100644 --- a/src/core/client/ui/components/Spinner/Spinner.tsx +++ b/src/core/client/ui/components/Spinner/Spinner.tsx @@ -4,7 +4,7 @@ import { withStyles } from "talk-ui/hocs"; import styles from "./Spinner.css"; -type Size = "sm" | "md"; +type Size = "xs" | "sm" | "md"; export interface SpinnerProps { /** @@ -21,6 +21,8 @@ export interface SpinnerProps { function calculateSize(size: Size): number { switch (size) { + case "xs": + return 15; case "sm": return 30; case "md": diff --git a/src/core/client/ui/components/SubBar/SubBar.tsx b/src/core/client/ui/components/SubBar/SubBar.tsx index 8473210b2..1225b967c 100644 --- a/src/core/client/ui/components/SubBar/SubBar.tsx +++ b/src/core/client/ui/components/SubBar/SubBar.tsx @@ -1,12 +1,12 @@ import cn from "classnames"; -import React, { StatelessComponent } from "react"; +import React, { HTMLAttributes, StatelessComponent } from "react"; import { Flex } from "talk-ui/components"; import { withStyles } from "talk-ui/hocs"; import styles from "./SubBar.css"; -interface Props { +interface Props extends HTMLAttributes { children?: React.ReactNode; className?: string; gutterBegin?: boolean; diff --git a/src/core/client/ui/components/Table/TableCell.css b/src/core/client/ui/components/Table/TableCell.css index 40936a2e7..138480e84 100644 --- a/src/core/client/ui/components/Table/TableCell.css +++ b/src/core/client/ui/components/Table/TableCell.css @@ -7,13 +7,11 @@ } .header { - composes: bodyCopy from "talk-ui/shared/typography.css"; - color: var(--palette-grey-dark); + composes: tableHeading from "talk-ui/shared/typography.css"; } .body { - composes: detail from "talk-ui/shared/typography.css"; - color: var(--palette-grey-dark); + composes: tableData from "talk-ui/shared/typography.css"; } .alignCenter { diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts index 283eb903c..acc931248 100644 --- a/src/core/client/ui/components/index.ts +++ b/src/core/client/ui/components/index.ts @@ -1,5 +1,6 @@ export { default as BaseButton } from "./BaseButton"; export { default as Button } from "./Button"; +export { default as Backdrop } from "./Backdrop"; export { default as ButtonIcon } from "./Button/ButtonIcon"; export { default as Typography } from "./Typography"; export { default as Popover } from "./Popover"; diff --git a/src/core/client/ui/helpers/blur.ts b/src/core/client/ui/helpers/blur.ts new file mode 100644 index 000000000..3b6ad501c --- /dev/null +++ b/src/core/client/ui/helpers/blur.ts @@ -0,0 +1,6 @@ +/** blur removes focus from current active element */ +export default function blur() { + if (window.document.activeElement) { + (window.document.activeElement as any).blur(); + } +} diff --git a/src/core/client/ui/helpers/combineEventHandlers.ts b/src/core/client/ui/helpers/combineEventHandlers.ts new file mode 100644 index 000000000..50d67941e --- /dev/null +++ b/src/core/client/ui/helpers/combineEventHandlers.ts @@ -0,0 +1,35 @@ +export default function combineEventHandlers( + a: A, + b: B, + c: C, + d: D +): A & B & C & D; +export default function combineEventHandlers( + a: A, + b: B, + c: C +): A & B & C; +export default function combineEventHandlers(a: A, b: B): A & B; + +/** + * combineEventHandlers expects prop objects with React + * like onEvent handlers e.g. onClick, onKeydown and combine them + * by calling them one after another. + */ +export default function combineEventHandlers(...propObjects: any[]): any { + const result: any = {}; + propObjects.forEach(o => { + Object.keys(o).forEach(k => { + if (k in result) { + const prev = result[k]; + result[k] = (...args: any[]) => { + prev(...args); + (o as any)[k](...args); + }; + } else { + result[k] = (o as any)[k]; + } + }); + }); + return result; +} diff --git a/src/core/client/ui/helpers/index.ts b/src/core/client/ui/helpers/index.ts new file mode 100644 index 000000000..4f7c64bc1 --- /dev/null +++ b/src/core/client/ui/helpers/index.ts @@ -0,0 +1,2 @@ +export { default as blur } from "./blur"; +export { default as combineEventHandlers } from "./combineEventHandlers"; diff --git a/src/core/client/ui/hooks/index.ts b/src/core/client/ui/hooks/index.ts new file mode 100644 index 000000000..e41be91ca --- /dev/null +++ b/src/core/client/ui/hooks/index.ts @@ -0,0 +1,4 @@ +export { default as useFocus } from "./useFocus"; +export { default as usePreventFocusLoss } from "./usePreventFocusLoss"; +export { default as useBlurOnEsc } from "./useBlurOnEsc"; +export { default as useComboBox } from "./useComboBox"; diff --git a/src/core/client/ui/hooks/useBlurOnEsc.ts b/src/core/client/ui/hooks/useBlurOnEsc.ts new file mode 100644 index 000000000..15e705784 --- /dev/null +++ b/src/core/client/ui/hooks/useBlurOnEsc.ts @@ -0,0 +1,17 @@ +import { useCallback } from "react"; + +import { blur } from "../helpers"; + +/** useBlurOnEsc returns handlers that calls blur when pressing ESC */ +export default function useBlurOnEsc(active: boolean) { + return { + onKeyDown: useCallback( + (evt: React.KeyboardEvent) => { + if (evt.keyCode === 27) { + blur(); + } + }, + [active] + ), + }; +} diff --git a/src/core/client/ui/hooks/useComboBox.ts b/src/core/client/ui/hooks/useComboBox.ts new file mode 100644 index 000000000..9ac8430a4 --- /dev/null +++ b/src/core/client/ui/hooks/useComboBox.ts @@ -0,0 +1,114 @@ +import React, { useState } from "react"; + +/** + * ActiveDescendant is the id of the current active selection, which needs to be passed to `aria-activedescendant` prop + */ +export type ActiveDescendant = string | undefined; + +/** EventHandlers are the ones that should be passed to the TextField */ +export interface EventHandlers { + onBlur: React.EventHandler; + onChange: React.EventHandler>; + onKeyDown: React.EventHandler; +} + +export type ListBoxOptionElement = React.ReactElement<{ + id: string; + "aria-selected": boolean; + onClick: React.EventHandler; + href?: string; +}>; + +/** + * ListBoxOptionClickOrEnterHandler is called whenever a click or enter + * occurs on a list box option + */ +export type ListBoxOptionClickOrEnterHandler = ( + evt: React.KeyboardEvent | React.MouseEvent, + element: ListBoxOptionElement +) => void; + +/** + * ListBoxOption is the data for the options to pass to `useComboBox`. + */ +export interface ListBoxOption { + element: ListBoxOptionElement; + onClickOrEnter?: ListBoxOptionClickOrEnterHandler; +} + +/** + * useComboBox accepts an `id` and a list of `ListBoxOption` and returns + * a managed list of `ListBoxOption`, the active descendant and event handlers + * for the textfield. You can find the managed props of the elements in + * the type `ListBoxOptionElements`. + * + * @param id unique identifier for accessibility purposes + * @param opts options to show in the listbox + */ +export default function useComboBox( + id: string, + opts: T[] +): [T[], ActiveDescendant, EventHandlers] { + const [activeIndex, setActiveIndex] = useState(null); + return [ + opts.map((item, i) => ({ + ...item, + element: React.cloneElement(item.element, { + id: `${id}-${i}`, + "aria-selected": activeIndex === i, + key: `${id}-${i}`, + onClick: + item.element.props.onClick || + ((evt: React.MouseEvent) => { + if (item.onClickOrEnter) { + item.onClickOrEnter!(evt, item.element); + } + }), + }), + })), + activeIndex !== null ? `${id}-${activeIndex}` : undefined, + { + onBlur: () => { + /** Reset active selection when the textfield is blurred */ + setActiveIndex(null); + }, + onChange: () => { + /** Reset active selection when the text changes */ + setActiveIndex(null); + }, + onKeyDown: (evt: React.KeyboardEvent) => { + // On Arror Down + if (evt.keyCode === 40) { + if (activeIndex !== opts.length - 1) { + if (activeIndex === null) { + setActiveIndex(0); + } else { + setActiveIndex((activeIndex || 0) + 1); + } + } + evt.preventDefault(); + } + // On Arrow Up + else if (evt.keyCode === 38) { + if (activeIndex !== 0) { + if (activeIndex === null) { + setActiveIndex(0); + } else { + setActiveIndex((activeIndex || 0) - 1); + } + } + evt.preventDefault(); + } + // On ENTER + else if (evt.keyCode === 13) { + if (activeIndex !== null && opts[activeIndex].onClickOrEnter) { + opts[activeIndex].onClickOrEnter!(evt, opts[activeIndex].element); + } + } else { + // Any other key. + setActiveIndex(null); + } + }, + }, + ]; +} diff --git a/src/core/client/ui/hooks/useFocus.ts b/src/core/client/ui/hooks/useFocus.ts new file mode 100644 index 000000000..dbbdedc09 --- /dev/null +++ b/src/core/client/ui/hooks/useFocus.ts @@ -0,0 +1,12 @@ +import { EventHandler, FocusEvent, useCallback, useState } from "react"; + +/** useFocus tracks the focus state and returns the event handlers to do so */ +export default function useFocus(): [ + boolean, + { onFocus: EventHandler; onBlur: EventHandler } +] { + const [focused, setFocused] = useState(false); + const onFocus = useCallback(() => setFocused(true), [setFocused]); + const onBlur = useCallback(() => setFocused(false), [setFocused]); + return [focused, { onFocus, onBlur }]; +} diff --git a/src/core/client/ui/hooks/usePreventFocusLoss.ts b/src/core/client/ui/hooks/usePreventFocusLoss.ts new file mode 100644 index 000000000..0af006623 --- /dev/null +++ b/src/core/client/ui/hooks/usePreventFocusLoss.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; + +/** usePreventFocusLoss returns event handlers that will prevent the current focus from being lost */ +export default function usePreventFocusLoss(active: boolean) { + return { + onMouseDown: useCallback( + (evt: React.MouseEvent) => { + if (!active || evt.target === window.document.activeElement) { + return; + } + evt.preventDefault(); + }, + [active] + ), + }; +} diff --git a/src/core/client/ui/shared/typography.css b/src/core/client/ui/shared/typography.css index c0dd6d5f2..67dbc31dc 100644 --- a/src/core/client/ui/shared/typography.css +++ b/src/core/client/ui/shared/typography.css @@ -260,3 +260,19 @@ letter-spacing: calc(0.1em / 18); color: var(--palette-text-primary); } + +.tableHeading { + font-family: var(--font-family-sans-serif); + font-weight: var(--font-weight-medium); + font-size: calc(14rem / var(--rem-base)); + line-height: calc(20em / 14); + color: var(--palette-grey-dark); +} + +.tableData { + font-family: var(--font-family-sans-serif); + font-weight: var(--font-weight-regular); + font-size: calc(14rem / var(--rem-base)); + line-height: calc(14em / 14); + color: var(--palette-grey-dark); +} diff --git a/src/core/client/ui/theme/mixins.css b/src/core/client/ui/theme/mixins.css index 4ceb1069d..90ec7e3dc 100644 --- a/src/core/client/ui/theme/mixins.css +++ b/src/core/client/ui/theme/mixins.css @@ -94,3 +94,9 @@ letter-spacing: calc(0.2em / 16); color: var(--palette-grey-darkest); } +@define-mixin outline { + outline-width: 3px; + outline-color: Highlight; + outline-color: -webkit-focus-ring-color; + outline-style: auto; +} diff --git a/src/core/server/graph/tenant/resolvers/AcceptCommentPayload.ts b/src/core/server/graph/tenant/resolvers/AcceptCommentPayload.ts index adff7abea..83175cd1b 100644 --- a/src/core/server/graph/tenant/resolvers/AcceptCommentPayload.ts +++ b/src/core/server/graph/tenant/resolvers/AcceptCommentPayload.ts @@ -1,7 +1,7 @@ import { GQLAcceptCommentPayloadTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { moderationQueuesPayloadResolver } from "./ModerationQueues"; +import { moderationQueuesResolver } from "./ModerationQueues"; export const AcceptCommentPayload: GQLAcceptCommentPayloadTypeResolver = { - moderationQueues: moderationQueuesPayloadResolver, + moderationQueues: moderationQueuesResolver, }; diff --git a/src/core/server/graph/tenant/resolvers/ModerationQueues.ts b/src/core/server/graph/tenant/resolvers/ModerationQueues.ts index 79eeab1c3..efd00360a 100644 --- a/src/core/server/graph/tenant/resolvers/ModerationQueues.ts +++ b/src/core/server/graph/tenant/resolvers/ModerationQueues.ts @@ -1,6 +1,7 @@ import { AcceptCommentPayloadToModerationQueuesResolver, GQLModerationQueuesTypeResolver, + QueryToModerationQueuesResolver, RejectCommentPayloadToModerationQueuesResolver, } from "talk-server/graph/tenant/schema/__generated__/types"; import { CommentConnectionInput } from "talk-server/models/comment"; @@ -78,14 +79,16 @@ export const sharedModerationInputResolver = async ( }); /** - * moderationQueuesPayloadResolver implements the resolver that can be used for - * moderation actions payloads. + * moderationQueuesResolver implements the resolver that resolves to the + * shared moderation queues or if `storyID` is provided to the story moderation + * queues. * * @param source the source of the payload, not used * @param args the args of the payload containing potentially a Story ID * @param ctx the TenantContext for which we can use to retrieve the shared data */ -export const moderationQueuesPayloadResolver: +export const moderationQueuesResolver: + | QueryToModerationQueuesResolver | AcceptCommentPayloadToModerationQueuesResolver | RejectCommentPayloadToModerationQueuesResolver = async ( source, diff --git a/src/core/server/graph/tenant/resolvers/Query.ts b/src/core/server/graph/tenant/resolvers/Query.ts index f32611707..9ed6a34bf 100644 --- a/src/core/server/graph/tenant/resolvers/Query.ts +++ b/src/core/server/graph/tenant/resolvers/Query.ts @@ -1,6 +1,6 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { sharedModerationInputResolver } from "./ModerationQueues"; +import { moderationQueuesResolver } from "./ModerationQueues"; export const Query: Required> = { story: (source, args, ctx) => ctx.loaders.Stories.findOrCreate.load(args), @@ -16,5 +16,5 @@ export const Query: Required> = { ctx.loaders.Auth.discoverOIDCConfiguration.load(issuer), debugScrapeStoryMetadata: (source, { url }, ctx) => ctx.loaders.Stories.debugScrapeMetadata.load(url), - moderationQueues: sharedModerationInputResolver, + moderationQueues: moderationQueuesResolver, }; diff --git a/src/core/server/graph/tenant/resolvers/RejectCommentPayload.ts b/src/core/server/graph/tenant/resolvers/RejectCommentPayload.ts index dc3eb6243..23ed69fc4 100644 --- a/src/core/server/graph/tenant/resolvers/RejectCommentPayload.ts +++ b/src/core/server/graph/tenant/resolvers/RejectCommentPayload.ts @@ -1,7 +1,7 @@ import { GQLRejectCommentPayloadTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { moderationQueuesPayloadResolver } from "./ModerationQueues"; +import { moderationQueuesResolver } from "./ModerationQueues"; export const RejectCommentPayload: GQLRejectCommentPayloadTypeResolver = { - moderationQueues: moderationQueuesPayloadResolver, + moderationQueues: moderationQueuesResolver, }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index da95ef0e8..3ff95a896 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -2081,9 +2081,10 @@ type Query { """ moderationQueues returns the set of ModerationQueues that are available for - all stories. + all stories or if given the story identified by the `storyID`. """ - moderationQueues: ModerationQueues! @auth(roles: [ADMIN, MODERATOR]) + moderationQueues(storyID: ID): ModerationQueues! + @auth(roles: [ADMIN, MODERATOR]) } ################################################################################ diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 6cd3dc30d..7c142856b 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -330,6 +330,27 @@ moderate-decision = Decision moderate-single-goToModerationQueues = Go to moderation queues moderate-single-singleCommentView = Single Comment View +### Moderate Search Bar +moderate-searchBar-allStories = All stories + .title = All stories +moderate-searchBar-noResults = No results +moderate-searchBar-stories = Stories: +moderate-searchBar-searchButton = Search +moderate-searchBar-titleNotAvailable = + .title = Title not available +moderate-searchBar-comboBox = + .aria-label = Search or jump to story +moderate-searchBar-searchForm = + .aria-label = Stories +moderate-searchBar-currentlyModerating = + .title = Currently moderating +moderate-searchBar-searchResultsMostRecentFirst = Search results (Most recent first) +moderate-searchBar-moderateAllStories = Moderate all stories +moderate-searchBar-comboBoxTextField = + .aria-label = Search or jump to story... + .placeholder = Use quotation marks around each search term (e.g. “team”, “St. Louis”) +moderate-searchBar-goTo = Go to +moderate-searchBar-seeAllResults = See all results ## Create Username @@ -452,6 +473,7 @@ stories-column-title = Title stories-column-author = Author stories-column-publishDate = Publish Date stories-column-status = Status +stories-column-clickToModerate = Click title to moderate story stories-status-popover = .description = A dropdown to change the story status