@dhis2/app-service-data 3.17.0 → 3.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitignore +5 -0
- package/d2.config.js +9 -0
- package/jest.config.js +14 -0
- package/package.json +4 -4
- package/src/__tests__/integration.test.tsx +80 -0
- package/src/__tests__/mutations.test.tsx +71 -0
- package/src/index.ts +5 -0
- package/src/react/components/CustomDataProvider.tsx +33 -0
- package/src/react/components/DataMutation.tsx +24 -0
- package/src/react/components/DataProvider.test.tsx +22 -0
- package/src/react/components/DataProvider.tsx +58 -0
- package/src/react/components/DataQuery.tsx +26 -0
- package/src/react/components/index.ts +4 -0
- package/src/react/context/DataContext.tsx +5 -0
- package/src/react/context/defaultDataContext.test.ts +46 -0
- package/src/react/context/defaultDataContext.ts +9 -0
- package/src/react/hooks/index.ts +3 -0
- package/src/react/hooks/mergeAndCompareVariables.test.ts +65 -0
- package/src/react/hooks/mergeAndCompareVariables.ts +32 -0
- package/src/react/hooks/stableVariablesHash.test.ts +53 -0
- package/src/react/hooks/stableVariablesHash.ts +56 -0
- package/src/react/hooks/useDataEngine.ts +8 -0
- package/src/react/hooks/useDataMutation.test.tsx +296 -0
- package/src/react/hooks/useDataMutation.ts +42 -0
- package/src/react/hooks/useDataQuery.test.tsx +1029 -0
- package/src/react/hooks/useDataQuery.ts +170 -0
- package/src/react/hooks/useQueryExecutor.test.tsx +195 -0
- package/src/react/hooks/useQueryExecutor.ts +99 -0
- package/src/react/hooks/useStaticInput.test.ts +101 -0
- package/src/react/hooks/useStaticInput.ts +27 -0
- package/src/react/index.ts +2 -0
- package/src/setupRTL.ts +5 -0
- package/src/types.ts +72 -0
- package/tsconfig.json +10 -0
package/.gitignore
ADDED
package/d2.config.js
ADDED
package/jest.config.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
collectCoverageFrom: [
|
|
3
|
+
'src/**/*.(js|jsx|ts|tsx)',
|
|
4
|
+
'!src/index.ts',
|
|
5
|
+
'!src/**/types/*',
|
|
6
|
+
'!src/**/types.ts',
|
|
7
|
+
'!src/engine/index.ts',
|
|
8
|
+
'!src/links/index.ts',
|
|
9
|
+
'!src/react/index.ts',
|
|
10
|
+
],
|
|
11
|
+
|
|
12
|
+
// Setup react-testing-library
|
|
13
|
+
setupFilesAfterEnv: ['<rootDir>/src/setupRTL.ts'],
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dhis2/app-service-data",
|
|
3
|
-
"version": "3.17.
|
|
3
|
+
"version": "3.17.2",
|
|
4
4
|
"main": "./build/cjs/index.js",
|
|
5
5
|
"module": "./build/es/index.js",
|
|
6
6
|
"types": "./build/types/index.d.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
-
"
|
|
23
|
+
"**"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build:types": "tsc --emitDeclarationOnly --outDir ./build/types",
|
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
"coverage": "yarn test --coverage"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@dhis2/data-engine": "3.17.2",
|
|
36
37
|
"@tanstack/react-query": "^4.36.1",
|
|
37
38
|
"prop-types": "^15.7.2"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
|
-
"@dhis2/app-service-config": "3.17.
|
|
41
|
-
"@dhis2/data-engine": "3.17.0",
|
|
41
|
+
"@dhis2/app-service-config": "3.17.2",
|
|
42
42
|
"react": "^16.8.6 || ^18",
|
|
43
43
|
"react-dom": "^16.8.6 || ^18"
|
|
44
44
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { render, waitFor } from '@testing-library/react'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { CustomDataProvider, DataQuery } from '../react'
|
|
4
|
+
|
|
5
|
+
describe('<DataQuery />', () => {
|
|
6
|
+
it('should render without failing', async () => {
|
|
7
|
+
const data = {
|
|
8
|
+
answer: 42,
|
|
9
|
+
}
|
|
10
|
+
const wrapper = ({ children }: { children?: React.ReactNode }) => (
|
|
11
|
+
<CustomDataProvider data={data}>{children}</CustomDataProvider>
|
|
12
|
+
)
|
|
13
|
+
const renderFunction = jest.fn(() => null)
|
|
14
|
+
|
|
15
|
+
render(
|
|
16
|
+
<DataQuery query={{ answer: { resource: 'answer' } }}>
|
|
17
|
+
{renderFunction}
|
|
18
|
+
</DataQuery>,
|
|
19
|
+
{ wrapper }
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(renderFunction).toHaveBeenCalledTimes(1)
|
|
23
|
+
expect(renderFunction).toHaveBeenLastCalledWith(
|
|
24
|
+
expect.objectContaining({
|
|
25
|
+
called: true,
|
|
26
|
+
loading: true,
|
|
27
|
+
})
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
await waitFor(() => {
|
|
31
|
+
expect(renderFunction).toHaveBeenCalledTimes(2)
|
|
32
|
+
expect(renderFunction).toHaveBeenLastCalledWith(
|
|
33
|
+
expect.objectContaining({
|
|
34
|
+
called: true,
|
|
35
|
+
loading: false,
|
|
36
|
+
data,
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should render an error', async () => {
|
|
43
|
+
const expectedError = new Error('Something went wrong')
|
|
44
|
+
const data = {
|
|
45
|
+
test: () => {
|
|
46
|
+
throw expectedError
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
const wrapper = ({ children }: { children?: React.ReactNode }) => (
|
|
50
|
+
<CustomDataProvider data={data}>{children}</CustomDataProvider>
|
|
51
|
+
)
|
|
52
|
+
const renderFunction = jest.fn(() => null)
|
|
53
|
+
|
|
54
|
+
render(
|
|
55
|
+
<DataQuery query={{ test: { resource: 'test' } }}>
|
|
56
|
+
{renderFunction}
|
|
57
|
+
</DataQuery>,
|
|
58
|
+
{ wrapper }
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(renderFunction).toHaveBeenCalledTimes(1)
|
|
62
|
+
expect(renderFunction).toHaveBeenLastCalledWith(
|
|
63
|
+
expect.objectContaining({
|
|
64
|
+
called: true,
|
|
65
|
+
loading: true,
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(renderFunction).toHaveBeenCalledTimes(2)
|
|
71
|
+
expect(renderFunction).toHaveBeenLastCalledWith(
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
called: true,
|
|
74
|
+
loading: false,
|
|
75
|
+
error: expectedError,
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render, waitFor, act } from '@testing-library/react'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { CustomDataProvider, DataMutation } from '../react'
|
|
4
|
+
|
|
5
|
+
describe('<DataMutation />', () => {
|
|
6
|
+
it('should render without failing', async () => {
|
|
7
|
+
const endpointSpy = jest.fn(() => Promise.resolve(42))
|
|
8
|
+
const mutation = {
|
|
9
|
+
resource: 'answer',
|
|
10
|
+
type: 'create' as const,
|
|
11
|
+
data: {
|
|
12
|
+
question: '?',
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
const data = {
|
|
16
|
+
answer: endpointSpy,
|
|
17
|
+
}
|
|
18
|
+
const wrapper = ({ children }: { children?: React.ReactNode }) => (
|
|
19
|
+
<CustomDataProvider data={data}>{children}</CustomDataProvider>
|
|
20
|
+
)
|
|
21
|
+
const renderSpy: jest.Mock = jest.fn(() => null)
|
|
22
|
+
render(<DataMutation mutation={mutation}>{renderSpy}</DataMutation>, {
|
|
23
|
+
wrapper,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
expect(endpointSpy).toHaveBeenCalledTimes(0)
|
|
27
|
+
expect(renderSpy).toHaveBeenCalledTimes(1)
|
|
28
|
+
expect(renderSpy).toHaveBeenLastCalledWith([
|
|
29
|
+
expect.any(Function),
|
|
30
|
+
expect.objectContaining({
|
|
31
|
+
called: false,
|
|
32
|
+
loading: false,
|
|
33
|
+
engine: expect.any(Object),
|
|
34
|
+
}),
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
await act(async () => {
|
|
38
|
+
const firstRenderSpyCall = renderSpy.mock.calls[0]
|
|
39
|
+
const firstRenderSpyArgument = firstRenderSpyCall[0]
|
|
40
|
+
const [mutate] = firstRenderSpyArgument
|
|
41
|
+
await mutate()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
waitFor(() => {
|
|
45
|
+
expect(endpointSpy).toHaveBeenCalledTimes(1)
|
|
46
|
+
expect(renderSpy).toHaveBeenCalledTimes(2)
|
|
47
|
+
expect(renderSpy).toHaveBeenLastCalledWith([
|
|
48
|
+
expect.any(Function),
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
called: true,
|
|
51
|
+
loading: true,
|
|
52
|
+
engine: expect.any(Object),
|
|
53
|
+
}),
|
|
54
|
+
])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
waitFor(() => {
|
|
58
|
+
expect(endpointSpy).toHaveBeenCalledTimes(1)
|
|
59
|
+
expect(renderSpy).toHaveBeenCalledTimes(3)
|
|
60
|
+
expect(renderSpy).toHaveBeenLastCalledWith([
|
|
61
|
+
expect.any(Function),
|
|
62
|
+
expect.objectContaining({
|
|
63
|
+
called: true,
|
|
64
|
+
loading: false,
|
|
65
|
+
data: { answer: 42 },
|
|
66
|
+
engine: expect.any(Object),
|
|
67
|
+
}),
|
|
68
|
+
])
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DataEngine, CustomDataLink } from '@dhis2/data-engine'
|
|
2
|
+
import type { CustomData, CustomLinkOptions } from '@dhis2/data-engine'
|
|
3
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { DataContext } from '../context/DataContext'
|
|
6
|
+
import { queryClientOptions as queryClientDefaults } from './DataProvider'
|
|
7
|
+
|
|
8
|
+
interface CustomProviderInput {
|
|
9
|
+
children: React.ReactNode
|
|
10
|
+
data: CustomData
|
|
11
|
+
options?: CustomLinkOptions
|
|
12
|
+
queryClientOptions?: any
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CustomDataProvider = ({
|
|
16
|
+
children,
|
|
17
|
+
data,
|
|
18
|
+
options,
|
|
19
|
+
queryClientOptions = queryClientDefaults,
|
|
20
|
+
}: CustomProviderInput): JSX.Element => {
|
|
21
|
+
const link = new CustomDataLink(data, options)
|
|
22
|
+
const engine = new DataEngine(link)
|
|
23
|
+
const queryClient = new QueryClient(queryClientOptions)
|
|
24
|
+
const context = { engine }
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<QueryClientProvider client={queryClient}>
|
|
28
|
+
<DataContext.Provider value={context}>
|
|
29
|
+
{children}
|
|
30
|
+
</DataContext.Provider>
|
|
31
|
+
</QueryClientProvider>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Mutation, MutationOptions } from '@dhis2/data-engine'
|
|
2
|
+
import { MutationRenderInput } from '../../types'
|
|
3
|
+
import { useDataMutation } from '../hooks/useDataMutation'
|
|
4
|
+
|
|
5
|
+
interface MutationInput extends MutationOptions {
|
|
6
|
+
mutation: Mutation
|
|
7
|
+
children: (input: MutationRenderInput) => any
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DataMutation = ({
|
|
11
|
+
mutation,
|
|
12
|
+
onComplete,
|
|
13
|
+
onError,
|
|
14
|
+
variables,
|
|
15
|
+
children,
|
|
16
|
+
}: MutationInput) => {
|
|
17
|
+
const mutationState = useDataMutation(mutation, {
|
|
18
|
+
onComplete,
|
|
19
|
+
onError,
|
|
20
|
+
variables,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return children(mutationState)
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { DataEngine, RestAPILink } from '@dhis2/data-engine'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { DataContext } from '../context/DataContext'
|
|
5
|
+
import { DataProvider } from './DataProvider'
|
|
6
|
+
|
|
7
|
+
describe('DataProvider', () => {
|
|
8
|
+
it('Should pass a new engine and RestAPILink to consumers', () => {
|
|
9
|
+
const renderFunction = jest.fn()
|
|
10
|
+
render(
|
|
11
|
+
<DataProvider baseUrl="test" apiVersion={42}>
|
|
12
|
+
<DataContext.Consumer>{renderFunction}</DataContext.Consumer>
|
|
13
|
+
</DataProvider>
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
expect(renderFunction).toHaveBeenCalled()
|
|
17
|
+
const context = renderFunction.mock.calls.pop()[0]
|
|
18
|
+
expect(context).not.toBeUndefined()
|
|
19
|
+
expect(context.engine).toBeInstanceOf(DataEngine)
|
|
20
|
+
expect(context.engine.link).toBeInstanceOf(RestAPILink)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* eslint-disable react/no-unused-prop-types */
|
|
2
|
+
import { useConfig } from '@dhis2/app-service-config'
|
|
3
|
+
import { DataEngine, RestAPILink } from '@dhis2/data-engine'
|
|
4
|
+
import {
|
|
5
|
+
QueryClient,
|
|
6
|
+
QueryClientProvider,
|
|
7
|
+
type QueryClientConfig,
|
|
8
|
+
} from '@tanstack/react-query'
|
|
9
|
+
import React from 'react'
|
|
10
|
+
import { DataContext } from '../context/DataContext'
|
|
11
|
+
|
|
12
|
+
export interface ProviderInput {
|
|
13
|
+
baseUrl?: string
|
|
14
|
+
apiVersion?: number
|
|
15
|
+
children: React.ReactNode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const queryClientOptions: QueryClientConfig = {
|
|
19
|
+
defaultOptions: {
|
|
20
|
+
queries: {
|
|
21
|
+
// Disable automatic error retries
|
|
22
|
+
retry: false,
|
|
23
|
+
// Retry on mount if query has errored
|
|
24
|
+
retryOnMount: true,
|
|
25
|
+
// Refetch on mount if data is stale
|
|
26
|
+
refetchOnMount: true,
|
|
27
|
+
// Don't refetch when the window regains focus
|
|
28
|
+
refetchOnWindowFocus: false,
|
|
29
|
+
// Don't refetch after connection issues
|
|
30
|
+
refetchOnReconnect: false,
|
|
31
|
+
// RQv4 uses 'online' as the default, which pauses queries without network connection.
|
|
32
|
+
// 'always' reestablishes behavior from v3, and lets requests fire when offline
|
|
33
|
+
// https://tanstack.com/query/latest/docs/framework/react/guides/network-mode
|
|
34
|
+
networkMode: 'always',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const queryClient = new QueryClient(queryClientOptions)
|
|
40
|
+
|
|
41
|
+
export const DataProvider = (props: ProviderInput): JSX.Element => {
|
|
42
|
+
const config = {
|
|
43
|
+
...useConfig(),
|
|
44
|
+
...props,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const link = new RestAPILink(config)
|
|
48
|
+
const engine = new DataEngine(link)
|
|
49
|
+
const context = { engine }
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<QueryClientProvider client={queryClient}>
|
|
53
|
+
<DataContext.Provider value={context}>
|
|
54
|
+
{props.children}
|
|
55
|
+
</DataContext.Provider>
|
|
56
|
+
</QueryClientProvider>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Query, QueryOptions } from '@dhis2/data-engine'
|
|
2
|
+
import { QueryRenderInput } from '../../types'
|
|
3
|
+
import { useDataQuery } from '../hooks/useDataQuery'
|
|
4
|
+
|
|
5
|
+
interface QueryInput extends QueryOptions {
|
|
6
|
+
query: Query
|
|
7
|
+
children: (input: QueryRenderInput) => any
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DataQuery = ({
|
|
11
|
+
query,
|
|
12
|
+
onComplete,
|
|
13
|
+
onError,
|
|
14
|
+
variables,
|
|
15
|
+
lazy,
|
|
16
|
+
children,
|
|
17
|
+
}: QueryInput) => {
|
|
18
|
+
const queryState = useDataQuery(query, {
|
|
19
|
+
onComplete,
|
|
20
|
+
onError,
|
|
21
|
+
variables,
|
|
22
|
+
lazy,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return children(queryState)
|
|
26
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defaultDataContext } from './defaultDataContext'
|
|
2
|
+
|
|
3
|
+
describe('defaultContext', () => {
|
|
4
|
+
const originalError = console.error
|
|
5
|
+
const mockError = jest.fn()
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
jest.clearAllMocks()
|
|
8
|
+
console.error = mockError
|
|
9
|
+
})
|
|
10
|
+
afterEach(() => (console.error = originalError))
|
|
11
|
+
|
|
12
|
+
it('Should throw if query is called', () => {
|
|
13
|
+
const context = defaultDataContext
|
|
14
|
+
expect(
|
|
15
|
+
context.engine.query({
|
|
16
|
+
test: {
|
|
17
|
+
resource: 'test',
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
).rejects.toBeTruthy()
|
|
21
|
+
|
|
22
|
+
expect(mockError).toHaveBeenCalledTimes(1)
|
|
23
|
+
expect(mockError.mock.calls.pop()).toMatchInlineSnapshot(`
|
|
24
|
+
Array [
|
|
25
|
+
"DHIS2 data context must be initialized, please ensure that you include a <DataProvider> in your application",
|
|
26
|
+
]
|
|
27
|
+
`)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('Should throw and log if mutate is called', () => {
|
|
31
|
+
const context = defaultDataContext
|
|
32
|
+
expect(
|
|
33
|
+
context.engine.mutate({
|
|
34
|
+
type: 'create',
|
|
35
|
+
resource: 'test',
|
|
36
|
+
data: {},
|
|
37
|
+
})
|
|
38
|
+
).rejects.toBeTruthy()
|
|
39
|
+
expect(mockError).toHaveBeenCalled()
|
|
40
|
+
expect(mockError.mock.calls.pop()).toMatchInlineSnapshot(`
|
|
41
|
+
Array [
|
|
42
|
+
"DHIS2 data context must be initialized, please ensure that you include a <DataProvider> in your application",
|
|
43
|
+
]
|
|
44
|
+
`)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { DataEngine, ErrorLink } from '@dhis2/data-engine'
|
|
2
|
+
|
|
3
|
+
const errorMessage =
|
|
4
|
+
'DHIS2 data context must be initialized, please ensure that you include a <DataProvider> in your application'
|
|
5
|
+
|
|
6
|
+
const link = new ErrorLink(errorMessage)
|
|
7
|
+
const engine = new DataEngine(link)
|
|
8
|
+
|
|
9
|
+
export const defaultDataContext = { engine }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { mergeAndCompareVariables } from './mergeAndCompareVariables'
|
|
2
|
+
import { stableVariablesHash } from './stableVariablesHash'
|
|
3
|
+
jest.mock('./stableVariablesHash', () => ({
|
|
4
|
+
stableVariablesHash: (object: unknown) => JSON.stringify(object),
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
const testVariables = {
|
|
8
|
+
question: 'What do you get when you multiply six by nine?',
|
|
9
|
+
answer: 42,
|
|
10
|
+
}
|
|
11
|
+
const testHash = stableVariablesHash(testVariables)
|
|
12
|
+
|
|
13
|
+
describe('mergeAndCompareVariables', () => {
|
|
14
|
+
it('Should return previous variables and hash when no new variables are provided', () => {
|
|
15
|
+
expect(
|
|
16
|
+
mergeAndCompareVariables(testVariables, undefined, undefined)
|
|
17
|
+
).toMatchObject({
|
|
18
|
+
identical: true,
|
|
19
|
+
mergedVariables: testVariables,
|
|
20
|
+
mergedVariablesHash: undefined,
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('Should return identical: true when merged variables are identical to old variables (without prev hash)', () => {
|
|
25
|
+
const newVariables = { answer: testVariables.answer }
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
mergeAndCompareVariables(testVariables, newVariables, undefined)
|
|
29
|
+
).toMatchObject({
|
|
30
|
+
identical: true,
|
|
31
|
+
mergedVariables: testVariables,
|
|
32
|
+
mergedVariablesHash: testHash,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('Should return identical: false with incorrect previous hash', () => {
|
|
37
|
+
const incorrectPreviousHash = 'IAmAHash'
|
|
38
|
+
const newVariables = { answer: 42 }
|
|
39
|
+
|
|
40
|
+
expect(
|
|
41
|
+
mergeAndCompareVariables(
|
|
42
|
+
testVariables,
|
|
43
|
+
newVariables,
|
|
44
|
+
incorrectPreviousHash
|
|
45
|
+
)
|
|
46
|
+
).toMatchObject({
|
|
47
|
+
identical: false,
|
|
48
|
+
mergedVariables: testVariables,
|
|
49
|
+
mergedVariablesHash: testHash,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('Should return identical: false when merged variables are different than old variables', () => {
|
|
54
|
+
const newVariables = { answer: 43 }
|
|
55
|
+
const expectedMergedVariables = { ...testVariables, ...newVariables }
|
|
56
|
+
|
|
57
|
+
expect(
|
|
58
|
+
mergeAndCompareVariables(testVariables, newVariables, testHash)
|
|
59
|
+
).toMatchObject({
|
|
60
|
+
identical: false,
|
|
61
|
+
mergedVariables: expectedMergedVariables,
|
|
62
|
+
mergedVariablesHash: stableVariablesHash(expectedMergedVariables),
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { QueryVariables } from '@dhis2/data-engine'
|
|
2
|
+
import { stableVariablesHash } from './stableVariablesHash'
|
|
3
|
+
|
|
4
|
+
export const mergeAndCompareVariables = (
|
|
5
|
+
previousVariables?: QueryVariables,
|
|
6
|
+
newVariables?: QueryVariables,
|
|
7
|
+
previousHash?: string
|
|
8
|
+
) => {
|
|
9
|
+
if (!newVariables) {
|
|
10
|
+
return {
|
|
11
|
+
identical: true,
|
|
12
|
+
mergedVariablesHash: previousHash,
|
|
13
|
+
mergedVariables: previousVariables,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Use cached hash if it exists
|
|
18
|
+
const currentHash = previousHash || stableVariablesHash(previousVariables)
|
|
19
|
+
|
|
20
|
+
const mergedVariables = {
|
|
21
|
+
...previousVariables,
|
|
22
|
+
...newVariables,
|
|
23
|
+
}
|
|
24
|
+
const mergedVariablesHash = stableVariablesHash(mergedVariables)
|
|
25
|
+
const identical = currentHash === mergedVariablesHash
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
identical,
|
|
29
|
+
mergedVariablesHash,
|
|
30
|
+
mergedVariables,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { stableVariablesHash } from './stableVariablesHash'
|
|
2
|
+
|
|
3
|
+
describe('stableVariablesHash', () => {
|
|
4
|
+
it('sorts objects before hashing', () => {
|
|
5
|
+
const one = {
|
|
6
|
+
a: {
|
|
7
|
+
one: 1,
|
|
8
|
+
two: 2,
|
|
9
|
+
three: 3,
|
|
10
|
+
},
|
|
11
|
+
b: [1, 2, 3],
|
|
12
|
+
c: 'c',
|
|
13
|
+
}
|
|
14
|
+
const two = {
|
|
15
|
+
c: 'c',
|
|
16
|
+
b: [1, 2, 3],
|
|
17
|
+
a: {
|
|
18
|
+
three: 3,
|
|
19
|
+
two: 2,
|
|
20
|
+
one: 1,
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expect(stableVariablesHash(one)).toEqual(stableVariablesHash(two))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('can handle primitives', () => {
|
|
28
|
+
const one = undefined
|
|
29
|
+
const two = 'string'
|
|
30
|
+
const three = 3
|
|
31
|
+
const four = null
|
|
32
|
+
const five = true
|
|
33
|
+
|
|
34
|
+
expect(stableVariablesHash(one)).toMatchInlineSnapshot(`undefined`)
|
|
35
|
+
expect(stableVariablesHash(two)).toMatchInlineSnapshot(`"\\"string\\""`)
|
|
36
|
+
expect(stableVariablesHash(three)).toMatchInlineSnapshot(`"3"`)
|
|
37
|
+
expect(stableVariablesHash(four)).toMatchInlineSnapshot(`"null"`)
|
|
38
|
+
expect(stableVariablesHash(five)).toMatchInlineSnapshot(`"true"`)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('throws a clear error when the variables contain a circular reference', () => {
|
|
42
|
+
const unserializable: any = {
|
|
43
|
+
value: 'value',
|
|
44
|
+
}
|
|
45
|
+
unserializable.circular = unserializable
|
|
46
|
+
|
|
47
|
+
expect(() =>
|
|
48
|
+
stableVariablesHash(unserializable)
|
|
49
|
+
).toThrowErrorMatchingInlineSnapshot(
|
|
50
|
+
`"Could not serialize variables. Make sure that the variables do not contain circular references and can be processed by JSON.stringify."`
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
function hasObjectPrototype(o: any): boolean {
|
|
2
|
+
return Object.prototype.toString.call(o) === '[object Object]'
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function isPlainObject(o: any): o is object {
|
|
6
|
+
if (!hasObjectPrototype(o)) {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// If has modified constructor
|
|
11
|
+
const ctor = o.constructor
|
|
12
|
+
if (typeof ctor === 'undefined') {
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// If has modified prototype
|
|
17
|
+
const prot = ctor.prototype
|
|
18
|
+
if (!hasObjectPrototype(prot)) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// If constructor does not have an Object-specific method
|
|
23
|
+
if (!Object.prototype.hasOwnProperty.call(prot, 'isPrototypeOf')) {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Most likely a plain Object
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hashes the value into a stable hash.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export function stableVariablesHash(value: any): string {
|
|
36
|
+
let hash
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
hash = JSON.stringify(value, (_, val) =>
|
|
40
|
+
isPlainObject(val)
|
|
41
|
+
? Object.keys(val)
|
|
42
|
+
.sort()
|
|
43
|
+
.reduce((result: Record<string, unknown>, key) => {
|
|
44
|
+
result[key] = (val as Record<string, unknown>)[key]
|
|
45
|
+
return result
|
|
46
|
+
}, {})
|
|
47
|
+
: val
|
|
48
|
+
)
|
|
49
|
+
} catch (e) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'Could not serialize variables. Make sure that the variables do not contain circular references and can be processed by JSON.stringify.'
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return hash
|
|
56
|
+
}
|