[next] Search (#2251)

* feat: added text indexes, query param to edges

* fix: cleaned up, added createdAt index

* fix: improved indexing support

* feat: integrate search into community and stories

* feat: adorn with search button

* test: add tests
This commit is contained in:
Wyatt Johnson
2019-04-02 18:09:15 +02:00
committed by Kiwi
parent 5e90f028a9
commit 08e8e61e88
46 changed files with 1083 additions and 385 deletions
+6 -6
View File
@@ -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": "*"
+2 -2
View File
@@ -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",
@@ -5,4 +5,9 @@
.textField {
height: 31px;
width: calc(36 * var(--spacing-unit));
}
.adornment {
padding: 0 var(--spacing-unit);
}
@@ -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> = props => (
@@ -31,16 +36,50 @@ const UserTableFilter: StatelessComponent<Props> = props => (
Search
</Typography>
</Localized>
<Localized
id="community-filter-searchField"
attrs={{ placeholder: true, "aria-label": true }}
<Form
onSubmit={({ search }: { search: string }) =>
props.onSetSearchFilter(search)
}
>
<TextField
classes={{ input: styles.textField }}
placeholder="Search by username or email address..."
aria-label="Search by username or email address"
/>
</Localized>
{({ handleSubmit }) => (
<form autoComplete="off" onSubmit={handleSubmit} id="configure-form">
<Field name="search">
{({ input }) => (
<Localized
id="community-filter-searchField"
attrs={{ placeholder: true, "aria-label": true }}
>
<TextField
className={styles.textField}
placeholder="Search by username or email address..."
aria-label="Search by username or email address"
name={input.name}
onChange={input.onChange}
value={input.value}
variant="seamlessAdornment"
adornment={
<Localized
id="community-filter-searchButton"
attrs={{ "aria-label": true }}
>
<Button
className={styles.adornment}
variant="adornment"
type="submit"
color="dark"
aria-label="Search"
>
<Icon size="md">search</Icon>
</Button>
</Localized>
}
/>
</Localized>
)}
</Field>
</form>
)}
</Form>
</FieldSet>
<FieldSet>
<Localized id="community-filter-showMe">
@@ -59,7 +98,7 @@ const UserTableFilter: StatelessComponent<Props> = props => (
<SelectField
aria-label="Search by role"
value={props.roleFilter || ""}
onChange={e => props.onSetRoleFilter(e.target.value as any)}
onChange={e => props.onSetRoleFilter((e.target.value as any) || null)}
>
<Localized id="community-filter-everyone">
<Option value="">Everyone</Option>
@@ -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> = 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<string>("");
const [roleFilter, setRoleFilter] = useState<GQLUSER_ROLE_RL | null>(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 (
<IntersectionProvider>
<HorizontalGutter size="double">
<UserTableFilter
onSetRoleFilter={role => setRoleFilterAndRefetch(role || null)}
onSetRoleFilter={setRoleFilter}
roleFilter={roleFilter}
onSetSearchFilter={setSearchFilter}
searchFilter={searchFilter}
/>
<UserTable
viewer={props.query && props.query.viewer}
loading={!props.query || refetching}
loading={!props.query || isRefetching}
users={users}
onLoadMore={loadMore}
hasMore={!refetching && props.relay.hasMore()}
disableLoadMore={disableLoadMore}
hasMore={!isRefetching && props.relay.hasMore()}
disableLoadMore={isLoadingMore}
/>
</HorizontalGutter>
</IntersectionProvider>
);
};
// 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
)
}
`,
}
@@ -62,11 +62,8 @@ export class QueueContainer extends React.Component<QueueContainerProps> {
};
}
// 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,
@@ -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,
@@ -5,4 +5,9 @@
.textField {
height: 31px;
width: calc(36 * var(--spacing-unit));
}
.adornment {
padding: 0 var(--spacing-unit);
}
@@ -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> = props => (
@@ -30,16 +35,50 @@ const StoryTableFilter: StatelessComponent<Props> = props => (
Search
</Typography>
</Localized>
<Localized
id="stories-filter-searchField"
attrs={{ placeholder: true, "aria-label": true }}
<Form
onSubmit={({ search }: { search: string }) =>
props.onSetSearchFilter(search)
}
>
<TextField
classes={{ input: styles.textField }}
placeholder="Search by story title or author..."
aria-label="Search by story title or author"
/>
</Localized>
{({ handleSubmit }) => (
<form autoComplete="off" onSubmit={handleSubmit} id="configure-form">
<Field name="search">
{({ input }) => (
<Localized
id="stories-filter-searchField"
attrs={{ placeholder: true, "aria-label": true }}
>
<TextField
className={styles.textField}
placeholder="Search by story title or author..."
aria-label="Search by story title or author"
name={input.name}
onChange={input.onChange}
value={input.value}
variant="seamlessAdornment"
adornment={
<Localized
id="stories-filter-searchButton"
attrs={{ "aria-label": true }}
>
<Button
className={styles.adornment}
variant="adornment"
type="submit"
color="dark"
aria-label="Search"
>
<Icon size="md">search</Icon>
</Button>
</Localized>
}
/>
</Localized>
)}
</Field>
</form>
)}
</Form>
</FieldSet>
<FieldSet>
<Localized id="stories-filter-showMe">
@@ -58,7 +97,9 @@ const StoryTableFilter: StatelessComponent<Props> = props => (
<SelectField
aria-label="Search by status"
value={props.statusFilter || ""}
onChange={e => props.onSetStatusFilter(e.target.value as any)}
onChange={e =>
props.onSetStatusFilter((e.target.value as any) || null)
}
>
<Localized id="stories-filter-allStories">
<Option value="">All Stories</Option>
@@ -18,7 +18,7 @@ interface Props {
}
const StatusChangeContainer: StatelessComponent<Props> = 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 => {
[props.storyID, props.closeStory, props.openStory, props.status]
);
return (
<StatusChange onChangeStatus={hanldeChangeStatus} status={props.status} />
<StatusChange onChangeStatus={handleChangeStatus} status={props.status} />
);
};
@@ -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> = 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<string>("");
const [statusFilter, setStatusFilter] = useState<GQLSTORY_STATUS_RL | null>(
null
);
const setStatusFilterAndRefetch = useCallback(
(status: GQLSTORY_STATUS_RL | null) => {
setStatusFilter(status);
setRefetching(true);
props.relay.refetchConnection(
10,
error => {
setRefetching(false);
if (error) {
// tslint:disable-next-line:no-console
console.error(error);
}
},
{
statusFilter: status,
}
);
},
[statusFilter, props.relay]
);
const loadMore = useCallback(
() => {
if (!props.relay.hasMore() || props.relay.isLoading()) {
return;
}
setDisableLoadMore(true);
props.relay.loadMore(
10, // Fetch the next 10 feed items
error => {
setDisableLoadMore(false);
if (error) {
// tslint:disable-next-line:no-console
console.error(error);
}
}
);
},
[props.relay]
);
const [, isRefetching] = useRefetch<
Pick<
StoryTableContainerPaginationQueryVariables,
"searchFilter" | "statusFilter"
>
>(props.relay, {
searchFilter: searchFilter || null,
statusFilter,
});
return (
<IntersectionProvider>
<HorizontalGutter size="double">
<StoryTableFilter
onSetStatusFilter={status =>
setStatusFilterAndRefetch(status || null)
}
onSetStatusFilter={setStatusFilter}
statusFilter={statusFilter}
onSetSearchFilter={setSearchFilter}
searchFilter={searchFilter}
/>
<StoryTable
viewer={props.query && props.query.viewer}
loading={!props.query || refetching}
loading={!props.query || isRefetching}
stories={stories}
onLoadMore={loadMore}
hasMore={!refetching && props.relay.hasMore()}
disableLoadMore={disableLoadMore}
isSearching={Boolean(statusFilter)}
hasMore={!isRefetching && props.relay.hasMore()}
disableLoadMore={isLoadingMore}
isSearching={Boolean(statusFilter) || Boolean(searchFilter)}
/>
</HorizontalGutter>
</IntersectionProvider>
);
};
// 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
)
}
`,
@@ -19,16 +19,46 @@ exports[`renders community 1`] = `
>
Search
</legend>
<div
className="TextField-root"
<form
autoComplete="off"
id="configure-form"
onSubmit={[Function]}
>
<input
aria-label="Search by username or email address"
className="TextField-input UserTableFilter-textField TextField-colorRegular"
placeholder="Search by username or email address..."
type="text"
/>
</div>
<div
className="TextField-root UserTableFilter-textField"
>
<input
aria-label="Search by username or email address"
className="TextField-input TextField-colorRegular TextField-seamlessAdornment"
name="search"
onChange={[Function]}
placeholder="Search by username or email address..."
type="text"
value=""
/>
<div
className="TextField-adornment"
>
<button
aria-label="Search"
className="BaseButton-root Button-root UserTableFilter-adornment Button-sizeRegular Button-colorDark Button-variantAdornment"
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
search
</span>
</button>
</div>
</div>
</form>
</fieldset>
<fieldset
className="FieldSet-root"
@@ -261,16 +291,46 @@ exports[`renders empty community 1`] = `
>
Search
</legend>
<div
className="TextField-root"
<form
autoComplete="off"
id="configure-form"
onSubmit={[Function]}
>
<input
aria-label="Search by username or email address"
className="TextField-input UserTableFilter-textField TextField-colorRegular"
placeholder="Search by username or email address..."
type="text"
/>
</div>
<div
className="TextField-root UserTableFilter-textField"
>
<input
aria-label="Search by username or email address"
className="TextField-input TextField-colorRegular TextField-seamlessAdornment"
name="search"
onChange={[Function]}
placeholder="Search by username or email address..."
type="text"
value=""
/>
<div
className="TextField-adornment"
>
<button
aria-label="Search"
className="BaseButton-root Button-root UserTableFilter-adornment Button-sizeRegular Button-colorDark Button-variantAdornment"
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
search
</span>
</button>
</div>
</div>
</form>
</fieldset>
<fieldset
className="FieldSet-root"
@@ -5,6 +5,7 @@ import sinon from "sinon";
import { GQLUSER_ROLE } from "talk-framework/schema";
import {
createSinonStub,
findParentWithType,
replaceHistoryLocation,
waitForElement,
waitUntilThrow,
@@ -189,3 +190,34 @@ it("load more", async () => {
// 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 })
);
});
@@ -410,11 +410,15 @@ Markdown can be found
type="text"
value={3}
/>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
<div
className="TextField-adornment"
>
Characters
</p>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Characters
</p>
</div>
</div>
</div>
<div
@@ -443,11 +447,15 @@ Markdown can be found
type="text"
value={1000}
/>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
<div
className="TextField-adornment"
>
Characters
</p>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Characters
</p>
</div>
</div>
</div>
</fieldset>
@@ -221,11 +221,15 @@ comment is toxic, according to Perspective API. By default the treshold is set t
type="text"
value=""
/>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
<div
className="TextField-adornment"
>
%
</p>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
%
</p>
</div>
</div>
</div>
<fieldset
@@ -19,16 +19,46 @@ exports[`renders empty stories 1`] = `
>
Search
</legend>
<div
className="TextField-root"
<form
autoComplete="off"
id="configure-form"
onSubmit={[Function]}
>
<input
aria-label="Search by story title or author"
className="TextField-input StoryTableFilter-textField TextField-colorRegular"
placeholder="Search by story title or author..."
type="text"
/>
</div>
<div
className="TextField-root StoryTableFilter-textField"
>
<input
aria-label="Search by story title or author"
className="TextField-input TextField-colorRegular TextField-seamlessAdornment"
name="search"
onChange={[Function]}
placeholder="Search by story title or author..."
type="text"
value=""
/>
<div
className="TextField-adornment"
>
<button
aria-label="Search"
className="BaseButton-root Button-root StoryTableFilter-adornment Button-sizeRegular Button-colorDark Button-variantAdornment"
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
search
</span>
</button>
</div>
</div>
</form>
</fieldset>
<fieldset
className="FieldSet-root"
@@ -268,16 +298,46 @@ exports[`renders stories 1`] = `
>
Search
</legend>
<div
className="TextField-root"
<form
autoComplete="off"
id="configure-form"
onSubmit={[Function]}
>
<input
aria-label="Search by story title or author"
className="TextField-input StoryTableFilter-textField TextField-colorRegular"
placeholder="Search by story title or author..."
type="text"
/>
</div>
<div
className="TextField-root StoryTableFilter-textField"
>
<input
aria-label="Search by story title or author"
className="TextField-input TextField-colorRegular TextField-seamlessAdornment"
name="search"
onChange={[Function]}
placeholder="Search by story title or author..."
type="text"
value=""
/>
<div
className="TextField-adornment"
>
<button
aria-label="Search"
className="BaseButton-root Button-root StoryTableFilter-adornment Button-sizeRegular Button-colorDark Button-variantAdornment"
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
search
</span>
</button>
</div>
</div>
</form>
</fieldset>
<fieldset
className="FieldSet-root"
@@ -4,6 +4,7 @@ import sinon from "sinon";
import {
createSinonStub,
findParentWithType,
replaceHistoryLocation,
waitForElement,
waitUntilThrow,
@@ -202,3 +203,35 @@ it("load more", async () => {
// 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 })
);
});
@@ -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,
+1
View File
@@ -0,0 +1 @@
export { default as useEffectAfterMount } from "./useEffectAfterMount";
@@ -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<any> | undefined
) {
const mountedRef = useRef<boolean>(false);
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
return effect();
}, deps);
}
@@ -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";
@@ -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];
}
@@ -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<V = Variables>(
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<any[]>((a, k) => {
a.push((variables as any)[k]);
return a;
}, []),
]
);
return [() => setManualRefetchCount(manualRefetchCount + 1), refetching];
}
@@ -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<Props> {
public state = {
disableShowAll: false,
@@ -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<Props> {
};
}
// 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,
@@ -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,
@@ -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);
}
}
}
}
+107 -33
View File
@@ -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
<Playground>
<Flex itemGutter wrap>
<Button>Push Me</Button>
@@ -20,57 +21,130 @@ import Flex from '../Flex'
<Button color="primary">Push Me</Button>
<Button color="error">Push Me</Button>
<Button color="success">Push Me</Button>
<Button color="dark">Push Me</Button>
<Button disabled>Push Me</Button>
<Button active>Push Me</Button>
<Button><ButtonIcon>face</ButtonIcon><span>Push Me</span></Button>
<Button>
<ButtonIcon>face</ButtonIcon>
<span>Push Me</span>
</Button>
<Button fullWidth>Push Me</Button>
</Flex>
</Playground>
## Filled Button
<Playground>
<Flex itemGutter wrap>
<Button variant="filled">Push Me</Button>
<Button variant="filled" size="small">Push Me</Button>
<Button variant="filled" size="large">Push Me</Button>
<Button variant="filled" color="primary">Push Me</Button>
<Button variant="filled" color="error">Push Me</Button>
<Button variant="filled" color="success">Push Me</Button>
<Button variant="filled" disabled>Push Me</Button>
<Button variant="filled" active>Push Me</Button>
<Button variant="filled"><ButtonIcon>face</ButtonIcon><span>Push Me</span></Button>
<Button variant="filled" fullWidth>Push Me</Button>
<Button variant="filled" size="small">
Push Me
</Button>
<Button variant="filled" size="large">
Push Me
</Button>
<Button variant="filled" color="primary">
Push Me
</Button>
<Button variant="filled" color="error">
Push Me
</Button>
<Button variant="filled" color="success">
Push Me
</Button>
<Button variant="filled" color="dark">
Push Me
</Button>
<Button variant="filled" disabled>
Push Me
</Button>
<Button variant="filled" active>
Push Me
</Button>
<Button variant="filled">
<ButtonIcon>face</ButtonIcon>
<span>Push Me</span>
</Button>
<Button variant="filled" fullWidth>
Push Me
</Button>
</Flex>
</Playground>
## Outlined Button
<Playground>
<Flex itemGutter wrap>
<Button variant="outlined">Push Me</Button>
<Button variant="outlined" size="small">Push Me</Button>
<Button variant="outlined" size="large">Push Me</Button>
<Button variant="outlined" color="primary">Push Me</Button>
<Button variant="outlined" color="error">Push Me</Button>
<Button variant="outlined" color="success">Push Me</Button>
<Button variant="outlined" disabled>Push Me</Button>
<Button variant="outlined" active>Push Me</Button>
<Button variant="outlined"><ButtonIcon>face</ButtonIcon><span>Push Me</span></Button>
<Button variant="outlined" fullWidth>Push Me</Button>
<Button variant="outlined" size="small">
Push Me
</Button>
<Button variant="outlined" size="large">
Push Me
</Button>
<Button variant="outlined" color="primary">
Push Me
</Button>
<Button variant="outlined" color="error">
Push Me
</Button>
<Button variant="outlined" color="success">
Push Me
</Button>
<Button variant="outlined" color="dark">
Push Me
</Button>
<Button variant="outlined" disabled>
Push Me
</Button>
<Button variant="outlined" active>
Push Me
</Button>
<Button variant="outlined">
<ButtonIcon>face</ButtonIcon>
<span>Push Me</span>
</Button>
<Button variant="outlined" fullWidth>
Push Me
</Button>
</Flex>
</Playground>
## Ghost Button
<Playground>
<Flex itemGutter wrap>
<Button variant="ghost">Push Me</Button>
<Button variant="ghost" size="small">Push Me</Button>
<Button variant="ghost" size="large">Push Me</Button>
<Button variant="ghost" color="primary">Push Me</Button>
<Button variant="ghost" color="error">Push Me</Button>
<Button variant="ghost" color="success">Push Me</Button>
<Button variant="ghost" disabled>Push Me</Button>
<Button variant="ghost" active>Push Me</Button>
<Button variant="ghost"><ButtonIcon>face</ButtonIcon><span>Push Me</span></Button>
<Button variant="ghost" fullWidth>Push Me</Button>
<Button variant="ghost" size="small">
Push Me
</Button>
<Button variant="ghost" size="large">
Push Me
</Button>
<Button variant="ghost" color="primary">
Push Me
</Button>
<Button variant="ghost" color="error">
Push Me
</Button>
<Button variant="ghost" color="success">
Push Me
</Button>
<Button variant="ghost" color="dark">
Push Me
</Button>
<Button variant="ghost" disabled>
Push Me
</Button>
<Button variant="ghost" active>
Push Me
</Button>
<Button variant="ghost">
<ButtonIcon>face</ButtonIcon>
<span>Push Me</span>
</Button>
<Button variant="ghost" fullWidth>
Push Me
</Button>
</Flex>
</Playground>
@@ -24,10 +24,23 @@ interface Props extends Omit<BaseButtonProps, "ref"> {
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<Props> {
[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,
@@ -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;
}
}
@@ -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";
<Playground>
<HorizontalGutter>
<TextField placeholder="This is a placeholder" />
<Flex>
<TextField
placeholder="This is a placeholder"
variant="seamlessAdornment"
adornment={
<Button variant="adornment" color="dark">
<Icon size="md">search</Icon>
</Button>
}
/>
</Flex>
<TextField defaultValue="This is an input field" />
<TextField color="error" defaultValue="A TextField with an error" />
<TextField
@@ -62,6 +62,8 @@ export interface TextFieldProps {
textAlignCenter?: boolean;
adornment?: React.ReactNode;
variant?: "regular" | "seamlessAdornment";
}
const TextField: StatelessComponent<TextFieldProps> = props => {
@@ -74,6 +76,7 @@ const TextField: StatelessComponent<TextFieldProps> = props => {
placeholder,
adornment,
textAlignCenter,
variant,
...rest
} = props;
@@ -89,6 +92,7 @@ const TextField: StatelessComponent<TextFieldProps> = props => {
[classes.colorRegular]: color === "regular",
[classes.colorError]: color === "error",
[classes.textAlignCenter]: textAlignCenter,
[classes.seamlessAdornment]: variant === "seamlessAdornment",
});
return (
@@ -99,7 +103,7 @@ const TextField: StatelessComponent<TextFieldProps> = props => {
value={value}
{...rest}
/>
{adornment}
{adornment && <div className={styles.adornment}>{adornment}</div>}
</div>
);
};
@@ -10,6 +10,10 @@ exports[`renders correctly 1`] = `
placeholder=""
type="text"
/>
Unit
<div
className="TextField-adornment"
>
Unit
</div>
</div>
`;
@@ -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<string, Story | null>(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<string, GQLStoryMetadata | null>(urls =>
+29 -4
View File
@@ -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)),
};
};
@@ -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
+6 -5
View File
@@ -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 = [
@@ -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) {
+3 -2
View File
@@ -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";
@@ -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<T> = {
[P in keyof Writeable<Partial<T>>]: IndexType
} &
Record<string, IndexType>;
type IndexCreationFunction<T> = (
indexSpec: IndexSpecification<T>,
indexOptions?: IndexOptions
) => Promise<string>;
export function createIndexFactory<T>(
collection: Collection<T>
): IndexCreationFunction<T> {
const log = logger.child({
collectionName: collection.collectionName,
});
return async (
indexSpec: IndexSpecification<T>,
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<T>(
variants: Array<IndexSpecification<T>>,
indexOptions: IndexOptions = {}
) {
return async (
createIndex: IndexCreationFunction<T>,
indexSpec: IndexSpecification<T>,
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<T>) =>
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);
}
};
}
+1 -72
View File
@@ -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<T> {
return cursor;
}
}
type IndexType = 1 | -1;
export type IndexSpecification<T> = {
[P in keyof Writeable<Partial<T>>]: IndexType
} &
Record<string, IndexType>;
type IndexCreationFunction<T> = (
indexSpec: IndexSpecification<T>,
indexOptions?: IndexOptions
) => Promise<string>;
export function createIndexFactory<T>(
collection: Collection<T>
): IndexCreationFunction<T> {
const log = logger.child({
collectionName: collection.collectionName,
});
return async (
indexSpec: IndexSpecification<T>,
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<T>(
variants: Array<IndexSpecification<T>>
) {
return async (
createIndex: IndexCreationFunction<T>,
indexSpec: IndexSpecification<T>
) => {
/**
* 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<T>) =>
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);
}
};
}
+2 -2
View File
@@ -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.
+23 -9
View File
@@ -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<Readonly<Story>>([
{ createdAt: -1 },
]);
// TEXT { $**, createdAt }
await createIndex(
{ tenantID: 1, "$**": "text", createdAt: -1 },
{ background: true }
);
// Story based Comment Connection pagination.
const variants = createConnectionOrderVariants<Readonly<Story>>(
[{ 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,
+1 -1
View File
@@ -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) {
+33 -8
View File
@@ -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<Readonly<User>>([
{ 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<Readonly<User>>(
[{ 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,
+4
View File
@@ -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