refactor(core): adjust graphql hook (#5339)

This commit is contained in:
EYHN
2023-12-28 07:43:25 +00:00
parent 7e75e19d04
commit 0b9cd00fd3
28 changed files with 137 additions and 119 deletions

View File

@@ -13,11 +13,11 @@ import {
sendSetPasswordEmailMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useSetAtom } from 'jotai/react';
import { useCallback, useState } from 'react';
import { useMutation } from '../../../hooks/use-mutation';
import type { AuthPanelProps } from './index';
const useEmailTitle = (emailType: AuthPanelProps['emailType']) => {

View File

@@ -7,7 +7,6 @@ import { Button } from '@affine/component/ui/button';
import { type GetUserQuery, getUserQuery } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { GraphQLError } from 'graphql';
@@ -15,6 +14,7 @@ import { type FC, useState } from 'react';
import { useCallback } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { useMutation } from '../../../hooks/use-mutation';
import { emailRegex } from '../../../utils/email-regex';
import type { AuthPanelProps } from './index';
import * as style from './style.css';

View File

@@ -9,16 +9,17 @@ import {
subscriptionQuery,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { nanoid } from 'nanoid';
import { Suspense, useCallback, useEffect, useMemo } from 'react';
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
import { useMutation } from '../../../hooks/use-mutation';
import {
RouteLogic,
useNavigateHelper,
} from '../../../hooks/use-navigate-helper';
import { useQuery } from '../../../hooks/use-query';
import * as styles from './subscription-redirect.css';
import { useSubscriptionSearch } from './use-subscription';

View File

@@ -9,11 +9,6 @@ import {
createAffineCloudBlobStorage,
globalBlockSuiteSchema,
} from '@affine/workspace';
import {
useMutateQueryResource,
useMutation,
useQueryInfinite,
} from '@affine/workspace/affine/gql';
import { assertEquals } from '@blocksuite/global/utils';
import { Workspace } from '@blocksuite/store';
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
@@ -23,6 +18,12 @@ import { useMemo } from 'react';
import useSWRImmutable from 'swr/immutable';
import { applyUpdate } from 'yjs';
import {
useMutateQueryResource,
useMutation,
} from '../../../hooks/use-mutation';
import { useQueryInfinite } from '../../../hooks/use-query';
const logger = new DebugLogger('page-history');
type DocHistory = ListHistoryQuery['workspace']['histories'][number];

View File

@@ -14,7 +14,6 @@ import {
uploadAvatarMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import bytes from 'bytes';
@@ -35,6 +34,8 @@ import {
} from '../../../../atoms';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { useSelfHosted } from '../../../../hooks/affine/use-server-config';
import { useMutation } from '../../../../hooks/use-mutation';
import { useQuery } from '../../../../hooks/use-query';
import { useUserSubscription } from '../../../../hooks/use-subscription';
import { validateAndReduceImage } from '../../../../utils/reduce-image';
import { Upload } from '../../../pure/file-upload';

View File

@@ -20,7 +20,6 @@ import {
} from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useSetAtom } from 'jotai';
@@ -28,6 +27,8 @@ import { Suspense, useCallback, useMemo, useState } from 'react';
import { openSettingModalAtom } from '../../../../../atoms';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { useMutation } from '../../../../../hooks/use-mutation';
import { useQuery } from '../../../../../hooks/use-query';
import {
type SubscriptionMutator,
useUserSubscription,

View File

@@ -3,12 +3,12 @@ import {
cancelSubscriptionMutation,
resumeSubscriptionMutation,
} from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { nanoid } from 'nanoid';
import type { PropsWithChildren } from 'react';
import { useState } from 'react';
import { useMutation } from '../../../../../hooks/use-mutation';
import { ConfirmLoadingModal, DowngradeModal } from './modals';
/**

View File

@@ -7,13 +7,13 @@ import {
} from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useQuery } from '@affine/workspace/affine/gql';
import { useSetAtom } from 'jotai';
import { Suspense, useEffect, useRef, useState } from 'react';
import type { FallbackProps } from 'react-error-boundary';
import { SWRErrorBoundary } from '../../../../../components/pure/swr-error-bundary';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { useQuery } from '../../../../../hooks/use-query';
import { useUserSubscription } from '../../../../../hooks/use-subscription';
import { PlanLayout } from './layout';
import { type FixedPrice, getPlanDetail, PlanCard } from './plan-card';

View File

@@ -13,7 +13,6 @@ import {
} from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { DoneIcon } from '@blocksuite/icons';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useSetAtom } from 'jotai';
@@ -31,6 +30,7 @@ import {
import { openPaymentDisableAtom } from '../../../../../atoms';
import { authAtom } from '../../../../../atoms/index';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { useMutation } from '../../../../../hooks/use-mutation';
import { CancelAction, ResumeAction } from './actions';
import { BulledListIcon } from './icons/bulled-list';
import { ConfirmLoadingModal } from './modals';

View File

@@ -0,0 +1,146 @@
/**
* @vitest-environment happy-dom
*/
import { uploadAvatarMutation } from '@affine/graphql';
import { render } from '@testing-library/react';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMutation } from '../use-mutation';
import { useQuery } from '../use-query';
let fetch: Mock;
describe('GraphQL wrapper for SWR', () => {
beforeEach(() => {
fetch = vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ data: { hello: 1 } }), {
headers: {
'content-type': 'application/json',
},
})
)
);
vi.stubGlobal('fetch', fetch);
});
afterEach(() => {
fetch.mockReset();
});
describe('useQuery', () => {
const Component = ({ id }: { id: number }) => {
const { data, isLoading, error } = useQuery({
query: {
id: 'query',
query: `
query {
hello
}
`,
operationName: 'query',
definitionName: 'query',
},
// @ts-expect-error forgive the fake variables
variables: { id },
});
if (isLoading) {
return <div>loading</div>;
}
if (error) {
return <div>error</div>;
}
// @ts-expect-error
return <div>number: {data!.hello}</div>;
};
it('should send query correctly', async () => {
const component = <Component id={1} />;
const renderer = render(component);
const el = await renderer.findByText('number: 1');
expect(el).toMatchInlineSnapshot(`
<div>
number:${' '}
1
</div>
`);
});
it('should not send request if cache hit', async () => {
const component = <Component id={2} />;
const renderer = render(component);
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(fetch).toBeCalledTimes(1);
render(<Component id={3} />);
expect(fetch).toBeCalledTimes(2);
});
});
describe('useMutation', () => {
const Component = () => {
const { trigger, error, isMutating } = useMutation({
mutation: {
id: 'mutation',
query: `
mutation {
hello
}
`,
operationName: 'mutation',
definitionName: 'mutation',
},
});
if (isMutating) {
return <div>mutating</div>;
}
if (error) {
return <div>error</div>;
}
return (
<div>
<button onClick={() => trigger()}>click</button>
</div>
);
};
it('should trigger mutation', async () => {
const component = <Component />;
const renderer = render(component);
const button = await renderer.findByText('click');
button.click();
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(renderer.asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div>
mutating
</div>
</DocumentFragment>
`);
});
it('should get rid of generated types', async () => {
function _NotActuallyRunDefinedForTypeTesting() {
const { trigger } = useMutation({
mutation: uploadAvatarMutation,
});
trigger({
avatar: new File([''], 'avatar.png'),
});
}
expect(_NotActuallyRunDefinedForTypeTesting).toBeTypeOf('function');
});
});
});

View File

@@ -1,8 +1,8 @@
import type { Permission } from '@affine/graphql';
import { inviteByEmailMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutation } from '../use-mutation';
import { useMutateCloud } from './use-mutate-cloud';
export function useInviteMember(workspaceId: string) {

View File

@@ -6,11 +6,12 @@ import {
revokePublicPageMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { useSetAtom } from 'jotai';
import { useCallback, useMemo } from 'react';
import type { PageMode } from '../../atoms';
import { useMutation } from '../use-mutation';
import { useQuery } from '../use-query';
type NoParametersKeys<T> = {
[K in keyof T]: T[K] extends () => any ? K : never;

View File

@@ -1,8 +1,9 @@
import { WorkspaceFlavour } from '@affine/env/workspace';
import { getIsOwnerQuery } from '@affine/graphql';
import { useQueryImmutable } from '@affine/workspace/affine/gql';
import type { WorkspaceMetadata } from '@affine/workspace/metadata';
import { useQueryImmutable } from '../use-query';
export function useIsWorkspaceOwner(workspaceMetadata: WorkspaceMetadata) {
const { data } = useQueryImmutable(
workspaceMetadata.flavour !== WorkspaceFlavour.LOCAL

View File

@@ -1,5 +1,6 @@
import { getMemberCountByWorkspaceIdQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
import { useQuery } from '../use-query';
export function useMemberCount(workspaceId: string) {
const { data } = useQuery({

View File

@@ -2,7 +2,8 @@ import {
type GetMembersByWorkspaceIdQuery,
getMembersByWorkspaceIdQuery,
} from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
import { useQuery } from '../use-query';
export type Member = Omit<
GetMembersByWorkspaceIdQuery['workspace']['members'][number],

View File

@@ -1,7 +1,7 @@
import { revokeMemberPermissionMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutation } from '../use-mutation';
import { useMutateCloud } from './use-mutate-cloud';
export function useRevokeMemberPermission(workspaceId: string) {

View File

@@ -1,7 +1,8 @@
import { serverConfigQuery } from '@affine/graphql';
import { useQueryImmutable } from '@affine/workspace/affine/gql';
import type { BareFetcher, Middleware } from 'swr';
import { useQueryImmutable } from '../use-query';
const wrappedFetcher = (fetcher: BareFetcher<any> | null, ...args: any[]) =>
fetcher?.(...args).catch(() => null);

View File

@@ -1,7 +1,7 @@
import { setWorkspacePublicByIdMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutation } from '../use-mutation';
import { useMutateCloud } from './use-mutate-cloud';
export function useToggleCloudPublic(workspaceId: string) {

View File

@@ -0,0 +1,89 @@
import type {
GraphQLQuery,
MutationOptions,
QueryResponse,
QueryVariables,
RecursiveMaybeFields,
} from '@affine/graphql';
import { fetcher } from '@affine/graphql';
import type { GraphQLError } from 'graphql';
import { useMemo } from 'react';
import type { Key } from 'swr';
import { useSWRConfig } from 'swr';
import type {
SWRMutationConfiguration,
SWRMutationResponse,
} from 'swr/mutation';
import useSWRMutation from 'swr/mutation';
/**
* A useSWRMutation wrapper for sending graphql mutations
*
* @example
*
* ```ts
* import { someMutation } from '@affine/graphql'
*
* const { trigger } = useMutation({
* mutation: someMutation,
* })
*
* trigger({ name: 'John Doe' })
*/
export function useMutation<Mutation extends GraphQLQuery, K extends Key = Key>(
options: Omit<MutationOptions<Mutation>, 'variables'>,
config?: Omit<
SWRMutationConfiguration<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
K,
QueryVariables<Mutation>
>,
'fetcher'
>
): SWRMutationResponse<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
K,
QueryVariables<Mutation>
>;
export function useMutation(
options: Omit<MutationOptions<GraphQLQuery>, 'variables'>,
config?: any
) {
return useSWRMutation(
() => ['cloud', options.mutation.id],
(_: unknown[], { arg }: { arg: any }) =>
fetcher({ ...options, query: options.mutation, variables: arg }),
config
);
}
// use this to revalidate all queries that match the filter
export const useMutateQueryResource = () => {
const { mutate } = useSWRConfig();
const revalidateResource = useMemo(
() =>
<Q extends GraphQLQuery>(
query: Q,
varsFilter: (
vars: RecursiveMaybeFields<QueryVariables<Q>>
) => boolean = _vars => true
) => {
return mutate(key => {
const res =
Array.isArray(key) &&
key[0] === 'cloud' &&
key[1] === query.id &&
varsFilter(key[2]);
if (res) {
console.debug('revalidate resource', key);
}
return res;
});
},
[mutate]
);
return revalidateResource;
};

View File

@@ -0,0 +1,129 @@
import type {
GraphQLQuery,
QueryOptions,
QueryResponse,
} from '@affine/graphql';
import { fetcher } from '@affine/graphql';
import type { GraphQLError } from 'graphql';
import { useCallback, useMemo } from 'react';
import type { SWRConfiguration, SWRResponse } from 'swr';
import useSWR from 'swr';
import useSWRImutable from 'swr/immutable';
import useSWRInfinite from 'swr/infinite';
/**
* A `useSWR` wrapper for sending graphql queries
*
* @example
*
* ```ts
* import { someQuery, someQueryWithNoVars } from '@affine/graphql'
*
* const swrResponse1 = useQuery({
* query: workspaceByIdQuery,
* variables: { id: '1' }
* })
*
* const swrResponse2 = useQuery({
* query: someQueryWithNoVars
* })
* ```
*/
type useQueryFn = <Query extends GraphQLQuery>(
options?: QueryOptions<Query>,
config?: Omit<
SWRConfiguration<
QueryResponse<Query>,
GraphQLError | GraphQLError[],
typeof fetcher<Query>
>,
'fetcher'
>
) => SWRResponse<
QueryResponse<Query>,
GraphQLError | GraphQLError[],
{
suspense: true;
}
>;
const createUseQuery =
(immutable: boolean): useQueryFn =>
(options, config) => {
const configWithSuspense: SWRConfiguration = useMemo(
() => ({
suspense: true,
...config,
}),
[config]
);
const useSWRFn = immutable ? useSWRImutable : useSWR;
return useSWRFn(
options ? () => ['cloud', options.query.id, options.variables] : null,
options ? () => fetcher(options) : null,
configWithSuspense
);
};
export const useQuery = createUseQuery(false);
export const useQueryImmutable = createUseQuery(true);
export function useQueryInfinite<Query extends GraphQLQuery>(
options: Omit<QueryOptions<Query>, 'variables'> & {
getVariables: (
pageIndex: number,
previousPageData: QueryResponse<Query>
) => QueryOptions<Query>['variables'];
},
config?: Omit<
SWRConfiguration<
QueryResponse<Query>,
GraphQLError | GraphQLError[],
typeof fetcher<Query>
>,
'fetcher'
>
) {
const configWithSuspense: SWRConfiguration = useMemo(
() => ({
suspense: true,
...config,
}),
[config]
);
const { data, setSize, size, error } = useSWRInfinite<
QueryResponse<Query>,
GraphQLError | GraphQLError[]
>(
(pageIndex: number, previousPageData: QueryResponse<Query>) => [
'cloud',
options.query.id,
options.getVariables(pageIndex, previousPageData),
],
async ([_, __, variables]) => {
const params = { ...options, variables } as QueryOptions<Query>;
return fetcher(params);
},
configWithSuspense
);
const loadingMore = size > 0 && data && !data[size - 1];
// todo: find a generic way to know whether or not there are more items to load
const loadMore = useCallback(() => {
if (loadingMore) {
return;
}
setSize(size => size + 1).catch(err => {
console.error(err);
});
}, [loadingMore, setSize]);
return {
data,
error,
loadingMore,
loadMore,
};
}

View File

@@ -1,5 +1,6 @@
import { quotaQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
import { useQuery } from './use-query';
export const useUserQuota = () => {
const { data } = useQuery({

View File

@@ -1,8 +1,8 @@
import { type SubscriptionQuery, subscriptionQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useSelfHosted } from './affine/use-server-config';
import { useQuery } from './use-query';
export type Subscription = NonNullable<
NonNullable<SubscriptionQuery['currentUser']>['subscription']

View File

@@ -13,8 +13,8 @@ import {
changePasswordMutation,
sendVerifyChangeEmailMutation,
} from '@affine/graphql';
import { fetcher } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { fetcher, useMutation } from '@affine/workspace/affine/gql';
import { useSetAtom } from 'jotai/react';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
@@ -29,6 +29,7 @@ import { z } from 'zod';
import { SubscriptionRedirect } from '../components/affine/auth/subscription-redirect';
import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status';
import { useCurrentUser } from '../hooks/affine/use-current-user';
import { useMutation } from '../hooks/use-mutation';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
const authTypeSchema = z.enum([

View File

@@ -5,7 +5,7 @@ import {
type GetInviteInfoQuery,
getInviteInfoQuery,
} from '@affine/graphql';
import { fetcher } from '@affine/workspace/affine/gql';
import { fetcher } from '@affine/graphql';
import { useSetAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import { type LoaderFunction, redirect, useLoaderData } from 'react-router-dom';

View File

@@ -1,8 +1,8 @@
import { Button } from '@affine/component/ui/button';
import { type GetCurrentUserQuery, getCurrentUserQuery } from '@affine/graphql';
import { fetcher } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { fetcher } from '@affine/workspace/affine/gql';
import { Logo1Icon } from '@blocksuite/icons';
import { useCallback, useMemo } from 'react';
import {