@dhis2/app-runtime
Advanced tools
Comparing version 1.5.1 to 2.0.0
@@ -10,15 +10,16 @@ 'use strict'; | ||
const uninitializedFetch = async () => { | ||
throw new Error('DHIS2 data context must be initialized, please ensure that you include a <DataProvider> in your application'); | ||
const useStaticInput = (staticValue, { | ||
warn = false, | ||
name = 'input' | ||
} = {}) => { | ||
const originalValue = React.useRef(staticValue); | ||
const [value, setValue] = React.useState(() => originalValue.current); | ||
React.useEffect(() => { | ||
if (warn && originalValue.current !== staticValue) { | ||
console.warn(`The ${name} should be static, don't create it within the render loop!`); | ||
} | ||
}, [warn, staticValue, originalValue, name]); | ||
return [value, setValue]; | ||
}; | ||
const defaultContext = { | ||
baseUrl: '', | ||
apiVersion: 0, | ||
apiUrl: '', | ||
fetch: uninitializedFetch | ||
}; | ||
const DataContext = React__default.createContext(defaultContext); | ||
function ownKeys(object, enumerableOnly) { | ||
@@ -72,3 +73,107 @@ var keys = Object.keys(object); | ||
} | ||
const useQueryExecutor = ({ | ||
execute, | ||
variables: initialVariables, | ||
singular, | ||
immediate, | ||
onComplete, | ||
onError | ||
}) => { | ||
const [theExecute] = useStaticInput(execute); | ||
const [state, setState] = React.useState({ | ||
called: !!immediate, | ||
loading: !!immediate | ||
}); | ||
const variables = React.useRef(initialVariables); | ||
const abortControllersRef = React.useRef([]); | ||
const abort = () => { | ||
abortControllersRef.current.forEach(controller => controller.abort()); | ||
abortControllersRef.current = []; | ||
}; | ||
const refetch = React.useCallback((newVariables = {}) => { | ||
setState(state => !state.called || !state.loading ? { | ||
called: true, | ||
loading: true | ||
} : state); | ||
if (singular) { | ||
abort(); // Cleanup any in-progress fetches | ||
} | ||
const controller = new AbortController(); | ||
abortControllersRef.current.push(controller); | ||
variables.current = _objectSpread({}, variables.current, {}, newVariables); | ||
const options = { | ||
variables: variables.current, | ||
signal: controller.signal, | ||
onComplete, | ||
onError | ||
}; | ||
return theExecute(options).then(data => { | ||
if (!controller.signal.aborted) { | ||
setState({ | ||
called: true, | ||
loading: false, | ||
data | ||
}); | ||
return data; | ||
} | ||
return new Promise(() => {}); | ||
}).catch(error => { | ||
if (!controller.signal.aborted) { | ||
setState({ | ||
called: true, | ||
loading: false, | ||
error | ||
}); | ||
} | ||
return new Promise(() => {}); // Don't throw errors in refetch promises, wait forever | ||
}); | ||
}, [onComplete, onError, singular, theExecute]); | ||
React.useEffect(() => { | ||
if (immediate) { | ||
refetch(); | ||
} | ||
return abort; | ||
}, [immediate, refetch]); | ||
return _objectSpread({ | ||
refetch, | ||
abort | ||
}, state); | ||
}; | ||
const resolveDynamicQuery = ({ | ||
resource, | ||
id, | ||
data, | ||
params | ||
}, variables) => ({ | ||
resource, | ||
id: typeof id === 'function' ? id(variables) : id, | ||
data: typeof data === 'function' ? data(variables) : data, | ||
params: typeof params === 'function' ? params(variables) : params | ||
}); | ||
const getMutationFetchType = mutation => mutation.type === 'update' ? mutation.partial ? 'update' : 'replace' : mutation.type; | ||
function _defineProperty$1(obj, key, value) { | ||
if (key in obj) { | ||
Object.defineProperty(obj, key, { | ||
value: value, | ||
enumerable: true, | ||
configurable: true, | ||
writable: true | ||
}); | ||
} else { | ||
obj[key] = value; | ||
} | ||
return obj; | ||
} | ||
const reduceResponses = (responses, names) => responses.reduce((out, response, idx) => { | ||
@@ -79,51 +184,195 @@ out[names[idx]] = response; | ||
const fetchData = (context, query, signal) => { | ||
const names = Object.keys(query); | ||
const requests = names.map(name => query[name]); | ||
const requestPromises = requests.map(q => context.fetch(q, { | ||
signal: signal | ||
})); | ||
return Promise.all(requestPromises).then(responses => reduceResponses(responses, names)); | ||
}; | ||
class DataEngine { | ||
constructor(link) { | ||
_defineProperty$1(this, "link", void 0); | ||
const useDataQuery = query => { | ||
const context = React.useContext(DataContext); | ||
const [state, setState] = React.useState({ | ||
loading: true | ||
}); | ||
const [refetchCount, setRefetchCount] = React.useState(0); | ||
const refetch = React.useCallback(() => setRefetchCount(count => count + 1), []); | ||
React.useEffect(() => { | ||
const controller = new AbortController(); | ||
this.link = link; | ||
} | ||
const abort = () => controller.abort(); | ||
fetchData(context, query, controller.signal).then(data => { | ||
!controller.signal.aborted && setState({ | ||
loading: false, | ||
data | ||
query(query, { | ||
variables = {}, | ||
signal, | ||
onComplete, | ||
onError | ||
} = {}) { | ||
const names = Object.keys(query); | ||
const queries = names.map(name => query[name]); | ||
return Promise.all(queries.map(q => { | ||
const resolvedQuery = resolveDynamicQuery(q, variables); | ||
return this.link.executeResourceQuery('read', resolvedQuery, { | ||
signal | ||
}); | ||
})).then(results => { | ||
const data = reduceResponses(results, names); | ||
onComplete && onComplete(data); | ||
return data; | ||
}).catch(error => { | ||
!controller.signal.aborted && setState({ | ||
loading: false, | ||
error | ||
}); | ||
}); // Cleanup inflight requests | ||
onError && onError(error); | ||
throw error; | ||
}); | ||
} | ||
return abort; | ||
}, [context, refetchCount]); // eslint-disable-line react-hooks/exhaustive-deps | ||
mutate(mutation, { | ||
variables = {}, | ||
signal, | ||
onComplete, | ||
onError | ||
} = {}) { | ||
const query = resolveDynamicQuery(mutation, variables); | ||
const result = this.link.executeResourceQuery(getMutationFetchType(mutation), query, { | ||
signal | ||
}); | ||
return result.then(data => { | ||
onComplete && onComplete(data); | ||
return data; | ||
}).catch(error => { | ||
onError && onError(error); | ||
throw error; | ||
}); | ||
} | ||
return _objectSpread({ | ||
refetch | ||
}, state); | ||
} | ||
function _defineProperty$2(obj, key, value) { | ||
if (key in obj) { | ||
Object.defineProperty(obj, key, { | ||
value: value, | ||
enumerable: true, | ||
configurable: true, | ||
writable: true | ||
}); | ||
} else { | ||
obj[key] = value; | ||
} | ||
return obj; | ||
} | ||
class ErrorLink { | ||
constructor(errorMessage) { | ||
_defineProperty$2(this, "errorMessage", void 0); | ||
this.errorMessage = errorMessage; | ||
} | ||
executeResourceQuery() { | ||
console.error(this.errorMessage); | ||
return Promise.reject(this.errorMessage); | ||
} | ||
} | ||
const errorMessage = 'DHIS2 data context must be initialized, please ensure that you include a <DataProvider> in your application'; | ||
const link = new ErrorLink(errorMessage); | ||
const engine = new DataEngine(link); | ||
const defaultContext = { | ||
engine | ||
}; | ||
const DataContext = React__default.createContext(defaultContext); | ||
const useDataEngine = () => { | ||
const context = React.useContext(DataContext); | ||
return context.engine; | ||
}; | ||
const empty = {}; | ||
const useDataQuery = (query, { | ||
onComplete, | ||
onError, | ||
variables = empty | ||
} = {}) => { | ||
const engine = useDataEngine(); | ||
const [theQuery] = useStaticInput(query, { | ||
warn: true, | ||
name: 'query' | ||
}); | ||
const execute = React.useCallback(options => engine.query(theQuery, options), [engine, theQuery]); | ||
const { | ||
refetch, | ||
loading, | ||
error, | ||
data | ||
} = useQueryExecutor({ | ||
execute, | ||
variables, | ||
singular: true, | ||
immediate: true, | ||
onComplete, | ||
onError | ||
}); | ||
return { | ||
engine, | ||
refetch, | ||
loading, | ||
error, | ||
data | ||
}; | ||
}; | ||
const DataQuery = ({ | ||
query, | ||
onComplete, | ||
onError, | ||
variables, | ||
children | ||
}) => { | ||
const queryState = useDataQuery(query); | ||
const queryState = useDataQuery(query, { | ||
onComplete, | ||
onError, | ||
variables | ||
}); | ||
return children(queryState); | ||
}; | ||
const empty$1 = {}; | ||
const useDataMutation = (mutation, { | ||
onComplete, | ||
onError, | ||
variables = empty$1 | ||
} = {}) => { | ||
const engine = useDataEngine(); | ||
const [theMutation] = useStaticInput(mutation, { | ||
warn: true, | ||
name: 'mutation' | ||
}); | ||
const execute = React.useCallback(options => engine.mutate(theMutation, options), [engine, theMutation]); | ||
const { | ||
refetch: mutate, | ||
called, | ||
loading, | ||
error, | ||
data | ||
} = useQueryExecutor({ | ||
execute, | ||
variables, | ||
singular: false, | ||
immediate: false, | ||
onComplete, | ||
onError | ||
}); | ||
return [mutate, { | ||
engine, | ||
called, | ||
loading, | ||
error, | ||
data | ||
}]; | ||
}; | ||
const DataMutation = ({ | ||
mutation, | ||
onComplete, | ||
onError, | ||
variables, | ||
children | ||
}) => { | ||
const mutationState = useDataMutation(mutation, { | ||
onComplete, | ||
onError, | ||
variables | ||
}); | ||
return children(mutationState); | ||
}; | ||
const ConfigContext = React__default.createContext({ | ||
@@ -145,3 +394,80 @@ baseUrl: '..', | ||
function _defineProperty$1(obj, key, value) { | ||
const joinPath = (...parts) => { | ||
const realParts = parts.filter(part => !!part); | ||
return realParts.map(part => part.replace(/^\/+|\/+$/g, '')).join('/'); | ||
}; | ||
const encodeQueryParameter = param => { | ||
if (Array.isArray(param)) { | ||
return param.map(encodeQueryParameter).join(','); | ||
} | ||
if (typeof param === 'string') { | ||
return encodeURIComponent(param); | ||
} | ||
if (typeof param === 'number') { | ||
return String(param); | ||
} | ||
if (typeof param === 'object') { | ||
throw new Error('Object parameter mappings not yet implemented'); | ||
} | ||
throw new Error('Unknown parameter type'); | ||
}; | ||
const queryParametersToQueryString = params => Object.keys(params).filter(key => key && params[key]).map(key => `${encodeURIComponent(key)}=${encodeQueryParameter(params[key])}`).join('&'); | ||
const actionPrefix = 'action::'; | ||
const isAction = resource => resource.startsWith(actionPrefix); | ||
const makeActionPath = resource => joinPath('dhis-web-commons', `${resource.substr(actionPrefix.length)}.action`); | ||
const queryToResourcePath = (apiPath, { | ||
resource, | ||
id, | ||
params = {} | ||
}) => { | ||
const base = isAction(resource) ? makeActionPath(resource) : joinPath(apiPath, resource, id); | ||
if (Object.keys(params).length) { | ||
return `${base}?${queryParametersToQueryString(params)}`; | ||
} | ||
return base; | ||
}; | ||
const getMethod = type => { | ||
switch (type) { | ||
case 'create': | ||
return 'POST'; | ||
case 'read': | ||
return 'GET'; | ||
case 'update': | ||
return 'PATCH'; | ||
case 'replace': | ||
return 'PUT'; | ||
case 'delete': | ||
return 'DELETE'; | ||
} | ||
}; | ||
const queryToRequestOptions = (type, { | ||
data | ||
}, signal) => ({ | ||
method: getMethod(type), | ||
body: data ? JSON.stringify(data) : undefined, | ||
headers: data ? { | ||
'Content-Type': 'application/json' | ||
} : undefined, | ||
signal | ||
}); | ||
function _defineProperty$3(obj, key, value) { | ||
if (key in obj) { | ||
@@ -169,5 +495,5 @@ Object.defineProperty(obj, key, { | ||
_defineProperty$1(this, "type", void 0); | ||
_defineProperty$3(this, "type", void 0); | ||
_defineProperty$1(this, "details", void 0); | ||
_defineProperty$3(this, "details", void 0); | ||
@@ -200,3 +526,3 @@ this.type = type; | ||
ownKeys$1(source, true).forEach(function (key) { | ||
_defineProperty$2(target, key, source[key]); | ||
_defineProperty$4(target, key, source[key]); | ||
}); | ||
@@ -215,3 +541,3 @@ } else if (Object.getOwnPropertyDescriptors) { | ||
function _defineProperty$2(obj, key, value) { | ||
function _defineProperty$4(obj, key, value) { | ||
if (key in obj) { | ||
@@ -230,3 +556,27 @@ Object.defineProperty(obj, key, { | ||
} | ||
function fetchData$1(url, options = {}) { | ||
const parseStatus = async response => { | ||
if (response.status === 401 || response.status === 403 || response.status === 409) { | ||
const message = await response.json().then(body => { | ||
return body.message; | ||
}).catch(() => { | ||
return response.status === 401 ? 'Unauthorized' : 'Forbidden'; | ||
}); | ||
throw new FetchError({ | ||
type: 'access', | ||
message, | ||
details: response | ||
}); | ||
} | ||
if (response.status < 200 || response.status >= 400) { | ||
throw new FetchError({ | ||
type: 'unknown', | ||
message: `An unknown error occurred - ${response.statusText} (${response.status})`, | ||
details: response | ||
}); | ||
} | ||
return response; | ||
}; | ||
function fetchData(url, options = {}) { | ||
return fetch(url, _objectSpread$1({}, options, { | ||
@@ -244,123 +594,53 @@ credentials: 'include', | ||
}); | ||
}).then(response => { | ||
if (response.status === 401 || response.status === 403 || response.status === 409) { | ||
throw new FetchError({ | ||
type: 'access', | ||
message: response.statusText, | ||
details: response | ||
}); | ||
}).then(parseStatus).then(async response => { | ||
if (response.headers.get('Content-Type') === 'application/json') { | ||
return await response.json(); // Will throw if invalid JSON! | ||
} | ||
if (response.status < 200 || response.status >= 400) { | ||
throw new FetchError({ | ||
type: 'unknown', | ||
message: `An unknown error occurred - ${response.statusText} (${response.status})`, | ||
details: response | ||
}); | ||
} | ||
return response; | ||
}).then(response => response.json()); | ||
return await response.text(); | ||
}); | ||
} | ||
const joinPath = (...parts) => { | ||
return parts.map(part => part.replace(/^\/+|\/+$/g, '')).join('/'); | ||
}; | ||
function _objectWithoutProperties(source, excluded) { | ||
if (source == null) return {}; | ||
var target = _objectWithoutPropertiesLoose(source, excluded); | ||
var key, i; | ||
if (Object.getOwnPropertySymbols) { | ||
var sourceSymbolKeys = Object.getOwnPropertySymbols(source); | ||
for (i = 0; i < sourceSymbolKeys.length; i++) { | ||
key = sourceSymbolKeys[i]; | ||
if (excluded.indexOf(key) >= 0) continue; | ||
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; | ||
target[key] = source[key]; | ||
} | ||
function _defineProperty$5(obj, key, value) { | ||
if (key in obj) { | ||
Object.defineProperty(obj, key, { | ||
value: value, | ||
enumerable: true, | ||
configurable: true, | ||
writable: true | ||
}); | ||
} else { | ||
obj[key] = value; | ||
} | ||
return target; | ||
return obj; | ||
} | ||
class RestAPILink { | ||
constructor({ | ||
baseUrl, | ||
apiVersion | ||
}) { | ||
_defineProperty$5(this, "apiPath", void 0); | ||
function _objectWithoutPropertiesLoose(source, excluded) { | ||
if (source == null) return {}; | ||
var target = {}; | ||
var sourceKeys = Object.keys(source); | ||
var key, i; | ||
_defineProperty$5(this, "baseUrl", void 0); | ||
for (i = 0; i < sourceKeys.length; i++) { | ||
key = sourceKeys[i]; | ||
if (excluded.indexOf(key) >= 0) continue; | ||
target[key] = source[key]; | ||
} | ||
_defineProperty$5(this, "apiVersion", void 0); | ||
return target; | ||
} | ||
const encodeQueryParameter = param => { | ||
if (Array.isArray(param)) { | ||
return param.map(encodeQueryParameter).join(','); | ||
this.baseUrl = baseUrl; | ||
this.apiVersion = apiVersion; | ||
this.apiPath = joinPath('api', String(apiVersion)); | ||
} | ||
if (typeof param === 'string') { | ||
return encodeURIComponent(param); | ||
fetch(path, options) { | ||
return fetchData(joinPath(this.baseUrl, path), options); | ||
} | ||
if (typeof param === 'number') { | ||
return String(param); | ||
executeResourceQuery(type, query, { | ||
signal | ||
}) { | ||
return this.fetch(queryToResourcePath(this.apiPath, query), queryToRequestOptions(type, query, signal)); | ||
} | ||
if (typeof param === 'object') { | ||
throw new Error('Object parameter mappings not yet implemented'); | ||
} | ||
} | ||
throw new Error('Unknown parameter type'); | ||
}; | ||
const queryParametersToQueryString = params => Object.keys(params).filter(key => key && params[key]).map(key => `${encodeURIComponent(key)}=${encodeQueryParameter(params[key])}`).join('&'); | ||
const actionPrefix = 'action::'; | ||
const isAction = resource => resource.startsWith(actionPrefix); | ||
const makeActionURL = (baseUrl, resource) => joinPath(baseUrl, 'dhis-web-commons', `${resource.substr(actionPrefix.length)}.action`); | ||
const queryToResourceUrl = (_ref, { | ||
baseUrl, | ||
apiUrl | ||
}) => { | ||
let { | ||
resource | ||
} = _ref, | ||
params = _objectWithoutProperties(_ref, ["resource"]); | ||
const base = isAction(resource) ? makeActionURL(baseUrl, resource) : joinPath(apiUrl, resource); | ||
if (Object.keys(params).length) { | ||
return `${base}?${queryParametersToQueryString(params)}`; | ||
} | ||
return base; | ||
}; | ||
const makeContext$1 = ({ | ||
baseUrl, | ||
apiVersion | ||
}) => { | ||
const apiUrl = joinPath(baseUrl, 'api', String(apiVersion)); | ||
const context = { | ||
baseUrl, | ||
apiVersion, | ||
apiUrl, | ||
fetch: (query, options) => fetchData$1(joinPath(queryToResourceUrl(query, context)), options) | ||
}; | ||
return context; | ||
}; | ||
function ownKeys$2(object, enumerableOnly) { | ||
@@ -386,3 +666,3 @@ var keys = Object.keys(object); | ||
ownKeys$2(source, true).forEach(function (key) { | ||
_defineProperty$3(target, key, source[key]); | ||
_defineProperty$6(target, key, source[key]); | ||
}); | ||
@@ -401,3 +681,3 @@ } else if (Object.getOwnPropertyDescriptors) { | ||
function _defineProperty$3(obj, key, value) { | ||
function _defineProperty$6(obj, key, value) { | ||
if (key in obj) { | ||
@@ -419,73 +699,78 @@ Object.defineProperty(obj, key, { | ||
const link = new RestAPILink(config); | ||
const engine = new DataEngine(link); | ||
const context = { | ||
engine | ||
}; | ||
return React__default.createElement(DataContext.Provider, { | ||
value: makeContext$1(config) | ||
value: context | ||
}, props.children); | ||
}; | ||
const baseUrl = 'https://example.com'; | ||
const apiVersion = 42; | ||
function _defineProperty$7(obj, key, value) { | ||
if (key in obj) { | ||
Object.defineProperty(obj, key, { | ||
value: value, | ||
enumerable: true, | ||
configurable: true, | ||
writable: true | ||
}); | ||
} else { | ||
obj[key] = value; | ||
} | ||
const resolveCustomResource = async (customResource, query, { | ||
failOnMiss, | ||
options | ||
}) => { | ||
switch (typeof customResource) { | ||
case 'string': | ||
case 'number': | ||
case 'boolean': | ||
case 'object': | ||
return customResource; | ||
return obj; | ||
} | ||
case 'function': | ||
// function | ||
const result = await customResource(query, options); | ||
class CustomDataLink { | ||
constructor(customData, { | ||
failOnMiss = true, | ||
loadForever = false | ||
} = {}) { | ||
_defineProperty$7(this, "failOnMiss", void 0); | ||
if (!result && failOnMiss) { | ||
throw new Error(`The custom function for resource ${query.resource} must always return a value but returned ${result}`); | ||
} | ||
_defineProperty$7(this, "loadForever", void 0); | ||
return result || {}; | ||
_defineProperty$7(this, "data", void 0); | ||
default: | ||
// should be unreachable | ||
throw new Error(`Unknown resource type ${typeof customResource}`); | ||
this.data = customData; | ||
this.failOnMiss = failOnMiss; | ||
this.loadForever = loadForever; | ||
} | ||
}; | ||
const makeCustomContext = (customData, { | ||
failOnMiss = true, | ||
loadForever = false | ||
} = {}) => { | ||
const apiUrl = joinPath(baseUrl, 'api', String(apiVersion)); | ||
async executeResourceQuery(type, query, options) { | ||
if (this.loadForever) { | ||
return new Promise(() => {}); | ||
} | ||
const customFetch = async (query, options) => { | ||
const customResource = customData[query.resource]; | ||
const customResource = this.data[query.resource]; | ||
if (!customResource) { | ||
if (failOnMiss) { | ||
if (this.failOnMiss) { | ||
throw new Error(`No data provided for resource type ${query.resource}!`); | ||
} | ||
return Promise.resolve({}); | ||
return Promise.resolve(null); | ||
} | ||
return await resolveCustomResource(customResource, query, { | ||
failOnMiss, | ||
options | ||
}); | ||
}; | ||
switch (typeof customResource) { | ||
case 'string': | ||
case 'number': | ||
case 'boolean': | ||
case 'object': | ||
return customResource; | ||
const foreverLoadingFetch = async () => { | ||
return new Promise(() => {}); // Load forever | ||
}; | ||
case 'function': | ||
const result = await customResource(type, query, options); | ||
const context = { | ||
baseUrl, | ||
apiVersion, | ||
apiUrl, | ||
fetch: loadForever ? foreverLoadingFetch : customFetch | ||
}; | ||
return context; | ||
}; | ||
if (typeof result === 'undefined' && this.failOnMiss) { | ||
throw new Error(`The custom function for resource ${query.resource} must always return a value but returned ${result}`); | ||
} | ||
return result || null; | ||
} | ||
} | ||
} | ||
const CustomDataProvider = ({ | ||
@@ -496,3 +781,7 @@ children, | ||
}) => { | ||
const context = makeCustomContext(data, options); | ||
const link = new CustomDataLink(data, options); | ||
const engine = new DataEngine(link); | ||
const context = { | ||
engine | ||
}; | ||
return React__default.createElement(DataContext.Provider, { | ||
@@ -513,2 +802,3 @@ value: context | ||
exports.CustomDataProvider = CustomDataProvider; | ||
exports.DataMutation = DataMutation; | ||
exports.DataProvider = DataProvider; | ||
@@ -518,2 +808,4 @@ exports.DataQuery = DataQuery; | ||
exports.useConfig = useConfig; | ||
exports.useDataEngine = useDataEngine; | ||
exports.useDataMutation = useDataMutation; | ||
exports.useDataQuery = useDataQuery; |
@@ -1,16 +0,17 @@ | ||
import React, { useContext, useState, useCallback, useEffect } from 'react'; | ||
import React, { useRef, useState, useEffect, useCallback, useContext } from 'react'; | ||
const uninitializedFetch = async () => { | ||
throw new Error('DHIS2 data context must be initialized, please ensure that you include a <DataProvider> in your application'); | ||
const useStaticInput = (staticValue, { | ||
warn = false, | ||
name = 'input' | ||
} = {}) => { | ||
const originalValue = useRef(staticValue); | ||
const [value, setValue] = useState(() => originalValue.current); | ||
useEffect(() => { | ||
if (warn && originalValue.current !== staticValue) { | ||
console.warn(`The ${name} should be static, don't create it within the render loop!`); | ||
} | ||
}, [warn, staticValue, originalValue, name]); | ||
return [value, setValue]; | ||
}; | ||
const defaultContext = { | ||
baseUrl: '', | ||
apiVersion: 0, | ||
apiUrl: '', | ||
fetch: uninitializedFetch | ||
}; | ||
const DataContext = React.createContext(defaultContext); | ||
function ownKeys(object, enumerableOnly) { | ||
@@ -64,3 +65,107 @@ var keys = Object.keys(object); | ||
} | ||
const useQueryExecutor = ({ | ||
execute, | ||
variables: initialVariables, | ||
singular, | ||
immediate, | ||
onComplete, | ||
onError | ||
}) => { | ||
const [theExecute] = useStaticInput(execute); | ||
const [state, setState] = useState({ | ||
called: !!immediate, | ||
loading: !!immediate | ||
}); | ||
const variables = useRef(initialVariables); | ||
const abortControllersRef = useRef([]); | ||
const abort = () => { | ||
abortControllersRef.current.forEach(controller => controller.abort()); | ||
abortControllersRef.current = []; | ||
}; | ||
const refetch = useCallback((newVariables = {}) => { | ||
setState(state => !state.called || !state.loading ? { | ||
called: true, | ||
loading: true | ||
} : state); | ||
if (singular) { | ||
abort(); // Cleanup any in-progress fetches | ||
} | ||
const controller = new AbortController(); | ||
abortControllersRef.current.push(controller); | ||
variables.current = _objectSpread({}, variables.current, {}, newVariables); | ||
const options = { | ||
variables: variables.current, | ||
signal: controller.signal, | ||
onComplete, | ||
onError | ||
}; | ||
return theExecute(options).then(data => { | ||
if (!controller.signal.aborted) { | ||
setState({ | ||
called: true, | ||
loading: false, | ||
data | ||
}); | ||
return data; | ||
} | ||
return new Promise(() => {}); | ||
}).catch(error => { | ||
if (!controller.signal.aborted) { | ||
setState({ | ||
called: true, | ||
loading: false, | ||
error | ||
}); | ||
} | ||
return new Promise(() => {}); // Don't throw errors in refetch promises, wait forever | ||
}); | ||
}, [onComplete, onError, singular, theExecute]); | ||
useEffect(() => { | ||
if (immediate) { | ||
refetch(); | ||
} | ||
return abort; | ||
}, [immediate, refetch]); | ||
return _objectSpread({ | ||
refetch, | ||
abort | ||
}, state); | ||
}; | ||
const resolveDynamicQuery = ({ | ||
resource, | ||
id, | ||
data, | ||
params | ||
}, variables) => ({ | ||
resource, | ||
id: typeof id === 'function' ? id(variables) : id, | ||
data: typeof data === 'function' ? data(variables) : data, | ||
params: typeof params === 'function' ? params(variables) : params | ||
}); | ||
const getMutationFetchType = mutation => mutation.type === 'update' ? mutation.partial ? 'update' : 'replace' : mutation.type; | ||
function _defineProperty$1(obj, key, value) { | ||
if (key in obj) { | ||
Object.defineProperty(obj, key, { | ||
value: value, | ||
enumerable: true, | ||
configurable: true, | ||
writable: true | ||
}); | ||
} else { | ||
obj[key] = value; | ||
} | ||
return obj; | ||
} | ||
const reduceResponses = (responses, names) => responses.reduce((out, response, idx) => { | ||
@@ -71,51 +176,195 @@ out[names[idx]] = response; | ||
const fetchData = (context, query, signal) => { | ||
const names = Object.keys(query); | ||
const requests = names.map(name => query[name]); | ||
const requestPromises = requests.map(q => context.fetch(q, { | ||
signal: signal | ||
})); | ||
return Promise.all(requestPromises).then(responses => reduceResponses(responses, names)); | ||
}; | ||
class DataEngine { | ||
constructor(link) { | ||
_defineProperty$1(this, "link", void 0); | ||
const useDataQuery = query => { | ||
const context = useContext(DataContext); | ||
const [state, setState] = useState({ | ||
loading: true | ||
}); | ||
const [refetchCount, setRefetchCount] = useState(0); | ||
const refetch = useCallback(() => setRefetchCount(count => count + 1), []); | ||
useEffect(() => { | ||
const controller = new AbortController(); | ||
this.link = link; | ||
} | ||
const abort = () => controller.abort(); | ||
fetchData(context, query, controller.signal).then(data => { | ||
!controller.signal.aborted && setState({ | ||
loading: false, | ||
data | ||
query(query, { | ||
variables = {}, | ||
signal, | ||
onComplete, | ||
onError | ||
} = {}) { | ||
const names = Object.keys(query); | ||
const queries = names.map(name => query[name]); | ||
return Promise.all(queries.map(q => { | ||
const resolvedQuery = resolveDynamicQuery(q, variables); | ||
return this.link.executeResourceQuery('read', resolvedQuery, { | ||
signal | ||
}); | ||
})).then(results => { | ||
const data = reduceResponses(results, names); | ||
onComplete && onComplete(data); | ||
return data; | ||
}).catch(error => { | ||
!controller.signal.aborted && setState({ | ||
loading: false, | ||
error | ||
}); | ||
}); // Cleanup inflight requests | ||
onError && onError(error); | ||
throw error; | ||
}); | ||
} | ||
return abort; | ||
}, [context, refetchCount]); // eslint-disable-line react-hooks/exhaustive-deps | ||
mutate(mutation, { | ||
variables = {}, | ||
signal, | ||
onComplete, | ||
onError | ||
} = {}) { | ||
const query = resolveDynamicQuery(mutation, variables); | ||
const result = this.link.executeResourceQuery(getMutationFetchType(mutation), query, { | ||
signal | ||
}); | ||
return result.then(data => { | ||
onComplete && onComplete(data); | ||
return data; | ||
}).catch(error => { | ||
onError && onError(error); | ||
throw error; | ||
}); | ||
} | ||
return _objectSpread({ | ||
refetch | ||
}, state); | ||
} | ||
function _defineProperty$2(obj, key, value) { | ||
if (key in obj) { | ||
Object.defineProperty(obj, key, { | ||
value: value, | ||
enumerable: true, | ||
configurable: true, | ||
writable: true | ||
}); | ||
} else { | ||
obj[key] = value; | ||
} | ||
return obj; | ||
} | ||
class ErrorLink { | ||
constructor(errorMessage) { | ||
_defineProperty$2(this, "errorMessage", void 0); | ||
this.errorMessage = errorMessage; | ||
} | ||
executeResourceQuery() { | ||
console.error(this.errorMessage); | ||
return Promise.reject(this.errorMessage); | ||
} | ||
} | ||
const errorMessage = 'DHIS2 data context must be initialized, please ensure that you include a <DataProvider> in your application'; | ||
const link = new ErrorLink(errorMessage); | ||
const engine = new DataEngine(link); | ||
const defaultContext = { | ||
engine | ||
}; | ||
const DataContext = React.createContext(defaultContext); | ||
const useDataEngine = () => { | ||
const context = useContext(DataContext); | ||
return context.engine; | ||
}; | ||
const empty = {}; | ||
const useDataQuery = (query, { | ||
onComplete, | ||
onError, | ||
variables = empty | ||
} = {}) => { | ||
const engine = useDataEngine(); | ||
const [theQuery] = useStaticInput(query, { | ||
warn: true, | ||
name: 'query' | ||
}); | ||
const execute = useCallback(options => engine.query(theQuery, options), [engine, theQuery]); | ||
const { | ||
refetch, | ||
loading, | ||
error, | ||
data | ||
} = useQueryExecutor({ | ||
execute, | ||
variables, | ||
singular: true, | ||
immediate: true, | ||
onComplete, | ||
onError | ||
}); | ||
return { | ||
engine, | ||
refetch, | ||
loading, | ||
error, | ||
data | ||
}; | ||
}; | ||
const DataQuery = ({ | ||
query, | ||
onComplete, | ||
onError, | ||
variables, | ||
children | ||
}) => { | ||
const queryState = useDataQuery(query); | ||
const queryState = useDataQuery(query, { | ||
onComplete, | ||
onError, | ||
variables | ||
}); | ||
return children(queryState); | ||
}; | ||
const empty$1 = {}; | ||
const useDataMutation = (mutation, { | ||
onComplete, | ||
onError, | ||
variables = empty$1 | ||
} = {}) => { | ||
const engine = useDataEngine(); | ||
const [theMutation] = useStaticInput(mutation, { | ||
warn: true, | ||
name: 'mutation' | ||
}); | ||
const execute = useCallback(options => engine.mutate(theMutation, options), [engine, theMutation]); | ||
const { | ||
refetch: mutate, | ||
called, | ||
loading, | ||
error, | ||
data | ||
} = useQueryExecutor({ | ||
execute, | ||
variables, | ||
singular: false, | ||
immediate: false, | ||
onComplete, | ||
onError | ||
}); | ||
return [mutate, { | ||
engine, | ||
called, | ||
loading, | ||
error, | ||
data | ||
}]; | ||
}; | ||
const DataMutation = ({ | ||
mutation, | ||
onComplete, | ||
onError, | ||
variables, | ||
children | ||
}) => { | ||
const mutationState = useDataMutation(mutation, { | ||
onComplete, | ||
onError, | ||
variables | ||
}); | ||
return children(mutationState); | ||
}; | ||
const ConfigContext = React.createContext({ | ||
@@ -137,3 +386,80 @@ baseUrl: '..', | ||
function _defineProperty$1(obj, key, value) { | ||
const joinPath = (...parts) => { | ||
const realParts = parts.filter(part => !!part); | ||
return realParts.map(part => part.replace(/^\/+|\/+$/g, '')).join('/'); | ||
}; | ||
const encodeQueryParameter = param => { | ||
if (Array.isArray(param)) { | ||
return param.map(encodeQueryParameter).join(','); | ||
} | ||
if (typeof param === 'string') { | ||
return encodeURIComponent(param); | ||
} | ||
if (typeof param === 'number') { | ||
return String(param); | ||
} | ||
if (typeof param === 'object') { | ||
throw new Error('Object parameter mappings not yet implemented'); | ||
} | ||
throw new Error('Unknown parameter type'); | ||
}; | ||
const queryParametersToQueryString = params => Object.keys(params).filter(key => key && params[key]).map(key => `${encodeURIComponent(key)}=${encodeQueryParameter(params[key])}`).join('&'); | ||
const actionPrefix = 'action::'; | ||
const isAction = resource => resource.startsWith(actionPrefix); | ||
const makeActionPath = resource => joinPath('dhis-web-commons', `${resource.substr(actionPrefix.length)}.action`); | ||
const queryToResourcePath = (apiPath, { | ||
resource, | ||
id, | ||
params = {} | ||
}) => { | ||
const base = isAction(resource) ? makeActionPath(resource) : joinPath(apiPath, resource, id); | ||
if (Object.keys(params).length) { | ||
return `${base}?${queryParametersToQueryString(params)}`; | ||
} | ||
return base; | ||
}; | ||
const getMethod = type => { | ||
switch (type) { | ||
case 'create': | ||
return 'POST'; | ||
case 'read': | ||
return 'GET'; | ||
case 'update': | ||
return 'PATCH'; | ||
case 'replace': | ||
return 'PUT'; | ||
case 'delete': | ||
return 'DELETE'; | ||
} | ||
}; | ||
const queryToRequestOptions = (type, { | ||
data | ||
}, signal) => ({ | ||
method: getMethod(type), | ||
body: data ? JSON.stringify(data) : undefined, | ||
headers: data ? { | ||
'Content-Type': 'application/json' | ||
} : undefined, | ||
signal | ||
}); | ||
function _defineProperty$3(obj, key, value) { | ||
if (key in obj) { | ||
@@ -161,5 +487,5 @@ Object.defineProperty(obj, key, { | ||
_defineProperty$1(this, "type", void 0); | ||
_defineProperty$3(this, "type", void 0); | ||
_defineProperty$1(this, "details", void 0); | ||
_defineProperty$3(this, "details", void 0); | ||
@@ -192,3 +518,3 @@ this.type = type; | ||
ownKeys$1(source, true).forEach(function (key) { | ||
_defineProperty$2(target, key, source[key]); | ||
_defineProperty$4(target, key, source[key]); | ||
}); | ||
@@ -207,3 +533,3 @@ } else if (Object.getOwnPropertyDescriptors) { | ||
function _defineProperty$2(obj, key, value) { | ||
function _defineProperty$4(obj, key, value) { | ||
if (key in obj) { | ||
@@ -222,3 +548,27 @@ Object.defineProperty(obj, key, { | ||
} | ||
function fetchData$1(url, options = {}) { | ||
const parseStatus = async response => { | ||
if (response.status === 401 || response.status === 403 || response.status === 409) { | ||
const message = await response.json().then(body => { | ||
return body.message; | ||
}).catch(() => { | ||
return response.status === 401 ? 'Unauthorized' : 'Forbidden'; | ||
}); | ||
throw new FetchError({ | ||
type: 'access', | ||
message, | ||
details: response | ||
}); | ||
} | ||
if (response.status < 200 || response.status >= 400) { | ||
throw new FetchError({ | ||
type: 'unknown', | ||
message: `An unknown error occurred - ${response.statusText} (${response.status})`, | ||
details: response | ||
}); | ||
} | ||
return response; | ||
}; | ||
function fetchData(url, options = {}) { | ||
return fetch(url, _objectSpread$1({}, options, { | ||
@@ -236,123 +586,53 @@ credentials: 'include', | ||
}); | ||
}).then(response => { | ||
if (response.status === 401 || response.status === 403 || response.status === 409) { | ||
throw new FetchError({ | ||
type: 'access', | ||
message: response.statusText, | ||
details: response | ||
}); | ||
}).then(parseStatus).then(async response => { | ||
if (response.headers.get('Content-Type') === 'application/json') { | ||
return await response.json(); // Will throw if invalid JSON! | ||
} | ||
if (response.status < 200 || response.status >= 400) { | ||
throw new FetchError({ | ||
type: 'unknown', | ||
message: `An unknown error occurred - ${response.statusText} (${response.status})`, | ||
details: response | ||
}); | ||
} | ||
return response; | ||
}).then(response => response.json()); | ||
return await response.text(); | ||
}); | ||
} | ||
const joinPath = (...parts) => { | ||
return parts.map(part => part.replace(/^\/+|\/+$/g, '')).join('/'); | ||
}; | ||
function _objectWithoutProperties(source, excluded) { | ||
if (source == null) return {}; | ||
var target = _objectWithoutPropertiesLoose(source, excluded); | ||
var key, i; | ||
if (Object.getOwnPropertySymbols) { | ||
var sourceSymbolKeys = Object.getOwnPropertySymbols(source); | ||
for (i = 0; i < sourceSymbolKeys.length; i++) { | ||
key = sourceSymbolKeys[i]; | ||
if (excluded.indexOf(key) >= 0) continue; | ||
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; | ||
target[key] = source[key]; | ||
} | ||
function _defineProperty$5(obj, key, value) { | ||
if (key in obj) { | ||
Object.defineProperty(obj, key, { | ||
value: value, | ||
enumerable: true, | ||
configurable: true, | ||
writable: true | ||
}); | ||
} else { | ||
obj[key] = value; | ||
} | ||
return target; | ||
return obj; | ||
} | ||
class RestAPILink { | ||
constructor({ | ||
baseUrl, | ||
apiVersion | ||
}) { | ||
_defineProperty$5(this, "apiPath", void 0); | ||
function _objectWithoutPropertiesLoose(source, excluded) { | ||
if (source == null) return {}; | ||
var target = {}; | ||
var sourceKeys = Object.keys(source); | ||
var key, i; | ||
_defineProperty$5(this, "baseUrl", void 0); | ||
for (i = 0; i < sourceKeys.length; i++) { | ||
key = sourceKeys[i]; | ||
if (excluded.indexOf(key) >= 0) continue; | ||
target[key] = source[key]; | ||
} | ||
_defineProperty$5(this, "apiVersion", void 0); | ||
return target; | ||
} | ||
const encodeQueryParameter = param => { | ||
if (Array.isArray(param)) { | ||
return param.map(encodeQueryParameter).join(','); | ||
this.baseUrl = baseUrl; | ||
this.apiVersion = apiVersion; | ||
this.apiPath = joinPath('api', String(apiVersion)); | ||
} | ||
if (typeof param === 'string') { | ||
return encodeURIComponent(param); | ||
fetch(path, options) { | ||
return fetchData(joinPath(this.baseUrl, path), options); | ||
} | ||
if (typeof param === 'number') { | ||
return String(param); | ||
executeResourceQuery(type, query, { | ||
signal | ||
}) { | ||
return this.fetch(queryToResourcePath(this.apiPath, query), queryToRequestOptions(type, query, signal)); | ||
} | ||
if (typeof param === 'object') { | ||
throw new Error('Object parameter mappings not yet implemented'); | ||
} | ||
} | ||
throw new Error('Unknown parameter type'); | ||
}; | ||
const queryParametersToQueryString = params => Object.keys(params).filter(key => key && params[key]).map(key => `${encodeURIComponent(key)}=${encodeQueryParameter(params[key])}`).join('&'); | ||
const actionPrefix = 'action::'; | ||
const isAction = resource => resource.startsWith(actionPrefix); | ||
const makeActionURL = (baseUrl, resource) => joinPath(baseUrl, 'dhis-web-commons', `${resource.substr(actionPrefix.length)}.action`); | ||
const queryToResourceUrl = (_ref, { | ||
baseUrl, | ||
apiUrl | ||
}) => { | ||
let { | ||
resource | ||
} = _ref, | ||
params = _objectWithoutProperties(_ref, ["resource"]); | ||
const base = isAction(resource) ? makeActionURL(baseUrl, resource) : joinPath(apiUrl, resource); | ||
if (Object.keys(params).length) { | ||
return `${base}?${queryParametersToQueryString(params)}`; | ||
} | ||
return base; | ||
}; | ||
const makeContext$1 = ({ | ||
baseUrl, | ||
apiVersion | ||
}) => { | ||
const apiUrl = joinPath(baseUrl, 'api', String(apiVersion)); | ||
const context = { | ||
baseUrl, | ||
apiVersion, | ||
apiUrl, | ||
fetch: (query, options) => fetchData$1(joinPath(queryToResourceUrl(query, context)), options) | ||
}; | ||
return context; | ||
}; | ||
function ownKeys$2(object, enumerableOnly) { | ||
@@ -378,3 +658,3 @@ var keys = Object.keys(object); | ||
ownKeys$2(source, true).forEach(function (key) { | ||
_defineProperty$3(target, key, source[key]); | ||
_defineProperty$6(target, key, source[key]); | ||
}); | ||
@@ -393,3 +673,3 @@ } else if (Object.getOwnPropertyDescriptors) { | ||
function _defineProperty$3(obj, key, value) { | ||
function _defineProperty$6(obj, key, value) { | ||
if (key in obj) { | ||
@@ -411,73 +691,78 @@ Object.defineProperty(obj, key, { | ||
const link = new RestAPILink(config); | ||
const engine = new DataEngine(link); | ||
const context = { | ||
engine | ||
}; | ||
return React.createElement(DataContext.Provider, { | ||
value: makeContext$1(config) | ||
value: context | ||
}, props.children); | ||
}; | ||
const baseUrl = 'https://example.com'; | ||
const apiVersion = 42; | ||
function _defineProperty$7(obj, key, value) { | ||
if (key in obj) { | ||
Object.defineProperty(obj, key, { | ||
value: value, | ||
enumerable: true, | ||
configurable: true, | ||
writable: true | ||
}); | ||
} else { | ||
obj[key] = value; | ||
} | ||
const resolveCustomResource = async (customResource, query, { | ||
failOnMiss, | ||
options | ||
}) => { | ||
switch (typeof customResource) { | ||
case 'string': | ||
case 'number': | ||
case 'boolean': | ||
case 'object': | ||
return customResource; | ||
return obj; | ||
} | ||
case 'function': | ||
// function | ||
const result = await customResource(query, options); | ||
class CustomDataLink { | ||
constructor(customData, { | ||
failOnMiss = true, | ||
loadForever = false | ||
} = {}) { | ||
_defineProperty$7(this, "failOnMiss", void 0); | ||
if (!result && failOnMiss) { | ||
throw new Error(`The custom function for resource ${query.resource} must always return a value but returned ${result}`); | ||
} | ||
_defineProperty$7(this, "loadForever", void 0); | ||
return result || {}; | ||
_defineProperty$7(this, "data", void 0); | ||
default: | ||
// should be unreachable | ||
throw new Error(`Unknown resource type ${typeof customResource}`); | ||
this.data = customData; | ||
this.failOnMiss = failOnMiss; | ||
this.loadForever = loadForever; | ||
} | ||
}; | ||
const makeCustomContext = (customData, { | ||
failOnMiss = true, | ||
loadForever = false | ||
} = {}) => { | ||
const apiUrl = joinPath(baseUrl, 'api', String(apiVersion)); | ||
async executeResourceQuery(type, query, options) { | ||
if (this.loadForever) { | ||
return new Promise(() => {}); | ||
} | ||
const customFetch = async (query, options) => { | ||
const customResource = customData[query.resource]; | ||
const customResource = this.data[query.resource]; | ||
if (!customResource) { | ||
if (failOnMiss) { | ||
if (this.failOnMiss) { | ||
throw new Error(`No data provided for resource type ${query.resource}!`); | ||
} | ||
return Promise.resolve({}); | ||
return Promise.resolve(null); | ||
} | ||
return await resolveCustomResource(customResource, query, { | ||
failOnMiss, | ||
options | ||
}); | ||
}; | ||
switch (typeof customResource) { | ||
case 'string': | ||
case 'number': | ||
case 'boolean': | ||
case 'object': | ||
return customResource; | ||
const foreverLoadingFetch = async () => { | ||
return new Promise(() => {}); // Load forever | ||
}; | ||
case 'function': | ||
const result = await customResource(type, query, options); | ||
const context = { | ||
baseUrl, | ||
apiVersion, | ||
apiUrl, | ||
fetch: loadForever ? foreverLoadingFetch : customFetch | ||
}; | ||
return context; | ||
}; | ||
if (typeof result === 'undefined' && this.failOnMiss) { | ||
throw new Error(`The custom function for resource ${query.resource} must always return a value but returned ${result}`); | ||
} | ||
return result || null; | ||
} | ||
} | ||
} | ||
const CustomDataProvider = ({ | ||
@@ -488,3 +773,7 @@ children, | ||
}) => { | ||
const context = makeCustomContext(data, options); | ||
const link = new CustomDataLink(data, options); | ||
const engine = new DataEngine(link); | ||
const context = { | ||
engine | ||
}; | ||
return React.createElement(DataContext.Provider, { | ||
@@ -504,2 +793,2 @@ value: context | ||
export { CustomDataProvider, DataProvider, DataQuery, Provider, useConfig, useDataQuery }; | ||
export { CustomDataProvider, DataMutation, DataProvider, DataQuery, Provider, useConfig, useDataEngine, useDataMutation, useDataQuery }; |
{ | ||
"name": "@dhis2/app-runtime", | ||
"description": "A singular runtime dependency for applications on the DHIS2 platform", | ||
"version": "1.5.1", | ||
"version": "2.0.0", | ||
"main": "build/cjs/index.js", | ||
@@ -22,4 +22,4 @@ "module": "build/es/index.js", | ||
"devDependencies": { | ||
"@dhis2/app-service-config": "1.5.1", | ||
"@dhis2/app-service-data": "1.5.1" | ||
"@dhis2/app-service-config": "2.0.0", | ||
"@dhis2/app-service-data": "2.0.0" | ||
}, | ||
@@ -32,5 +32,5 @@ "peerDependencies": { | ||
"scripts": { | ||
"build": "rollup -c", | ||
"build": "rimraf build && rollup -c", | ||
"test": "echo \"No tests yet!\"" | ||
} | ||
} |
@@ -26,5 +26,2 @@ # DHIS2 Application Runtime | ||
The `@dhis2/app-runtime` library is a thin wrapper around application services. See each service's README for usage instructions. Currently, the included services are: | ||
- [config](../services/config) - contextualized application configuration | ||
- [data](../services/data) - declarative data fetching for DHIS2 api queries | ||
See [the docs](https://runtime.dhis2.nu) for usage and examples |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
38872
6
1358
27
3