Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@

2.1.1 (Dan Reynolds)

Expose `fragmentName` option for `useFragment` and `useFragmentWhere`.

2.1.0 (Dan Reynolds)

* Switch from using lodash to lodash-es to reduce bundle size
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nerdwallet/apollo-cache-policies",
"version": "2.1.0",
"version": "2.1.1",
"description": "An extension to the InMemoryCache from Apollo that adds additional cache policies.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
9 changes: 6 additions & 3 deletions src/client/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { DocumentNode } from 'graphql';
import { FragmentWhereFilter } from '../cache/types';

export type WatchFragmentOptions = {
export type FragmentOptions = {
fragment: DocumentNode,
fragmentName?: string;
}

export type WatchFragmentOptions = FragmentOptions & {
id: string;
}

export type WatchFragmentWhereOptions<FragmentType> = {
fragment: DocumentNode;
export type WatchFragmentWhereOptions<FragmentType> = FragmentOptions & {
filter?: FragmentWhereFilter<FragmentType>;
}
41 changes: 31 additions & 10 deletions src/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import { makeReference } from '@apollo/client/core';

function _generateQueryFromFragment({
fieldName,
fragmentDefinition,
watchDefinition,
definitions,
}: {
fieldName: string;
fragmentDefinition: FragmentDefinitionNode;
definitions: FragmentDefinitionNode[];
watchDefinition: FragmentDefinitionNode;
}): DocumentNode {
return {
kind: 'Document',
definitions: [
...definitions,
{
directives: [],
variableDefinitions: [],
Expand All @@ -33,7 +36,7 @@ function _generateQueryFromFragment({
name: { kind: "Name", value: "client" },
},
],
selectionSet: fragmentDefinition.selectionSet,
selectionSet: watchDefinition.selectionSet,
},
]
},
Expand All @@ -42,6 +45,20 @@ function _generateQueryFromFragment({
};
}

function getWatchDefinition(definitions: FragmentDefinitionNode[], fragmentName?: string): FragmentDefinitionNode {
if (!fragmentName) {
return definitions[0];
}

const fragmentDefinitionByName = definitions.find((def) => def.name.value === fragmentName);

if (!fragmentDefinitionByName) {
throw `No fragment with name: ${fragmentName}`;
}

return fragmentDefinitionByName;
}

// Returns a query that can be used to watch a normalized cache entity by converting the fragment to a query
// and dynamically adding a type policy that returns the entity.
export function buildWatchFragmentQuery(
Expand All @@ -50,11 +67,13 @@ export function buildWatchFragmentQuery(
policies: Policies,
}
): DocumentNode {
const { fragment, id, policies, fieldName } = options;
const fragmentDefinition = fragment.definitions[0] as FragmentDefinitionNode;
const { fragment, id, policies, fieldName, fragmentName } = options;
const definitions = fragment.definitions as FragmentDefinitionNode[];
const watchDefinition = getWatchDefinition(definitions, fragmentName);

const query = _generateQueryFromFragment({
fragmentDefinition: fragmentDefinition,
definitions,
watchDefinition,
fieldName,
});

Expand Down Expand Up @@ -85,12 +104,14 @@ export function buildWatchFragmentWhereQuery<FragmentType>(options: WatchFragmen
policies: Policies;
fieldName: string;
}): DocumentNode {
const { fragment, filter, policies, cache, fieldName, } = options;
const fragmentDefinition = fragment.definitions[0] as FragmentDefinitionNode;
const __typename = fragmentDefinition.typeCondition.name.value;
const { fragment, filter, policies, cache, fieldName, fragmentName } = options;
const definitions = fragment.definitions as FragmentDefinitionNode[];
const watchDefinition = getWatchDefinition(definitions, fragmentName);
const __typename = watchDefinition.typeCondition.name.value;

const query = _generateQueryFromFragment({
fragmentDefinition,
definitions,
watchDefinition,
fieldName,
});

Expand Down
5 changes: 5 additions & 0 deletions src/hooks/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { QueryHookOptions } from "@apollo/client";

export type FragmentHookOptions = Pick<QueryHookOptions, 'onCompleted' | 'onError' | 'skip' | 'returnPartialData' | 'errorPolicy'> & {
fragmentName?: string;
};
16 changes: 11 additions & 5 deletions src/hooks/useFragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { useOnce } from './utils';
import InvalidationPolicyCache from '../cache/InvalidationPolicyCache';
import { DocumentNode } from 'graphql';
import { buildWatchFragmentQuery } from '../client/utils';
import { FragmentHookOptions } from './types';
import { useFragmentTypePolicyFieldName } from './useFragmentTypePolicyFieldName';
import { useGetQueryDataByFieldName } from './useGetQueryDataByFieldName';
import { useQueryDataByFieldName } from './useGetQueryDataByFieldName';

interface UseFragmentOptions {
interface UseFragmentOptions extends FragmentHookOptions {
id: string;
}

Expand All @@ -17,13 +18,18 @@ export default function useFragment<FragmentType>(fragment: DocumentNode, option
const client = context.client;
const cache = client?.cache as unknown as InvalidationPolicyCache;
const fieldName = useFragmentTypePolicyFieldName();
const { id, ...queryOptions } = options;

const queryForFragment = useOnce(() => buildWatchFragmentQuery({
const query = useOnce(() => buildWatchFragmentQuery({
id,
fragment,
fieldName,
id: options.id,
policies: cache.policies,
}));

return useGetQueryDataByFieldName<FragmentType | null>(queryForFragment, fieldName);
return useQueryDataByFieldName<FragmentType | null>({
fieldName,
query: query,
options: queryOptions,
});
}
17 changes: 14 additions & 3 deletions src/hooks/useFragmentWhere.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,35 @@ import InvalidationPolicyCache from '../cache/InvalidationPolicyCache';
import { buildWatchFragmentWhereQuery } from '../client/utils';
import { FragmentWhereFilter } from '../cache/types';
import { useOnce } from './utils';
import { useQueryDataByFieldName } from './useGetQueryDataByFieldName';
import { FragmentHookOptions } from './types';
import { useFragmentTypePolicyFieldName } from './useFragmentTypePolicyFieldName';
import { useGetQueryDataByFieldName } from './useGetQueryDataByFieldName';

interface UseFragmentWhereOptions<FragmentType> extends FragmentHookOptions {
filter?: FragmentWhereFilter<FragmentType>;
}

// A hook for subscribing to a fragment for entities in the Apollo cache matching a given filter from a React component.
export default function useFragmentWhere<FragmentType>(fragment: DocumentNode, filter?: FragmentWhereFilter<FragmentType>) {
export default function useFragmentWhere<FragmentType>(fragment: DocumentNode, options: UseFragmentWhereOptions<FragmentType> = {}) {
const context = useContext(getApolloContext());
const client = context.client;
const cache = client?.cache as unknown as InvalidationPolicyCache;
const fieldName = useFragmentTypePolicyFieldName();
const { filter, fragmentName, ...queryOptions } = options;

const query = useOnce(() => buildWatchFragmentWhereQuery({
filter,
fragment,
fieldName,
fragmentName,
cache,
policies: cache.policies,
}));

return useGetQueryDataByFieldName<FragmentType[]>(query, fieldName);
return useQueryDataByFieldName<FragmentType[]>({
query,
fieldName,
options: queryOptions,
});
}

14 changes: 11 additions & 3 deletions src/hooks/useGetQueryDataByFieldName.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { useQuery } from "@apollo/client";
import { DocumentNode } from "graphql";
import { FragmentHookOptions } from "./types";

interface UseQueryDataByFieldNameType {
query: DocumentNode;
fieldName: string;
options: FragmentHookOptions;
}

// A hook that subscribes to a query with useQuery and gets the data under a particular field name
// of the raw useQuery response as the new data response.
export const useGetQueryDataByFieldName = <FieldType>(query: DocumentNode, fieldName: string) => {
const result = useQuery<Record<string, FieldType>>(query, {
export const useQueryDataByFieldName = <ResultFieldDataType>({ query, fieldName, options }: UseQueryDataByFieldNameType) => {
const result = useQuery<Record<string, ResultFieldDataType>>(query, {
...options,
fetchPolicy: 'cache-only',
});

Expand All @@ -13,6 +21,6 @@ export const useGetQueryDataByFieldName = <FieldType>(query: DocumentNode, field
data: result?.data?.[fieldName],
};

type RequiredDataResult = typeof requiredDataResult & { data: FieldType };
type RequiredDataResult = typeof requiredDataResult & { data: ResultFieldDataType };
return requiredDataResult as RequiredDataResult;
}
119 changes: 119 additions & 0 deletions tests/ApolloExtensionsClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,94 @@ describe('ApolloExtensionsClient', () => {
});
});
});

describe('with multiple fragments', () => {
describe('without a fragment name provided', () => {
test('should query for the first fragment', (done) => {
const observable = client.watchFragment({
fragment: gql`
fragment EmployeeFragment on Employee {
id
...OtherFragment
}

fragment OtherFragment on Employee {
employee_name
}
`,
id: employee.toRef(),
});

const subscription = observable.subscribe((val) => {
expect(val).toEqual({
__typename: 'Employee',
id: employee.id,
employee_name: employee.employee_name,
});
// @ts-ignore Type policies is private
expect(Object.keys(cache.policies.typePolicies.Query.fields).length).toEqual(1);
subscription.unsubscribe();
// @ts-ignore Type policies is private
expect(Object.keys(cache.policies.typePolicies.Query.fields).length).toEqual(0);
done();
});
});
});

describe('with a fragmentName provided', () => {
describe('with a matching fragment name', () => {
test('should query for the fragment with the given fragment name', (done) => {
const observable = client.watchFragment({
fragment: gql`
fragment OtherFragment on Employee {
employee_name
}

fragment EmployeeFragment on Employee {
id
...OtherFragment
}
`,
id: employee.toRef(),
fragmentName: 'EmployeeFragment',
});

const subscription = observable.subscribe((val) => {
expect(val).toEqual({
__typename: 'Employee',
id: employee.id,
employee_name: employee.employee_name,
});
// @ts-ignore Type policies is private
expect(Object.keys(cache.policies.typePolicies.Query.fields).length).toEqual(1);
subscription.unsubscribe();
// @ts-ignore Type policies is private
expect(Object.keys(cache.policies.typePolicies.Query.fields).length).toEqual(0);
done();
});
});
});

describe('without a matching fragment name', () => {
test('should error that the given fragment could not be found', () => {
expect(() => client.watchFragment({
fragment: gql`
fragment OtherFragment on Employee {
employee_name
}

fragment EmployeeFragment on Employee {
id
...OtherFragment
}
`,
id: employee.toRef(),
fragmentName: 'MissingFragment',
})).toThrowError('No fragment with name: MissingFragment');
});
});
});
});
});

describe('watchFragmentWhere', () => {
Expand Down Expand Up @@ -210,5 +298,36 @@ describe('ApolloExtensionsClient', () => {
});
});
});

describe('with multiple fragments', () => {
test('should query for the matching fragment name', (done) => {
const observable = client.watchFragmentWhere<EmployeeType>({
fragment: gql`
fragment OtherFragment on Employee {
employee_name
}

fragment EmployeeFragment on Employee {
id
...OtherFragment
}
`,
fragmentName: 'EmployeeFragment',
filter: {
employee_name: employee.employee_name,
},
});

const subscription = observable.subscribe((val) => {
expect(val).toEqual([{
__typename: 'Employee',
id: employee.id,
employee_name: employee.employee_name,
}]);
subscription.unsubscribe();
done();
});
});
});
});
});