diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a4bb6..1ef95a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index d813696..ef66f16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@nerdwallet/apollo-cache-policies", - "version": "2.0.3", + "version": "2.1.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 95d19b6..8911d27 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client/types.ts b/src/client/types.ts index 1b072c2..a96e214 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -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 = { - fragment: DocumentNode; +export type WatchFragmentWhereOptions = FragmentOptions & { filter?: FragmentWhereFilter; } diff --git a/src/client/utils.ts b/src/client/utils.ts index cfd6ff5..1e8f8fe 100644 --- a/src/client/utils.ts +++ b/src/client/utils.ts @@ -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: [], @@ -33,7 +36,7 @@ function _generateQueryFromFragment({ name: { kind: "Name", value: "client" }, }, ], - selectionSet: fragmentDefinition.selectionSet, + selectionSet: watchDefinition.selectionSet, }, ] }, @@ -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( @@ -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, }); @@ -85,12 +104,14 @@ export function buildWatchFragmentWhereQuery(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, }); diff --git a/src/hooks/types.ts b/src/hooks/types.ts new file mode 100644 index 0000000..39a3066 --- /dev/null +++ b/src/hooks/types.ts @@ -0,0 +1,5 @@ +import { QueryHookOptions } from "@apollo/client"; + +export type FragmentHookOptions = Pick & { + fragmentName?: string; +}; \ No newline at end of file diff --git a/src/hooks/useFragment.ts b/src/hooks/useFragment.ts index 51d156d..29ecfb0 100644 --- a/src/hooks/useFragment.ts +++ b/src/hooks/useFragment.ts @@ -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; } @@ -17,13 +18,18 @@ export default function useFragment(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(queryForFragment, fieldName); + return useQueryDataByFieldName({ + fieldName, + query: query, + options: queryOptions, + }); } diff --git a/src/hooks/useFragmentWhere.ts b/src/hooks/useFragmentWhere.ts index 234caa4..28af351 100644 --- a/src/hooks/useFragmentWhere.ts +++ b/src/hooks/useFragmentWhere.ts @@ -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 extends FragmentHookOptions { + filter?: FragmentWhereFilter; +} // 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(fragment: DocumentNode, filter?: FragmentWhereFilter) { +export default function useFragmentWhere(fragment: DocumentNode, options: UseFragmentWhereOptions = {}) { 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(query, fieldName); + return useQueryDataByFieldName({ + query, + fieldName, + options: queryOptions, + }); } diff --git a/src/hooks/useGetQueryDataByFieldName.ts b/src/hooks/useGetQueryDataByFieldName.ts index 6efa34e..c0e63ba 100644 --- a/src/hooks/useGetQueryDataByFieldName.ts +++ b/src/hooks/useGetQueryDataByFieldName.ts @@ -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 = (query: DocumentNode, fieldName: string) => { - const result = useQuery>(query, { +export const useQueryDataByFieldName = ({ query, fieldName, options }: UseQueryDataByFieldNameType) => { + const result = useQuery>(query, { + ...options, fetchPolicy: 'cache-only', }); @@ -13,6 +21,6 @@ export const useGetQueryDataByFieldName = (query: DocumentNode, field data: result?.data?.[fieldName], }; - type RequiredDataResult = typeof requiredDataResult & { data: FieldType }; + type RequiredDataResult = typeof requiredDataResult & { data: ResultFieldDataType }; return requiredDataResult as RequiredDataResult; } \ No newline at end of file diff --git a/tests/ApolloExtensionsClient.test.ts b/tests/ApolloExtensionsClient.test.ts index 1cb6dac..e6f873f 100644 --- a/tests/ApolloExtensionsClient.test.ts +++ b/tests/ApolloExtensionsClient.test.ts @@ -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', () => { @@ -210,5 +298,36 @@ describe('ApolloExtensionsClient', () => { }); }); }); + + describe('with multiple fragments', () => { + test('should query for the matching fragment name', (done) => { + const observable = client.watchFragmentWhere({ + 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(); + }); + }); + }); }); }); \ No newline at end of file