Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [DHIS2-15391] preview images in working lists and on enrollment dashboard #3546

Merged
merged 20 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions cypress/e2e/EnrollmentPage/BreakingTheGlass.feature
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
Feature: Breaking the glass page

# TODO - Flaky tests should be fixed by TECH-1662
@skip
Scenario: User with search scope access tries to access an enrollment in a protected program
Given the tei created by this test is cleared from the database
And the data store is clean
Expand Down
2 changes: 2 additions & 0 deletions src/components/AppLoader/AppLoader.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DisplayException } from 'capture-core/utils/exceptions';
import { makeQuerySingleResource } from 'capture-core/utils/api';
import { environments } from 'capture-core/constants';
import { buildUrl } from 'capture-core-utils';
import { initFeatureAvailability } from 'capture-core-utils/featuresSupport';
import { initializeAsync } from './init';
import { getStore } from '../../store/getStore';

Expand Down Expand Up @@ -43,6 +44,7 @@ export const AppLoader = (props: Props) => {

const load = useCallback(async () => {
try {
initFeatureAvailability(serverVersion);
simonadomnisoru marked this conversation as resolved.
Show resolved Hide resolved
await initializeAsync(
onCacheExpired,
querySingleResource,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// @flow
import { hasAPISupportForFeature } from './support';

let minorVersion = '';

export const initFeatureAvailability = (serverVersion: { minor: number }) => {
minorVersion = serverVersion.minor;
};

export const featureAvailable = (featureName: string) =>
hasAPISupportForFeature(minorVersion, featureName);
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @flow
export { hasAPISupportForFeature, FEATURES } from './support';
export { useFeature } from './useFeature';
export { initFeatureAvailability, featureAvailable } from './featureAvailable';
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const FEATURES = Object.freeze({
multiText: 'multiText',
customIcons: 'customIcons',
exportablePayload: 'exportablePayload',
trackerImageEndpoint: 'trackerImageEndpoint',
});

// The first minor version that supports the feature
Expand All @@ -14,6 +15,7 @@ const MINOR_VERSION_SUPPORT = Object.freeze({
[FEATURES.multiText]: 41,
[FEATURES.customIcons]: 41,
[FEATURES.exportablePayload]: 41,
[FEATURES.trackerImageEndpoint]: 41,
});

export const hasAPISupportForFeature = (minorVersion: string | number, featureName: string) =>
Expand Down
2 changes: 1 addition & 1 deletion src/core_modules/capture-core-utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ export { buildUrl } from './misc';
export { makeCancelable as makeCancelablePromise } from './cancelablePromise';
export { chunk } from './chunk';
export { WebWorker } from './WebWorker';
export { useFeature, FEATURES, hasAPISupportForFeature } from './featuresSupport';
export { useFeature, featureAvailable, FEATURES, hasAPISupportForFeature } from './featuresSupport';
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ class D2FilePlain extends Component<Props, State> {
target="_blank"
href={fileUrl}
rel="noopener noreferrer"
onBlur={(event) => { event.stopPropagation(); }}
>
{value.name}
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ class D2ImagePlain extends Component<Props, State> {
target="_blank"
href={imageUrl}
rel="noopener noreferrer"
onBlur={(event) => { event.stopPropagation(); }}
>
<img src={imageUrl} alt="" className={classes.image} />
</a>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// @flow
import { featureAvailable, FEATURES } from 'capture-core-utils';
import { dataElementTypes } from '../../../../metaData';
import type { QuerySingleResource } from '../../../../utils/api/api.types';

const getImageOrFileResourceSubvalue = async (keys: Object, querySingleResource: QuerySingleResource, eventId: string, absoluteApiPath: string) => {
const getFileResourceSubvalue = async (keys: Object, querySingleResource: QuerySingleResource, eventId: string, absoluteApiPath: string) => {
const promises = Object.keys(keys)
.map(async (key) => {
const value = keys[key];
Expand All @@ -26,6 +27,32 @@ const getImageOrFileResourceSubvalue = async (keys: Object, querySingleResource:
}, {});
};

const getImageSubvalue = async (keys: Object, querySingleResource: QuerySingleResource, eventId: string, absoluteApiPath: string) => {
const promises = Object.keys(keys)
.map(async (key) => {
const value = keys[key];
if (value) {
const { id, displayName: name } = await querySingleResource({ resource: `fileResources/${value}` });
return {
id,
name,
url: featureAvailable(FEATURES.trackerImageEndpoint) ?
`${absoluteApiPath}/tracker/events/${eventId}/dataValues/${key}/image?dimension=small` :
`${absoluteApiPath}/events/files?dataElementUid=${key}&eventUid=${eventId}`,
};
}
return {};
});

return (await Promise.all(promises))
.reduce((acc, { id, name, url }) => {
if (id) {
acc[id] = { value: id, name, url };
}
return acc;
}, {});
};


const getOrganisationUnitSubvalue = async (keys: Object, querySingleResource: QuerySingleResource) => {
const ids = Object.values(keys)
Expand All @@ -44,8 +71,8 @@ const getOrganisationUnitSubvalue = async (keys: Object, querySingleResource: Qu
};

const subValueGetterByElementType = {
[dataElementTypes.FILE_RESOURCE]: getImageOrFileResourceSubvalue,
[dataElementTypes.IMAGE]: getImageOrFileResourceSubvalue,
[dataElementTypes.FILE_RESOURCE]: getFileResourceSubvalue,
[dataElementTypes.IMAGE]: getImageSubvalue,
[dataElementTypes.ORGANISATION_UNIT]: getOrganisationUnitSubvalue,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @flow
import { translatedStatusTypes } from 'capture-core/events/statusTypes';
import { featureAvailable, FEATURES } from 'capture-core-utils';
import { convertServerToClient } from '../../../../../../../converters';
import type {
ApiEvents,
Expand Down Expand Up @@ -35,18 +36,22 @@ const buildTEIRecord = ({
apiTEI,
attributeValuesById,
trackedEntity,
programId,
}: {
columnsMetaForDataFetching: TeiColumnsMetaForDataFetchingArray,
apiTEI: ApiTei,
attributeValuesById: Object,
trackedEntity: string,
programId: string,
}) =>
columnsMetaForDataFetching.map(({ id, mainProperty, type }) => {
const value = mainProperty ? apiTEI[id] : attributeValuesById[id];
return {
id,
value: convertServerToClient(value, type),
urlPath: `/trackedEntityInstances/${trackedEntity}/${id}/image`,
urlPath: featureAvailable(FEATURES.trackerImageEndpoint) ?
`/tracker/trackedEntities/${trackedEntity}/attributes/${id}/image?program=${programId}&dimension=small` :
`/trackedEntityInstances/${trackedEntity}/${id}/image`,
};
});

Expand Down Expand Up @@ -75,7 +80,9 @@ const buildEventRecord = ({
return {
id: getFilterClientName(id),
value: clientValue,
urlPath: `/events/files?dataElementUid=${id}&eventUid=${apiEvent.event}`,
urlPath: featureAvailable(FEATURES.trackerImageEndpoint) ?
`/tracker/events/${apiEvent.event}/dataValues/${id}/image?dimension=small` :
`/events/files?dataElementUid=${id}&eventUid=${apiEvent.event}`,
};
});

Expand All @@ -98,6 +105,7 @@ export const convertToClientEvents = (
apiTEI,
attributeValuesById,
trackedEntity: apiEvent.trackedEntity,
programId: apiEvent.program,
})
: [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type ApiEvent = {
dataValues?: ApiDataElement,
parent: ApiTei,
trackedEntity: string,
program: string,
enrollment: string,
scheduledAt: string,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @flow
import { featureAvailable, FEATURES } from 'capture-core-utils';
import { convertServerToClient } from '../../../../../../../converters';
import type { ApiTeis, ApiTeiAttributes, TeiColumnsMetaForDataFetchingArray, ClientTeis } from './types';

Expand Down Expand Up @@ -29,7 +30,9 @@ export const convertToClientTeis = (
return {
id,
value: convertServerToClient(value, type),
urlPath: `/trackedEntityInstances/${tei.trackedEntity}/${id}/image`,
urlPath: featureAvailable(FEATURES.trackerImageEndpoint) ?
`/tracker/trackedEntities/${tei.trackedEntity}/attributes/${id}/image?program=${programId}&dimension=small` :
`/trackedEntityInstances/${tei.trackedEntity}/${id}/image`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we only add the urlPath if it is an image? Would improve readability quite a bit.

Also, in

and others, can we have a previewUrl and fullSizedUrl instead of the single url and remove the replace statement in PreviewImage.component. Would be more robust that way and the "UI-library" will not be responsible for computing URLs.

};
})
.filter(({ value }) => value != null)
Expand Down
16 changes: 13 additions & 3 deletions src/core_modules/capture-core/converters/clientToList.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import React from 'react';
import moment from 'moment';
import i18n from '@dhis2/d2-i18n';
import { Tag } from '@dhis2/ui';
import { PreviewImage } from 'capture-ui';
import { featureAvailable, FEATURES } from 'capture-core-utils';
import { dataElementTypes, type DataElement } from '../metaData';
import { convertMomentToDateFormatString } from '../utils/converters/date';
import { stringifyNumber } from './common/stringifyNumber';
Expand Down Expand Up @@ -31,7 +33,7 @@ type FileClientValue = {
value: string,
};

function convertResourceForDisplay(clientValue: FileClientValue) {
function convertFileForDisplay(clientValue: FileClientValue) {
return (
<a
href={clientValue.url}
Expand All @@ -44,6 +46,14 @@ function convertResourceForDisplay(clientValue: FileClientValue) {
);
}

function convertImageForDisplay(clientValue: FileClientValue) {
return featureAvailable(FEATURES.trackerImageEndpoint) ? (
<PreviewImage
url={clientValue.url}
/>
) : convertFileForDisplay(clientValue);
}

function convertRangeForDisplay(parser: any, clientValue: any) {
return (
<span>
Expand Down Expand Up @@ -89,8 +99,8 @@ const valueConvertersForType = {
[dataElementTypes.BOOLEAN]: (rawValue: boolean) => (rawValue ? i18n.t('Yes') : i18n.t('No')),
[dataElementTypes.COORDINATE]: MinimalCoordinates,
[dataElementTypes.AGE]: convertDateForListDisplay,
[dataElementTypes.FILE_RESOURCE]: convertResourceForDisplay,
[dataElementTypes.IMAGE]: convertResourceForDisplay,
[dataElementTypes.FILE_RESOURCE]: convertFileForDisplay,
[dataElementTypes.IMAGE]: convertImageForDisplay,
[dataElementTypes.ORGANISATION_UNIT]: (rawValue: Object) => rawValue.name,
[dataElementTypes.ASSIGNEE]: (rawValue: Object) => `${rawValue.name} (${rawValue.username})`,
[dataElementTypes.NUMBER_RANGE]: convertNumberRangeForDisplay,
Expand Down
6 changes: 4 additions & 2 deletions src/core_modules/capture-core/events/getSubValues.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @flow
import log from 'loglevel';
import isDefined from 'd2-utilizr/lib/isDefined';
import { errorCreator } from 'capture-core-utils';
import { errorCreator, featureAvailable, FEATURES } from 'capture-core-utils';
import { type RenderFoundation, dataElementTypes } from '../metaData';
import type { QuerySingleResource } from '../utils/api/api.types';

Expand Down Expand Up @@ -52,7 +52,9 @@ const subValueGetterByElementType = {
({
name: res.name,
value: res.id,
url: `${absoluteApiPath}/events/files?dataElementUid=${metaElementId}&eventUid=${eventId}`,
url: featureAvailable(FEATURES.trackerImageEndpoint) ?
`${absoluteApiPath}/tracker/events/${eventId}/dataValues/${metaElementId}/image?dimension=small` :
`${absoluteApiPath}/events/files?dataElementUid=${metaElementId}&eventUid=${eventId}`,
}))
.catch((error) => {
log.warn(errorCreator(GET_SUBVALUE_ERROR)({ value, eventId, metaElementId, error }));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @flow
import React from 'react';
import { withStyles } from '@material-ui/core/styles';

const styles = () => ({
container: {
position: 'relative',
display: 'inline-block',
'&:hover $image': {
opacity: 0.5,
},
'&:hover $icon': {
visibility: 'visible',
},
},
image: {
height: 80,
simonadomnisoru marked this conversation as resolved.
Show resolved Hide resolved
maxWidth: 120,
objectFit: 'contain',
},
icon: {
position: 'absolute',
padding: '4px 4px 2px 4px',
left: '50%',
bottom: '50%',
transform: 'translate(-50%, 50%)',
background: 'rgba(255, 255, 255, 0.5)',
cursor: 'pointer',
visibility: 'hidden',
},
});

const PreviewImagePlain = (props: {
url: string,
classes: {
container: string,
image: string,
icon: string,
},
}) => {
const { url, classes } = props;

return (
<div className={classes.container}>
<a
href={url}
simonadomnisoru marked this conversation as resolved.
Show resolved Hide resolved
target="_blank"
rel="noopener noreferrer"
onClick={(event) => { event.stopPropagation(); }}
>
<img
src={url}
className={classes.image}
/>
<div className={classes.icon}>
{/* Todo: Change to UI zoom in icon */}
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M22 11C22.0017 13.5613 21.0978 16.0408 19.4479 18L27 25.5859L25.5859 27L18 19.4479C16.0408 21.0978 13.5613 22.0017 11 22C8.82441 22 6.69767 21.3549 4.88873 20.1462C3.07979 18.9375 1.66989 17.2195 0.83733 15.2095C0.00476616 13.1995 -0.213071 10.9878 0.211367 8.85401C0.635804 6.72022 1.68345 4.76021 3.22183 3.22183C4.76021 1.68345 6.72022 0.635804 8.85401 0.211367C10.9878 -0.213071 13.1995 0.00476616 15.2095 0.83733C17.2195 1.66989 18.9375 3.07979 20.1462 4.88873C21.3549 6.69767 22 8.82441 22 11ZM5.99987 18.4832C7.47992 19.4722 9.21997 20 11 20C13.3861 19.9974 15.6738 19.0483 17.361 17.361C19.0483 15.6738 19.9974 13.3861 20 11C20 9.21997 19.4722 7.47992 18.4832 5.99987C17.4943 4.51983 16.0887 3.36628 14.4442 2.68509C12.7996 2.0039 10.99 1.82567 9.24419 2.17294C7.49836 2.5202 5.89472 3.37737 4.63604 4.63604C3.37737 5.89472 2.5202 7.49836 2.17294 9.24419C1.82567 10.99 2.0039 12.7996 2.68509 14.4442C3.36628 16.0887 4.51983 17.4943 5.99987 18.4832ZM12 10H16V12H12V16H10V12H6V10H10V6H12V10Z" fill="#212934" />
</svg>
</div>
</a>
</div>
);
};

export const PreviewImage = withStyles(styles)(PreviewImagePlain);
1 change: 1 addition & 0 deletions src/core_modules/capture-ui/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ export { IconButton } from './IconButton';
export { ChevronIcon } from './Icons';
export { NonBundledIcon } from './NonBundledIcon';
export { FlatList } from './FlatList';
export { PreviewImage } from './PreviewImage/PreviewImage.component';
Loading