mirror of
https://github.com/wassname/talk.git
synced 2026-08-11 11:27:10 +08:00
[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:
Generated
+6
-6
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user