diff --git a/scss/maintenance.scss b/scss/maintenance.scss index 17a57f211..779bf9bbb 100644 --- a/scss/maintenance.scss +++ b/scss/maintenance.scss @@ -60,4 +60,4 @@ html { .sortableModalHelper { z-index: 2000; -} \ No newline at end of file +} diff --git a/src/config/field-config/field-order.js b/src/config/field-config/field-order.js index cc1fa4754..5ca08d8a5 100644 --- a/src/config/field-config/field-order.js +++ b/src/config/field-config/field-order.js @@ -148,6 +148,7 @@ const fieldOrderByName = new Map([ 'name', 'shortName', 'code', + 'image', 'description', 'openingDate', 'closedDate', diff --git a/src/config/field-overrides/organisationUnit.js b/src/config/field-overrides/organisationUnit.js index d9016cc8c..f16dc97fc 100644 --- a/src/config/field-overrides/organisationUnit.js +++ b/src/config/field-overrides/organisationUnit.js @@ -1,8 +1,16 @@ import { isStartDateBeforeEndDate } from 'd2-ui/lib/forms/Validators'; import OrgUnitSelectDialogField from '../../forms/form-fields/orgunit-select-dialog-field'; import GeometryField, { validators as GeometryValidators } from '../../forms/form-fields/geometry-field'; +import { ImageSelect, ImageValidators } from '../../forms/form-fields/image-select'; export default new Map([ + [ + 'image', + { + component: ImageSelect, + validator: ImageValidators, + } + ], [ 'parent', { diff --git a/src/forms/form-fields/image-select.js b/src/forms/form-fields/image-select.js new file mode 100644 index 000000000..41eaae9d8 --- /dev/null +++ b/src/forms/form-fields/image-select.js @@ -0,0 +1,364 @@ +import Button from 'd2-ui/lib/button/Button'; +import PropTypes from 'prop-types'; +import React, { Component, createRef } from 'react'; + +const MAX_POLL_TRIES = 5; + +export const ImageValidators = [{ + // validator(value, formState) {}, + validator() {}, + message: 'invalid_image', +}]; + +const ImageSelectText = ({ children }) => { + return ( +

+ {children} +

+ ) +} + +ImageSelectText.propTypes = { + children: PropTypes.any.isRequired, +} + +const ImageSelectButton = ({ disabled, children, onClick }) => ( + { + if (disabled) return + onClick(...args) + }} + style={{ + display: 'inline-block', + marginRight: 4, + background: 'rgb(204, 204, 204)', + color: 'black', + height: 36, + lineHeight: '36px', + textTransform: 'uppercase', + fontWeight: 500, + fontSize: '14px', + letterSpacing: 0, + cursor: 'pointer', + padding: '0 16px', + ...(disabled ? { + color: 'rgba(0, 0, 0, 0.3)', + background: 'transparent', + cursor: 'not-allowed', + } : {}), + }} + > + {children} + +) + +ImageSelectButton.propTypes = { + onClick: PropTypes.func.isRequired, + children: PropTypes.any, + disabled: PropTypes.bool, +} + +export class ImageSelect extends Component { + fileInputRef = null + + constructor(props, context) { + super(props, context); + + this.api = context.d2.Api.getApi(); + this.state = { + initialValue: props.value, + initialized: !props.value, + loading: false, + error: null, + pending: false, + removed: false, + }; + + this.onFileSelect = this.onFileSelect.bind(this) + } + + componentDidMount() { + // no image exists on init + if (this.state.initialized) { + return; + } + + const { id } = this.props.value + this.pollStorageStatusWhilePending(id).finally( + () => this.setState({ initialized: true }) + ); + } + + storageStatusCheckDelay() { + return new Promise(resolve => setTimeout(resolve, 1000)) + } + + checkStorageStatus(id) { + return this.api + .get(`${this.api.baseUrl}/fileResources/${id}`) + .then(({ storageStatus }) => { + if (storageStatus === 'PENDING') { + this.setState({ pending: true }); + } else if (storageStatus === 'STORED') { + this.setState({ pending: false }); + } else { + const errorLabel = this.getTranslation('org_unit_image_storage_status_error'); + const error = new Error(`${errorLabel} ${storageStatus}`); + this.setState({ error }); + } + + return storageStatus; + }) + .catch(error => this.setState({ error })) + } + + pollStorageStatusWhilePending(id, pollCount = 0) { + if (pollCount === MAX_POLL_TRIES) { + const error = new Error('Timed out polling for image storage status update') + this.setState({ error }) + return + } + + return this.checkStorageStatus(id).then(storageStatus => { + if (storageStatus === 'PENDING') { + return this.storageStatusCheckDelay().then(() => + this.pollStorageStatusWhilePending(id, pollCount + 1) + ); + } + }); + } + + onFileSelect(event) { + const { onChange } = this.props; + const file = event.target.files[0]; + + if (!file) { + return; + } + + const formData = new FormData(); + formData.append('file', file); + formData.append('domain', this.props.domain); + + // Send image to server and save image id as avatar + this.setState({ + loading: true, + error: null, + pending: false, + removed: false, + }); + this.api + .post('fileResources', formData) + .then(postResponse => { + const { id: imageId, storageStatus } = postResponse.response.fileResource; + + if (!['PENDING', 'STORED'].includes(storageStatus)) { + throw new Error( + `Org unit image could not be stored, storageStatus is: ${storageStatus}` + ); + } + + const pending = storageStatus === 'PENDING' + this.setState({ pending, loading: false }); + + const imageReference = { id: imageId } + const target = { value: imageReference }; + onChange({ target }); + + if (pending) { + this.pollStorageStatusWhilePending(imageId) + } + }) + .catch(error => { + this.setState({ loading: false, error }) + onChange({ target: { value: null } }) + }); + } + + getTranslation(key) { + return this.context.d2.i18n.getTranslation(key); + } + + render() { + const { value: fileReference } = this.props; + const { id: fileResourceId } = fileReference || {} + const { initialized, initialValue, loading, error, pending } = this.state; + + const fileResourceReady = initialized && !loading && !error && fileResourceId; + const isPending = fileResourceReady && pending; + const displayImage = fileResourceReady && !isPending; + const wasRemoved = !fileResourceId && this.state.removed + const hasNoImage = !fileResourceId && !this.state.removed + const hasInitialValue = initialValue && initialValue.id + const dirty = ( + (fileResourceId && !hasInitialValue) || + (!fileResourceId && hasInitialValue) || + (hasInitialValue && initialValue.id !== fileResourceId) + ) + + return ( +
+ + +
+ + + { + if (initialValue) { + this.setState({ removed: true }) + } + + this.props.onChange({ target: { value: null } }) + }} + > + {this.getTranslation('org_unit_image_remove_image')} + + + {initialValue && dirty && this.props.onChange({ + target: { + value: initialValue, + }, + }) + } + > + {this.getTranslation('org_unit_image_reset')} + } +
+ + {hasNoImage && !error && !loading && !isPending && ( + + {this.getTranslation('org_unit_image_no_image_text')} + + )} + + {!initialized && ( + + {this.getTranslation('org_unit_image_loading_image_data_text')} + + )} + + {loading && ( + + {this.getTranslation('org_unit_image_uploading_image_text')} + + )} + + {error && ( + + + {this.getTranslation('org_unit_image_image_upload_error_text')} + {' '} + + + {error instanceof Error ? error.message : error.toString()} + + )} + + {isPending && ( + + {this.getTranslation('org_unit_image_image_pending_text')} + + )} + + {(dirty || isPending || wasRemoved) && ( + + {this.getTranslation('org_unit_image_save_reminder')} + + )} +
+ ) + } +} + +ImageSelect.contextTypes = { + d2: PropTypes.object.isRequired, +}; + +ImageSelect.propTypes = { + domain: PropTypes.string, + value: PropTypes.shape({ + id: PropTypes.string.isRequired, + }), +} + +ImageSelect.defaultProps = { + domain: 'ORG_UNIT', +} diff --git a/src/i18n/i18n_module_en.properties b/src/i18n/i18n_module_en.properties index 2ef961c49..cdc5fb458 100644 --- a/src/i18n/i18n_module_en.properties +++ b/src/i18n/i18n_module_en.properties @@ -2259,3 +2259,15 @@ open_periods_after_co_end_date=Open periods after category option end date element_hidden_by_filter=1 element hidden by filter $$total$$_elements_hidden_by_filter= $$total$$ elements hidden by filter add_section_to_form=Add section to form +label_organisation_unit_image=Organisation unit image +org_unit_image_select_file=Select an image +org_unit_image_alt_text=Organisation unit image +org_unit_image_no_image_text=Organisation unit does not have an image +org_unit_image_loading_image_data_text=Loading image +org_unit_image_uploading_image_text=Uploading image +org_unit_image_image_upload_error_text=There is an error with the image: +org_unit_image_image_pending_text=Upload successful, processing image +org_unit_image_storage_status_error=Image could not be stored, storageStatus is: +org_unit_image_save_reminder=Submit the form to save changes +org_unit_image_remove_image=Remove image +org_unit_image_reset=Reset changes