diff --git a/package-lock.json b/package-lock.json index cd223d444..e7917f2dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2825,9 +2825,9 @@ "integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw==" }, "@types/react": { - "version": "16.8.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.7.tgz", - "integrity": "sha512-0xbkIyrDNKUn4IJVf8JaCn+ucao/cq6ZB8O6kSzhrJub1cVSqgTArtG0qCfdERWKMEIvUbrwLXeQMqWEsyr9dA==", + "version": "16.8.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.10.tgz", + "integrity": "sha512-7bUQeZKP4XZH/aB4i7k1i5yuwymDu/hnLMhD9NjVZvQQH7ZUgRN3d6iu8YXzx4sN/tNr0bj8jgguk8hhObzGvA==", "dev": true, "requires": { "@types/prop-types": "*", @@ -2844,9 +2844,9 @@ } }, "@types/react-dom": { - "version": "16.0.11", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.0.11.tgz", - "integrity": "sha512-x6zUx9/42B5Kl2Vl9HlopV8JF64wLpX3c+Pst9kc1HgzrsH+mkehe/zmHMQTplIrR48H2gpU7ZqurQolYu8XBA==", + "version": "16.8.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.8.3.tgz", + "integrity": "sha512-HF5hD5YR3z9Mn6kXcW1VKe4AQ04ZlZj1EdLBae61hzQ3eEWWxMgNLUbIxeZp40BnSxqY1eAYLsH9QopQcxzScA==", "dev": true, "requires": { "@types/react": "*" diff --git a/package.json b/package.json index 41d06e56e..71e88230a 100644 --- a/package.json +++ b/package.json @@ -171,9 +171,9 @@ "@types/passport-oauth2": "^1.4.5", "@types/passport-strategy": "^0.2.33", "@types/prop-types": "^15.5.8", - "@types/react": "^16.8.7", + "@types/react": "^16.8.10", "@types/react-copy-to-clipboard": "^4.2.5", - "@types/react-dom": "^16.0.11", + "@types/react-dom": "^16.8.3", "@types/react-relay": "^1.3.9", "@types/react-responsive": "^3.0.1", "@types/react-test-renderer": "^16.8.1", diff --git a/src/core/client/admin/routes/community/components/UserTableFilter.css b/src/core/client/admin/routes/community/components/UserTableFilter.css index 06c450778..2bb04a279 100644 --- a/src/core/client/admin/routes/community/components/UserTableFilter.css +++ b/src/core/client/admin/routes/community/components/UserTableFilter.css @@ -5,4 +5,9 @@ .textField { height: 31px; + width: calc(36 * var(--spacing-unit)); +} + +.adornment { + padding: 0 var(--spacing-unit); } diff --git a/src/core/client/admin/routes/community/components/UserTableFilter.tsx b/src/core/client/admin/routes/community/components/UserTableFilter.tsx index 26e6418b3..7902c69c2 100644 --- a/src/core/client/admin/routes/community/components/UserTableFilter.tsx +++ b/src/core/client/admin/routes/community/components/UserTableFilter.tsx @@ -1,10 +1,13 @@ import { Localized } from "fluent-react/compat"; import React, { StatelessComponent } from "react"; +import { Field, Form } from "react-final-form"; import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "talk-framework/schema"; import { + Button, FieldSet, Flex, + Icon, OptGroup, Option, SelectField, @@ -17,6 +20,8 @@ import styles from "./UserTableFilter.css"; interface Props { roleFilter: GQLUSER_ROLE_RL | null; onSetRoleFilter: (role: GQLUSER_ROLE_RL) => void; + searchFilter: string; + onSetSearchFilter: (search: string) => void; } const UserTableFilter: StatelessComponent = props => ( @@ -31,16 +36,50 @@ const UserTableFilter: StatelessComponent = props => ( Search - + props.onSetSearchFilter(search) + } > - - + {({ handleSubmit }) => ( +
+ + {({ input }) => ( + + + + + } + /> + + )} + +
+ )} +
@@ -59,7 +98,7 @@ const UserTableFilter: StatelessComponent = props => ( props.onSetRoleFilter(e.target.value as any)} + onChange={e => props.onSetRoleFilter((e.target.value as any) || null)} > diff --git a/src/core/client/admin/routes/community/containers/UserTableContainer.tsx b/src/core/client/admin/routes/community/containers/UserTableContainer.tsx index d044887ca..79732104f 100644 --- a/src/core/client/admin/routes/community/containers/UserTableContainer.tsx +++ b/src/core/client/admin/routes/community/containers/UserTableContainer.tsx @@ -1,10 +1,14 @@ -import React, { StatelessComponent, useCallback, useState } from "react"; +import React, { StatelessComponent, useState } from "react"; import { graphql, RelayPaginationProp } from "react-relay"; import { UserTableContainer_query as QueryData } from "talk-admin/__generated__/UserTableContainer_query.graphql"; import { UserTableContainerPaginationQueryVariables } from "talk-admin/__generated__/UserTableContainerPaginationQuery.graphql"; import { IntersectionProvider } from "talk-framework/lib/intersection"; -import { withPaginationContainer } from "talk-framework/lib/relay"; +import { + useLoadMore, + useRefetch, + withPaginationContainer, +} from "talk-framework/lib/relay"; import { GQLUSER_ROLE_RL } from "talk-framework/schema"; import { HorizontalGutter } from "talk-ui/components"; @@ -20,77 +24,44 @@ const UserTableContainer: StatelessComponent = props => { const users = props.query ? props.query.users.edges.map(edge => edge.node) : []; - const [disableLoadMore, setDisableLoadMore] = useState(false); - const [refetching, setRefetching] = useState(false); + + const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); + const [searchFilter, setSearchFilter] = useState(""); const [roleFilter, setRoleFilter] = useState(null); - - const setRoleFilterAndRefetch = useCallback( - (role: GQLUSER_ROLE_RL | null) => { - setRoleFilter(role); - setRefetching(true); - props.relay.refetchConnection( - 10, - error => { - setRefetching(false); - if (error) { - // tslint:disable-next-line:no-console - console.error(error); - } - }, - { - roleFilter: role, - } - ); - }, - [roleFilter, props.relay] - ); - - const loadMore = useCallback( - () => { - if (!props.relay.hasMore() || props.relay.isLoading()) { - return; - } - setDisableLoadMore(true); - props.relay.loadMore( - 10, // Fetch the next 10 feed items - error => { - setDisableLoadMore(false); - if (error) { - // tslint:disable-next-line:no-console - console.error(error); - } - } - ); - }, - [props.relay] - ); + const [, isRefetching] = useRefetch< + Pick< + UserTableContainerPaginationQueryVariables, + "searchFilter" | "roleFilter" + > + >(props.relay, { + searchFilter: searchFilter || null, + roleFilter, + }); return ( setRoleFilterAndRefetch(role || null)} + onSetRoleFilter={setRoleFilter} roleFilter={roleFilter} + onSetSearchFilter={setSearchFilter} + searchFilter={searchFilter} /> ); }; -// TODO: (cvle) This should be autogenerated. -interface FragmentVariables { - count: number; - cursor?: string; - roleFilter: GQLUSER_ROLE_RL | null; -} +// TODO: (cvle) In this case they are the same, but they should be autogenerated. +type FragmentVariables = UserTableContainerPaginationQueryVariables; const enhanced = withPaginationContainer< Props, @@ -104,12 +75,17 @@ const enhanced = withPaginationContainer< count: { type: "Int!", defaultValue: 10 } cursor: { type: "Cursor" } roleFilter: { type: "USER_ROLE" } + searchFilter: { type: "String" } ) { viewer { ...UserRowContainer_viewer } - users(first: $count, after: $cursor, role: $roleFilter) - @connection(key: "UserTable_users") { + users( + first: $count + after: $cursor + role: $roleFilter + query: $searchFilter + ) @connection(key: "UserTable_users") { edges { node { id @@ -137,6 +113,7 @@ const enhanced = withPaginationContainer< count, cursor, roleFilter: fragmentVariables.roleFilter, + searchFilter: fragmentVariables.searchFilter, }; }, query: graphql` @@ -146,9 +123,15 @@ const enhanced = withPaginationContainer< $count: Int! $cursor: Cursor $roleFilter: USER_ROLE + $searchFilter: String ) { ...UserTableContainer_query - @arguments(count: $count, cursor: $cursor, roleFilter: $roleFilter) + @arguments( + count: $count + cursor: $cursor + roleFilter: $roleFilter + searchFilter: $searchFilter + ) } `, } diff --git a/src/core/client/admin/routes/moderate/containers/QueueContainer.tsx b/src/core/client/admin/routes/moderate/containers/QueueContainer.tsx index 21e849181..fd4c8575f 100644 --- a/src/core/client/admin/routes/moderate/containers/QueueContainer.tsx +++ b/src/core/client/admin/routes/moderate/containers/QueueContainer.tsx @@ -62,11 +62,8 @@ export class QueueContainer extends React.Component { }; } -// TODO: (cvle) This should be autogenerated. -interface FragmentVariables { - count: number; - cursor?: string; -} +// TODO: (cvle) If this could be autogenerated.. +type FragmentVariables = QueueContainerPaginationPendingQueryVariables; const createQueueContainer = ( queueQuery: GraphQLTaggedNode, diff --git a/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx b/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx index c4254ac38..704774023 100644 --- a/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx +++ b/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx @@ -61,11 +61,8 @@ export class RejectedQueueContainer extends React.Component< }; } -// TODO: (cvle) This should be autogenerated. -interface FragmentVariables { - count: number; - cursor?: string; -} +// TODO: (cvle) If this could be autogenerated.. +type FragmentVariables = RejectedQueueContainerPaginationQueryVariables; const enhanced = (withPaginationContainer< RejectedQueueContainerProps, diff --git a/src/core/client/admin/routes/stories/components/StoryTableFilter.css b/src/core/client/admin/routes/stories/components/StoryTableFilter.css index 06c450778..2bb04a279 100644 --- a/src/core/client/admin/routes/stories/components/StoryTableFilter.css +++ b/src/core/client/admin/routes/stories/components/StoryTableFilter.css @@ -5,4 +5,9 @@ .textField { height: 31px; + width: calc(36 * var(--spacing-unit)); +} + +.adornment { + padding: 0 var(--spacing-unit); } diff --git a/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx b/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx index 4ad42f4f0..7506f2a7a 100644 --- a/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx +++ b/src/core/client/admin/routes/stories/components/StoryTableFilter.tsx @@ -1,10 +1,13 @@ import { Localized } from "fluent-react/compat"; import React, { StatelessComponent } from "react"; +import { Field, Form } from "react-final-form"; import { GQLSTORY_STATUS, GQLSTORY_STATUS_RL } from "talk-framework/schema"; import { + Button, FieldSet, Flex, + Icon, Option, SelectField, TextField, @@ -16,6 +19,8 @@ import styles from "./StoryTableFilter.css"; interface Props { statusFilter: GQLSTORY_STATUS_RL | null; onSetStatusFilter: (status: GQLSTORY_STATUS_RL) => void; + searchFilter: string; + onSetSearchFilter: (search: string) => void; } const StoryTableFilter: StatelessComponent = props => ( @@ -30,16 +35,50 @@ const StoryTableFilter: StatelessComponent = props => ( Search - + props.onSetSearchFilter(search) + } > - - + {({ handleSubmit }) => ( +
+ + {({ input }) => ( + + + + + } + /> + + )} + +
+ )} +
@@ -58,7 +97,9 @@ const StoryTableFilter: StatelessComponent = props => ( props.onSetStatusFilter(e.target.value as any)} + onChange={e => + props.onSetStatusFilter((e.target.value as any) || null) + } > diff --git a/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx b/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx index 25d1a5500..43075fc28 100644 --- a/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx +++ b/src/core/client/admin/routes/stories/containers/StatusChangeContainer.tsx @@ -18,7 +18,7 @@ interface Props { } const StatusChangeContainer: StatelessComponent = props => { - const hanldeChangeStatus = useCallback( + const handleChangeStatus = useCallback( (status: GQLSTORY_STATUS_RL) => { if (props.status === status) { return; @@ -32,7 +32,7 @@ const StatusChangeContainer: StatelessComponent = props => { [props.storyID, props.closeStory, props.openStory, props.status] ); return ( - + ); }; diff --git a/src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx b/src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx index 3790e4551..890bc194c 100644 --- a/src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx +++ b/src/core/client/admin/routes/stories/containers/StoryTableContainer.tsx @@ -1,10 +1,14 @@ -import React, { StatelessComponent, useCallback, useState } from "react"; +import React, { StatelessComponent, useState } from "react"; import { graphql, RelayPaginationProp } from "react-relay"; import { StoryTableContainer_query as QueryData } from "talk-admin/__generated__/StoryTableContainer_query.graphql"; import { StoryTableContainerPaginationQueryVariables } from "talk-admin/__generated__/StoryTableContainerPaginationQuery.graphql"; import { IntersectionProvider } from "talk-framework/lib/intersection"; -import { withPaginationContainer } from "talk-framework/lib/relay"; +import { + useLoadMore, + useRefetch, + withPaginationContainer, +} from "talk-framework/lib/relay"; import { GQLSTORY_STATUS_RL } from "talk-framework/schema"; import { HorizontalGutter } from "talk-ui/components"; @@ -20,82 +24,47 @@ const StoryTableContainer: StatelessComponent = props => { const stories = props.query ? props.query.stories.edges.map(edge => edge.node) : []; - const [disableLoadMore, setDisableLoadMore] = useState(false); - const [refetching, setRefetching] = useState(false); + + const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); + const [searchFilter, setSearchFilter] = useState(""); const [statusFilter, setStatusFilter] = useState( null ); - - const setStatusFilterAndRefetch = useCallback( - (status: GQLSTORY_STATUS_RL | null) => { - setStatusFilter(status); - setRefetching(true); - props.relay.refetchConnection( - 10, - error => { - setRefetching(false); - if (error) { - // tslint:disable-next-line:no-console - console.error(error); - } - }, - { - statusFilter: status, - } - ); - }, - [statusFilter, props.relay] - ); - - const loadMore = useCallback( - () => { - if (!props.relay.hasMore() || props.relay.isLoading()) { - return; - } - setDisableLoadMore(true); - props.relay.loadMore( - 10, // Fetch the next 10 feed items - error => { - setDisableLoadMore(false); - if (error) { - // tslint:disable-next-line:no-console - console.error(error); - } - } - ); - }, - [props.relay] - ); + const [, isRefetching] = useRefetch< + Pick< + StoryTableContainerPaginationQueryVariables, + "searchFilter" | "statusFilter" + > + >(props.relay, { + searchFilter: searchFilter || null, + statusFilter, + }); return ( - setStatusFilterAndRefetch(status || null) - } + onSetStatusFilter={setStatusFilter} statusFilter={statusFilter} + onSetSearchFilter={setSearchFilter} + searchFilter={searchFilter} /> ); }; -// TODO: (cvle) This should be autogenerated. -interface FragmentVariables { - count: number; - cursor?: string; - statusFilter: GQLSTORY_STATUS_RL | null; -} +// TODO: (cvle) In this case they are the same, but they should be autogenerated. +type FragmentVariables = StoryTableContainerPaginationQueryVariables; const enhanced = withPaginationContainer< Props, @@ -109,12 +78,17 @@ const enhanced = withPaginationContainer< count: { type: "Int!", defaultValue: 10 } cursor: { type: "Cursor" } statusFilter: { type: "STORY_STATUS" } + searchFilter: { type: "String" } ) { viewer { ...StoryRowContainer_viewer } - stories(first: $count, after: $cursor, status: $statusFilter) - @connection(key: "StoryTable_stories") { + stories( + first: $count + after: $cursor + status: $statusFilter + query: $searchFilter + ) @connection(key: "StoryTable_stories") { edges { node { id @@ -142,6 +116,7 @@ const enhanced = withPaginationContainer< count, cursor, statusFilter: fragmentVariables.statusFilter, + searchFilter: fragmentVariables.searchFilter, }; }, query: graphql` @@ -151,12 +126,14 @@ const enhanced = withPaginationContainer< $count: Int! $cursor: Cursor $statusFilter: STORY_STATUS + $searchFilter: String ) { ...StoryTableContainer_query @arguments( count: $count cursor: $cursor statusFilter: $statusFilter + searchFilter: $searchFilter ) } `, diff --git a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap index 3101a8e84..94ca069be 100644 --- a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap +++ b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap @@ -19,16 +19,46 @@ exports[`renders community 1`] = ` > Search -
- -
+
+ +
+ +
+
+
Search -
- -
+
+ +
+ +
+
+
{ // Make sure third user was added. within(container).getByText(users[2].username); }); + +it("filter by search", async () => { + const { container } = await createTestRenderer({ + Query: { + users: createSinonStub( + s => s.onFirstCall().returns(communityUsers), + s => + s.onSecondCall().callsFake((_, data) => { + expectAndFail(data.query).toBe("search"); + return emptyCommunityUsers; + }) + ), + }, + }); + + const searchField = within(container).getByLabelText("Search by username", { + exact: false, + }); + const form = findParentWithType(searchField, "form")!; + + TestRenderer.act(() => { + searchField.props.onChange({ + target: { value: "search" }, + }); + form.props.onSubmit(); + }); + + await waitForElement(() => + within(container).getByText("could not find anyone", { exact: false }) + ); +}); diff --git a/src/core/client/admin/test/configure/__snapshots__/general.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/general.spec.tsx.snap index 809804602..d81f21139 100644 --- a/src/core/client/admin/test/configure/__snapshots__/general.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/general.spec.tsx.snap @@ -410,11 +410,15 @@ Markdown can be found type="text" value={3} /> -

- Characters -

+

+ Characters +

+
-

- Characters -

+

+ Characters +

+
diff --git a/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap index 011de24d9..6634835de 100644 --- a/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap @@ -221,11 +221,15 @@ comment is toxic, according to Perspective API. By default the treshold is set t type="text" value="" /> -

- % -

+

+ % +

+
Search -
- -
+
+ +
+ +
+
+
Search -
- -
+
+ +
+ +
+
+
{ // Make sure third user was added. within(container).getByText(stories[2].metadata!.title!); }); + +it("filter by search", async () => { + const { container } = await createTestRenderer({ + Query: { + stories: createSinonStub( + s => s.onFirstCall().returns(storyConnection), + s => + s.onSecondCall().callsFake((_, data) => { + expectAndFail(data.query).toBe("search"); + return emptyStories; + }) + ), + }, + }); + + const searchField = within(container).getByLabelText( + "Search by story title", + { exact: false } + ); + const form = findParentWithType(searchField, "form")!; + + TestRenderer.act(() => { + searchField.props.onChange({ + target: { value: "search" }, + }); + form.props.onSubmit(); + }); + + await waitForElement(() => + within(container).getByText("could not find any", { exact: false }) + ); +}); diff --git a/src/core/client/admin/views/decisionHistory/containers/DecisionHistoryContainer.tsx b/src/core/client/admin/views/decisionHistory/containers/DecisionHistoryContainer.tsx index f1b83ecb5..f4df3134c 100644 --- a/src/core/client/admin/views/decisionHistory/containers/DecisionHistoryContainer.tsx +++ b/src/core/client/admin/views/decisionHistory/containers/DecisionHistoryContainer.tsx @@ -53,11 +53,8 @@ export class DecisionHistoryContainer extends React.Component< }; } -// TODO: (cvle) This should be autogenerated. -interface FragmentVariables { - count: number; - cursor?: string; -} +// TODO: (cvle) If this could be autogenerated. +type FragmentVariables = DecisionHistoryContainerPaginationQueryVariables; const enhanced = withPaginationContainer< DecisionHistoryContainerProps, diff --git a/src/core/client/framework/hooks/index.ts b/src/core/client/framework/hooks/index.ts new file mode 100644 index 000000000..4a772d9df --- /dev/null +++ b/src/core/client/framework/hooks/index.ts @@ -0,0 +1 @@ +export { default as useEffectAfterMount } from "./useEffectAfterMount"; diff --git a/src/core/client/framework/hooks/useEffectAfterMount.ts b/src/core/client/framework/hooks/useEffectAfterMount.ts new file mode 100644 index 000000000..77dc4a46a --- /dev/null +++ b/src/core/client/framework/hooks/useEffectAfterMount.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from "react"; + +/** + * useEffectAfterMount is a react hook that will run effects + * except when the component has just been mounted. + */ +export default function useEffectAfterMount( + effect: React.EffectCallback, + deps?: ReadonlyArray | undefined +) { + const mountedRef = useRef(false); + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + return; + } + return effect(); + }, deps); +} diff --git a/src/core/client/framework/lib/relay/index.ts b/src/core/client/framework/lib/relay/index.ts index 6da94c702..79617174f 100644 --- a/src/core/client/framework/lib/relay/index.ts +++ b/src/core/client/framework/lib/relay/index.ts @@ -24,3 +24,5 @@ export { } from "./commitLocalUpdatePromisified"; export { initLocalBaseState, setAccessTokenInLocalState } from "./localState"; export { default as fetchQuery } from "./fetchQuery"; +export { default as useRefetch } from "./useRefetch"; +export { default as useLoadMore } from "./useLoadMore"; diff --git a/src/core/client/framework/lib/relay/useLoadMore.ts b/src/core/client/framework/lib/relay/useLoadMore.ts new file mode 100644 index 000000000..a2162be13 --- /dev/null +++ b/src/core/client/framework/lib/relay/useLoadMore.ts @@ -0,0 +1,30 @@ +import { useCallback, useState } from "react"; +import { RelayPaginationProp } from "react-relay"; + +/** + * useLoadMore is a react hook that returns a `loadMore` callback + * and a `isLoadingMore` boolean. + */ +export default function useLoadMore( + relay: RelayPaginationProp, + count: number +): [() => void, boolean] { + const [isLoadingMore, setIsLoadingMore] = useState(false); + const loadMore = useCallback( + () => { + if (!relay.hasMore() || relay.isLoading()) { + return; + } + setIsLoadingMore(true); + relay.loadMore(count, error => { + setIsLoadingMore(false); + if (error) { + // tslint:disable-next-line:no-console + console.error(error); + } + }); + }, + [relay] + ); + return [loadMore, isLoadingMore]; +} diff --git a/src/core/client/framework/lib/relay/useRefetch.ts b/src/core/client/framework/lib/relay/useRefetch.ts new file mode 100644 index 000000000..ca829fa1e --- /dev/null +++ b/src/core/client/framework/lib/relay/useRefetch.ts @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { RelayPaginationProp } from "react-relay"; +import { Variables } from "relay-runtime"; + +import { useEffectAfterMount } from "talk-framework/hooks"; + +/** + * useRefetch is a react hook that returns a `refetch` callback + * and a `isRefetching` boolean. Any change to the variables + * will result in a `refetch`. + */ +export default function useRefetch( + relay: RelayPaginationProp, + variables: V = {} as any +): [() => void, boolean] { + const [manualRefetchCount, setManualRefetchCount] = useState(0); + const [refetching, setRefetching] = useState(false); + useEffectAfterMount( + () => { + setRefetching(true); + const disposable = relay.refetchConnection( + 10, + error => { + setRefetching(false); + if (error) { + // tslint:disable-next-line:no-console + console.error(error); + } + }, + variables + ); + return () => { + if (disposable) { + disposable.dispose(); + } + }; + }, + [ + relay, + manualRefetchCount, + ...Object.keys(variables).reduce((a, k) => { + a.push((variables as any)[k]); + return a; + }, []), + ] + ); + return [() => setManualRefetchCount(manualRefetchCount + 1), refetching]; +} diff --git a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx index 08c4fd6c8..82cb7f6c3 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx @@ -3,15 +3,12 @@ import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay"; import { withProps } from "recompose"; import { withPaginationContainer } from "talk-framework/lib/relay"; -import { PropTypesOf } from "talk-framework/types"; +import { Omit, PropTypesOf } from "talk-framework/types"; import { ReplyListContainer1_comment as CommentData } from "talk-stream/__generated__/ReplyListContainer1_comment.graphql"; import { ReplyListContainer1_settings as SettingsData } from "talk-stream/__generated__/ReplyListContainer1_settings.graphql"; import { ReplyListContainer1_story as StoryData } from "talk-stream/__generated__/ReplyListContainer1_story.graphql"; import { ReplyListContainer1_viewer as ViewerData } from "talk-stream/__generated__/ReplyListContainer1_viewer.graphql"; -import { - COMMENT_SORT, - ReplyListContainer1PaginationQueryVariables, -} from "talk-stream/__generated__/ReplyListContainer1PaginationQuery.graphql"; +import { ReplyListContainer1PaginationQueryVariables } from "talk-stream/__generated__/ReplyListContainer1PaginationQuery.graphql"; import { ReplyListContainer5_comment as Comment5Data } from "talk-stream/__generated__/ReplyListContainer5_comment.graphql"; import { StatelessComponent } from "enzyme"; @@ -39,12 +36,12 @@ type Props = BaseProps & { | undefined; }; -// TODO: (cvle) This should be autogenerated. -interface FragmentVariables { - count: number; - cursor?: string; - orderBy: COMMENT_SORT; -} +// TODO: (cvle) If this could be autogenerated. +type FragmentVariables = Omit< + ReplyListContainer1PaginationQueryVariables, + "commentID" +>; + export class ReplyListContainer extends React.Component { public state = { disableShowAll: false, diff --git a/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx b/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx index c2324537f..a5c96694d 100644 --- a/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx @@ -2,7 +2,7 @@ import React, { ChangeEvent } from "react"; import { graphql, RelayPaginationProp } from "react-relay"; import { withPaginationContainer } from "talk-framework/lib/relay"; -import { PropTypesOf } from "talk-framework/types"; +import { Omit, PropTypesOf } from "talk-framework/types"; import { StreamContainer_settings as SettingsData } from "talk-stream/__generated__/StreamContainer_settings.graphql"; import { StreamContainer_story as StoryData } from "talk-stream/__generated__/StreamContainer_story.graphql"; import { StreamContainer_viewer as ViewerData } from "talk-stream/__generated__/StreamContainer_viewer.graphql"; @@ -96,12 +96,11 @@ export class StreamContainer extends React.Component { }; } -// TODO: (cvle) This should be autogenerated. -interface FragmentVariables { - count: number; - cursor?: string; - orderBy: COMMENT_SORT; -} +// TODO: (cvle) if this could be autogenerated.. +type FragmentVariables = Omit< + StreamContainerPaginationQueryVariables, + "storyID" +>; const enhanced = withPaginationContainer< Props, diff --git a/src/core/client/stream/tabs/profile/containers/CommentHistoryContainer.tsx b/src/core/client/stream/tabs/profile/containers/CommentHistoryContainer.tsx index f2eb3e9b0..f1d90e26a 100644 --- a/src/core/client/stream/tabs/profile/containers/CommentHistoryContainer.tsx +++ b/src/core/client/stream/tabs/profile/containers/CommentHistoryContainer.tsx @@ -52,11 +52,8 @@ export class CommentHistoryContainer extends React.Component< }; } -// TODO: (cvle) This should be autogenerated. -interface FragmentVariables { - count: number; - cursor?: string; -} +// TODO: (cvle) If this could be autogenerated. +type FragmentVariables = CommentHistoryContainerPaginationQueryVariables; const enhanced = withPaginationContainer< CommentHistoryContainerProps, diff --git a/src/core/client/ui/components/Button/Button.css b/src/core/client/ui/components/Button/Button.css index eedba60f5..2ee6da3c8 100644 --- a/src/core/client/ui/components/Button/Button.css +++ b/src/core/client/ui/components/Button/Button.css @@ -63,6 +63,9 @@ &.colorBrand { color: var(--palette-brand-main); } + &.colorDark { + color: var(--palette-text-primary); + } &:not(.disabled) { &.colorRegular { @@ -115,6 +118,16 @@ color: var(--palette-brand-lighter); } } + &.colorDark { + /* @todo: What colors to use here.. */ + &.mouseHover { + color: var(--palette-text-primary); + } + &:active, + &.active { + color: var(--palette-text-primary); + } + } } } @@ -135,6 +148,9 @@ &.colorBrand { background-color: var(--palette-brand-main); } + &.colorDark { + background-color: var(--palette-text-primary); + } &:not(.disabled) { &.colorRegular { @@ -182,6 +198,16 @@ background-color: var(--palette-brand-lighter); } } + &.colorDark { + /* @todo: What colors to use here.. */ + &.mouseHover { + background-color: var(--palette-text-primary); + } + &:active, + &.active { + background-color: var(--palette-text-primary); + } + } } } @@ -207,6 +233,10 @@ color: var(--palette-brand-main); border: 1px solid currentColor; } + &.colorDark { + color: var(--palette-text-primary); + border: 1px solid currentColor; + } &:not(.disabled) { &.colorRegular { @@ -264,6 +294,17 @@ border: 1px solid currentColor; } } + &.colorDark { + &.mouseHover { + color: var(--palette-text-primary); + border: 1px solid currentColor; + } + &:active, + &.active { + color: var(--palette-text-primary); + border: 1px solid currentColor; + } + } } } @@ -284,6 +325,9 @@ &.colorBrand { color: var(--palette-brand-main); } + &.colorDark { + color: var(--palette-text-primary); + } &:not(.disabled) { &.colorRegular { @@ -341,6 +385,17 @@ background-color: var(--palette-brand-main); } } + &.colorDark { + &.mouseHover { + border: 1px solid currentColor; + } + &:active, + &.active { + border: 1px solid currentColor; + color: var(--palette-text-light); + background-color: var(--palette-text-primary); + } + } } } @@ -369,6 +424,9 @@ &.colorBrand { color: var(--palette-brand-main); } + &.colorDark { + color: var(--palette-text-primary); + } &:not(.disabled) { &.colorRegular { @@ -421,5 +479,97 @@ color: var(--palette-brand-lighter); } } + &.colorDark { + /* @todo: What colors to use here.. */ + &.mouseHover { + color: var(--palette-text-primary); + } + &:active, + &.active { + color: var(--palette-text-primary); + } + } + } +} + +.variantAdornment { + color: var(--palette-text-light); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + &.colorRegular { + background-color: var(--palette-grey-main); + } + &.colorPrimary { + background-color: var(--palette-primary-main); + } + &.colorError { + background-color: var(--palette-error-main); + } + &.colorSuccess { + background-color: var(--palette-success-main); + } + &.colorBrand { + background-color: var(--palette-brand-main); + } + &.colorDark { + background-color: var(--palette-text-primary); + } + + &:not(.disabled) { + &.colorRegular { + &.mouseHover { + background-color: var(--palette-grey-light); + } + &:active, + &.active { + background-color: var(--palette-grey-lighter); + } + } + &.colorPrimary { + &.mouseHover { + background-color: var(--palette-primary-light); + } + &:active, + &.active { + background-color: var(--palette-primary-lighter); + } + } + &.colorError { + &.mouseHover { + background-color: var(--palette-error-light); + } + &:active, + &.active { + background-color: var(--palette-error-lighter); + } + } + &.colorSuccess { + &.mouseHover { + background-color: var(--palette-success-light); + } + &:active, + &.active { + background-color: var(--palette-success-lighter); + } + } + &.colorBrand { + &.mouseHover { + background-color: var(--palette-brand-light); + } + &:active, + &.active { + background-color: var(--palette-brand-lighter); + } + } + &.colorDark { + /* @todo: What colors to use here.. */ + &.mouseHover { + background-color: var(--palette-text-primary); + } + &:active, + &.active { + background-color: var(--palette-text-primary); + } + } } } diff --git a/src/core/client/ui/components/Button/Button.mdx b/src/core/client/ui/components/Button/Button.mdx index f6d85b6fc..ffb1a629d 100644 --- a/src/core/client/ui/components/Button/Button.mdx +++ b/src/core/client/ui/components/Button/Button.mdx @@ -3,15 +3,16 @@ name: Button menu: UI Kit --- -import { Playground } from 'docz' -import Button from './Button' -import ButtonIcon from './ButtonIcon' -import Icon from '../Icon' -import Flex from '../Flex' +import { Playground } from "docz"; +import Button from "./Button"; +import ButtonIcon from "./ButtonIcon"; +import Icon from "../Icon"; +import Flex from "../Flex"; # Button ## Regular Button + @@ -20,57 +21,130 @@ import Flex from '../Flex' + - + ## Filled Button + - - - - - - - - - + + + + + + + + + + ## Outlined Button + - - - - - - - - - + + + + + + + + + + ## Ghost Button + - - - - - - - - - + + + + + + + + + + diff --git a/src/core/client/ui/components/Button/Button.tsx b/src/core/client/ui/components/Button/Button.tsx index acdcd2a78..ada2ed357 100644 --- a/src/core/client/ui/components/Button/Button.tsx +++ b/src/core/client/ui/components/Button/Button.tsx @@ -24,10 +24,23 @@ interface Props extends Omit { size?: "small" | "regular" | "large"; /** Color of the button */ - color?: "regular" | "primary" | "error" | "success" | "brand" | "light"; + color?: + | "regular" + | "primary" + | "error" + | "success" + | "brand" + | "light" + | "dark"; /** Variant of the button */ - variant?: "regular" | "filled" | "outlined" | "ghost" | "underlined"; + variant?: + | "regular" + | "filled" + | "outlined" + | "ghost" + | "underlined" + | "adornment"; /** If set renders a full width button */ fullWidth?: boolean; @@ -72,10 +85,12 @@ export class Button extends React.Component { [classes.colorError]: color === "error", [classes.colorSuccess]: color === "success", [classes.colorBrand]: color === "brand", + [classes.colorDark]: color === "dark", [classes.variantRegular]: variant === "regular", [classes.variantFilled]: variant === "filled", [classes.variantOutlined]: variant === "outlined", [classes.variantGhost]: variant === "ghost", + [classes.variantAdornment]: variant === "adornment", [classes.variantUnderlined]: variant === "underlined", [classes.fullWidth]: fullWidth, [classes.active]: active, diff --git a/src/core/client/ui/components/TextField/TextField.css b/src/core/client/ui/components/TextField/TextField.css index 6033b558f..95bcac14c 100644 --- a/src/core/client/ui/components/TextField/TextField.css +++ b/src/core/client/ui/components/TextField/TextField.css @@ -2,6 +2,7 @@ display: flex; width: calc(29 * var(--spacing-unit)); align-items: center; + height: 36px; } .input { @@ -11,9 +12,9 @@ padding: calc(0.5 * var(--spacing-unit)); box-sizing: border-box; border-radius: var(--round-corners); - height: 36px; - line-height: 36px; width: 100%; + line-height: 36px; + align-self: stretch; &:read-only { background-color: var(--palette-grey-lightest); @@ -24,8 +25,8 @@ } } -.input + * { - padding-left: calc(0.5 * var(--spacing-unit)); +.adornment { + margin-left: calc(0.5 * var(--spacing-unit)); } .colorRegular { @@ -47,3 +48,16 @@ .textAlignCenter { text-align: center; } + +.seamlessAdornment { + border-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + & + .adornment { + height: 100%; + margin: 0; + display: flex; + align-items: stretch; + } +} diff --git a/src/core/client/ui/components/TextField/TextField.mdx b/src/core/client/ui/components/TextField/TextField.mdx index 52c3df9a8..59be64785 100644 --- a/src/core/client/ui/components/TextField/TextField.mdx +++ b/src/core/client/ui/components/TextField/TextField.mdx @@ -4,7 +4,11 @@ menu: UI Kit --- import { Playground, PropsTable } from "docz"; -import TextField from "./TextField.tsx"; + +import TextField from "./TextField"; +import Button from "../Button"; +import Icon from "../Icon"; +import Flex from "../Flex"; import HorizontalGutter from "../HorizontalGutter"; # TextField @@ -13,7 +17,17 @@ import HorizontalGutter from "../HorizontalGutter"; - + + + search + + } + /> + = props => { @@ -74,6 +76,7 @@ const TextField: StatelessComponent = props => { placeholder, adornment, textAlignCenter, + variant, ...rest } = props; @@ -89,6 +92,7 @@ const TextField: StatelessComponent = props => { [classes.colorRegular]: color === "regular", [classes.colorError]: color === "error", [classes.textAlignCenter]: textAlignCenter, + [classes.seamlessAdornment]: variant === "seamlessAdornment", }); return ( @@ -99,7 +103,7 @@ const TextField: StatelessComponent = props => { value={value} {...rest} /> - {adornment} + {adornment &&
{adornment}
} ); }; diff --git a/src/core/client/ui/components/TextField/__snapshots__/TextField.spec.tsx.snap b/src/core/client/ui/components/TextField/__snapshots__/TextField.spec.tsx.snap index e211a5fa8..13359b792 100644 --- a/src/core/client/ui/components/TextField/__snapshots__/TextField.spec.tsx.snap +++ b/src/core/client/ui/components/TextField/__snapshots__/TextField.spec.tsx.snap @@ -10,6 +10,10 @@ exports[`renders correctly 1`] = ` placeholder="" type="text" /> - Unit +
+ Unit +
`; diff --git a/src/core/server/graph/tenant/loaders/Stories.ts b/src/core/server/graph/tenant/loaders/Stories.ts index c4de49178..825e6d610 100644 --- a/src/core/server/graph/tenant/loaders/Stories.ts +++ b/src/core/server/graph/tenant/loaders/Stories.ts @@ -34,6 +34,14 @@ const statusFilter = ( } }; +const queryFilter = (query?: string): StoryConnectionInput["filter"] => { + if (query) { + return { $text: { $search: query } }; + } + + return {}; +}; + /** * primeStoriesFromConnection will prime a given context with the stories * retrieved via a connection. @@ -67,13 +75,16 @@ export default (ctx: TenantContext) => ({ story: new DataLoader(ids => retrieveManyStories(ctx.mongo, ctx.tenant.id, ids) ), - connection: ({ first = 10, after, status }: QueryToStoriesArgs) => + connection: ({ first = 10, after, status, query }: QueryToStoriesArgs) => retrieveStoryConnection(ctx.mongo, ctx.tenant.id, { first, after, filter: { // Merge the status filter into the connection filter. ...statusFilter(status), + + // Merge the query filters into the query. + ...queryFilter(query), }, }).then(primeStoriesFromConnection(ctx)), debugScrapeMetadata: new DataLoader(urls => diff --git a/src/core/server/graph/tenant/loaders/Users.ts b/src/core/server/graph/tenant/loaders/Users.ts index 4ed8a3024..d891ac983 100644 --- a/src/core/server/graph/tenant/loaders/Users.ts +++ b/src/core/server/graph/tenant/loaders/Users.ts @@ -1,15 +1,34 @@ import DataLoader from "dataloader"; -import { isNil, omitBy } from "lodash"; import Context from "talk-server/graph/tenant/context"; -import { QueryToUsersArgs } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLUSER_ROLE, + QueryToUsersArgs, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { Connection } from "talk-server/models/helpers/connection"; import { retrieveManyUsers, retrieveUserConnection, User, + UserConnectionInput, } from "talk-server/models/user"; +const roleFilter = (role?: GQLUSER_ROLE): UserConnectionInput["filter"] => { + if (role) { + return { role }; + } + + return {}; +}; + +const queryFilter = (query?: string): UserConnectionInput["filter"] => { + if (query) { + return { $text: { $search: query } }; + } + + return {}; +}; + /** * primeUsersFromConnection will prime a given context with the users retrieved * via a connection. @@ -39,11 +58,17 @@ export default (ctx: Context) => { return { user, - connection: ({ first = 10, after, role }: QueryToUsersArgs) => + connection: ({ first = 10, after, role, query }: QueryToUsersArgs) => retrieveUserConnection(ctx.mongo, ctx.tenant.id, { first, after, - filter: omitBy({ role }, isNil), + filter: { + // Merge role filters into the query. + ...roleFilter(role), + + // Merge the query filters into the query. + ...queryFilter(query), + }, }).then(primeUsersFromConnection(ctx)), }; }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 452923075..7197a304b 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1802,6 +1802,7 @@ type Query { first: Int = 10 after: Cursor status: STORY_STATUS + query: String ): StoriesConnection! @auth(roles: [ADMIN, MODERATOR]) """ @@ -1812,8 +1813,12 @@ type Query { """ users returns filtered users that can be paginated. """ - users(first: Int = 10, after: Cursor, role: USER_ROLE): UsersConnection! - @auth(roles: [ADMIN, MODERATOR]) + users( + first: Int = 10 + after: Cursor + role: USER_ROLE + query: String + ): UsersConnection! @auth(roles: [ADMIN, MODERATOR]) """ viewer is the current logged in User. If no user is currently logged in, it will diff --git a/src/core/server/models/action/comment.ts b/src/core/server/models/action/comment.ts index 4a2bde922..761e99f19 100644 --- a/src/core/server/models/action/comment.ts +++ b/src/core/server/models/action/comment.ts @@ -11,10 +11,8 @@ import { GQLCOMMENT_FLAG_REASON, GQLCOMMENT_FLAG_REPORTED_REASON, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { - createIndexFactory, - FilterQuery, -} from "talk-server/models/helpers/query"; +import { createIndexFactory } from "talk-server/models/helpers/indexing"; +import { FilterQuery } from "talk-server/models/helpers/query"; import { TenantResource } from "talk-server/models/tenant"; function collection(mongo: Db) { @@ -113,7 +111,10 @@ export async function createCommentActionIndexes(mongo: Db) { await createIndex({ tenantID: 1, id: 1 }, { unique: true }); // { actionType, commentID } - await createIndex({ tenantID: 1, actionType: 1, commentID: 1, userID: 1 }); + await createIndex( + { tenantID: 1, actionType: 1, commentID: 1, userID: 1 }, + { background: true } + ); } const ActionSchema = [ diff --git a/src/core/server/models/action/moderation/comment.ts b/src/core/server/models/action/moderation/comment.ts index c1bc8a8a8..c9e4ef90c 100644 --- a/src/core/server/models/action/moderation/comment.ts +++ b/src/core/server/models/action/moderation/comment.ts @@ -8,10 +8,11 @@ import { ConnectionInput, resolveConnection, } from "talk-server/models/helpers/connection"; -import Query, { +import { createConnectionOrderVariants, createIndexFactory, -} from "talk-server/models/helpers/query"; +} from "talk-server/models/helpers/indexing"; +import Query from "talk-server/models/helpers/query"; import { TenantResource } from "talk-server/models/tenant"; function collection(mongo: Db) { diff --git a/src/core/server/models/comment/index.ts b/src/core/server/models/comment/index.ts index 227e71757..57c2cf23a 100644 --- a/src/core/server/models/comment/index.ts +++ b/src/core/server/models/comment/index.ts @@ -21,10 +21,11 @@ import { OrderedConnectionInput, resolveConnection, } from "talk-server/models/helpers/connection"; -import Query, { +import { createConnectionOrderVariants, createIndexFactory, -} from "talk-server/models/helpers/query"; +} from "talk-server/models/helpers/indexing"; +import Query from "talk-server/models/helpers/query"; import { TenantResource } from "talk-server/models/tenant"; import { CommentTag } from "./tag"; diff --git a/src/core/server/models/helpers/indexing.ts b/src/core/server/models/helpers/indexing.ts new file mode 100644 index 000000000..00ed59dae --- /dev/null +++ b/src/core/server/models/helpers/indexing.ts @@ -0,0 +1,76 @@ +import { merge } from "lodash"; +import { Collection, IndexOptions } from "mongodb"; + +import { Writeable } from "talk-common/types"; +import logger from "talk-server/logger"; + +type IndexType = 1 | -1 | "text"; + +export type IndexSpecification = { + [P in keyof Writeable>]: IndexType +} & + Record; + +type IndexCreationFunction = ( + indexSpec: IndexSpecification, + indexOptions?: IndexOptions +) => Promise; + +export function createIndexFactory( + collection: Collection +): IndexCreationFunction { + const log = logger.child({ + collectionName: collection.collectionName, + }); + + return async ( + indexSpec: IndexSpecification, + indexOptions: IndexOptions = {} + ) => { + try { + // Try to create the index. + const indexName = await collection.createIndex(indexSpec, indexOptions); + log.debug({ indexName, indexSpec, indexOptions }, "index was created"); + + // Match the interface from the `createIndex` function by returning the + // index name. + return indexName; + } catch (err) { + log.error({ err, indexSpec, indexOptions }, "could not create index"); + + // Rethrow the error here. + throw err; + } + }; +} + +export function createConnectionOrderVariants( + variants: Array>, + indexOptions: IndexOptions = {} +) { + return async ( + createIndex: IndexCreationFunction, + indexSpec: IndexSpecification, + variantIndexOptions: IndexOptions = {} + ) => { + /** + * createIndexVariant will create a variant on the specified `indexSpec` that + * will include the new variation. + * + * @param variantSpec the spec that makes this variant different + */ + const createIndexVariant = (variantSpec: IndexSpecification) => + createIndex( + merge({}, indexSpec, variantSpec), + merge({}, indexOptions, variantIndexOptions) + ); + + // Create a raw index without the variants applied. + await createIndex(indexSpec, merge({}, indexOptions, variantIndexOptions)); + + // Create all the variants. + for (const variant of variants) { + await createIndexVariant(variant); + } + }; +} diff --git a/src/core/server/models/helpers/query.ts b/src/core/server/models/helpers/query.ts index 94f46ff17..504eabc5d 100644 --- a/src/core/server/models/helpers/query.ts +++ b/src/core/server/models/helpers/query.ts @@ -1,11 +1,6 @@ import { isUndefined, merge, omitBy } from "lodash"; -import { - Collection, - Cursor, - FilterQuery as MongoFilterQuery, - IndexOptions, -} from "mongodb"; +import { Collection, Cursor, FilterQuery as MongoFilterQuery } from "mongodb"; import { Writeable } from "talk-common/types"; import logger from "talk-server/logger"; @@ -108,69 +103,3 @@ export default class Query { return cursor; } } - -type IndexType = 1 | -1; - -export type IndexSpecification = { - [P in keyof Writeable>]: IndexType -} & - Record; - -type IndexCreationFunction = ( - indexSpec: IndexSpecification, - indexOptions?: IndexOptions -) => Promise; - -export function createIndexFactory( - collection: Collection -): IndexCreationFunction { - const log = logger.child({ - collectionName: collection.collectionName, - }); - - return async ( - indexSpec: IndexSpecification, - indexOptions: IndexOptions = {} - ) => { - try { - // Try to create the index. - const indexName = await collection.createIndex(indexSpec, indexOptions); - log.debug({ indexName, indexSpec, indexOptions }, "index was created"); - - // Match the interface from the `createIndex` function by returning the - // index name. - return indexName; - } catch (err) { - log.error({ err, indexSpec, indexOptions }, "could not create index"); - - // Rethrow the error here. - throw err; - } - }; -} - -export function createConnectionOrderVariants( - variants: Array> -) { - return async ( - createIndex: IndexCreationFunction, - indexSpec: IndexSpecification - ) => { - /** - * createIndexVariant will create a variant on the specified `indexSpec` that - * will include the new variation. - * - * @param variantSpec the spec that makes this variant different - */ - const createIndexVariant = (variantSpec: IndexSpecification) => - createIndex(merge({}, indexSpec, variantSpec)); - - // Create a raw index without the variants applied. - await createIndex(indexSpec); - - // Create all the variants. - for (const variant of variants) { - await createIndexVariant(variant); - } - }; -} diff --git a/src/core/server/models/story/counts/index.ts b/src/core/server/models/story/counts/index.ts index 730b268f0..eefa33a04 100644 --- a/src/core/server/models/story/counts/index.ts +++ b/src/core/server/models/story/counts/index.ts @@ -9,7 +9,7 @@ import { dotize } from "talk-common/utils/dotize"; import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types"; import logger from "talk-server/logger"; import { EncodedCommentActionCounts } from "talk-server/models/action/comment"; -import { createIndexFactory } from "talk-server/models/helpers/query"; +import { createIndexFactory } from "talk-server/models/helpers/indexing"; import { retrieveStory, Story } from "talk-server/models/story"; import { AugmentedRedis } from "talk-server/services/redis"; @@ -28,7 +28,7 @@ export async function createStoryCountIndexes(mongo: Db) { const createIndex = createIndexFactory(collection(mongo)); // { createdAt } - await createIndex({ tenantID: 1, createdAt: 1 }); + await createIndex({ tenantID: 1, createdAt: 1 }, { background: true }); } // TODO: (wyattjoh) write a test to verify that this set of counts is always in sync with GQLCOMMENT_STATUS. diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index 4993a1108..ab6ef0a33 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -9,17 +9,18 @@ import { GQLStoryMetadata, GQLStorySettings, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { TenantResource } from "talk-server/models/tenant"; - import { Connection, ConnectionInput, resolveConnection, -} from "../helpers/connection"; -import Query, { +} from "talk-server/models/helpers/connection"; +import { createConnectionOrderVariants, createIndexFactory, -} from "../helpers/query"; +} from "talk-server/models/helpers/indexing"; +import Query from "talk-server/models/helpers/query"; +import { TenantResource } from "talk-server/models/tenant"; + import { createEmptyCommentModerationQueueCounts, createEmptyCommentStatusCounts, @@ -87,11 +88,24 @@ export async function createStoryIndexes(mongo: Db) { // UNIQUE { url } await createIndex({ tenantID: 1, url: 1 }, { unique: true }); - const variants = createConnectionOrderVariants>([ - { createdAt: -1 }, - ]); + // TEXT { $**, createdAt } + await createIndex( + { tenantID: 1, "$**": "text", createdAt: -1 }, + { background: true } + ); - // Story based Comment Connection pagination. + const variants = createConnectionOrderVariants>( + [{ createdAt: -1 }], + { background: true } + ); + + // Story Connection pagination. + // { ...connectionParams } + await variants(createIndex, { + tenantID: 1, + }); + + // Closed At ordered Story Connection pagination. // { closedAt, ...connectionParams } await variants(createIndex, { tenantID: 1, diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index c51bf0d02..f11a7b7da 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -9,7 +9,7 @@ import { GQLMODERATION_MODE, GQLSettings, } from "talk-server/graph/tenant/schema/__generated__/types"; -import { createIndexFactory } from "talk-server/models/helpers/query"; +import { createIndexFactory } from "talk-server/models/helpers/indexing"; import { Settings } from "talk-server/models/settings"; function collection(mongo: Db) { diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 38fea2f14..af436cef3 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -13,11 +13,11 @@ import { UserNotFoundError, } from "talk-server/errors"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; -import Query, { +import { createConnectionOrderVariants, createIndexFactory, - FilterQuery, -} from "talk-server/models/helpers/query"; +} from "talk-server/models/helpers/indexing"; +import Query, { FilterQuery } from "talk-server/models/helpers/query"; import { TenantResource } from "talk-server/models/tenant"; import { Connection, @@ -97,14 +97,39 @@ export async function createUserIndexes(mongo: Db) { // UNIQUE { profiles.type, profiles.id } await createIndex( { tenantID: 1, "profiles.type": 1, "profiles.id": 1 }, - { unique: true } + { + unique: true, + // We're filtering by the first entry in the profiles array to ensure we + // only enforce uniqueness when the profiles array has at least a single + // profile. + partialFilterExpression: { "profiles.0": { $exists: true } }, + } ); - const variants = createConnectionOrderVariants>([ - { createdAt: -1 }, - ]); + // TEXT { id, username, email, createdAt } + await createIndex( + { + tenantID: 1, + id: "text", + username: "text", + email: "text", + createdAt: -1, + }, + { background: true } + ); - // Story based Comment Connection pagination. + const variants = createConnectionOrderVariants>( + [{ createdAt: -1 }], + { background: true } + ); + + // User Connection pagination. + // { ...connectionParams } + await variants(createIndex, { + tenantID: 1, + }); + + // Role based User Connection pagination. // { role, ...connectionParams } await variants(createIndex, { tenantID: 1, diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index a6bfd1905..ae7557147 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -373,6 +373,8 @@ community-emptyMessage = We could not find anyone in your community matching you community-filter-searchField = .placeholder = Search by username or email address... .aria-label = Search by username or email address +community-filter-searchButton = + .aria-label = Search community-filter-roleSelectField = .aria-label = Search by role @@ -403,6 +405,8 @@ stories-noMatchMessage = We could not find any stories matching your criteria. stories-filter-searchField = .placeholder = Search by story title or author... .aria-label = Search by story title or author +stories-filter-searchButton = + .aria-label = Search stories-filter-statusSelectField = .aria-label = Search by status