From 73990c0bd16f16b835fd06d5d5553a0cdc5e0ad1 Mon Sep 17 00:00:00 2001 From: Birk Johansson Date: Thu, 21 Mar 2024 09:51:39 +0100 Subject: [PATCH] feat(icons): support uploading icons (#2818) * feat(icons): custom icons * fix: default key to filename * fix: upload icons and pagination * fix: fix button width * refactor: cleanup * fix: hide hidden upload field * refactor: move uploadiconfield to separate component --- scss/IconPicker/IconPicker.scss | 28 +- .../helpers/IconPickerDialog/Icon.js | 24 +- .../helpers/IconPickerDialog/IconList.js | 136 ++++++++++ .../IconPickerDialog/IconPickerCustomTab.js | 140 ++++++++++ .../IconPickerDialog/IconPickerDialog.js | 241 +++++++++--------- .../helpers/IconPickerDialog/uploadIcon.js | 29 +++ src/forms/form-fields/helpers/fileToBase64.js | 9 + src/forms/form-fields/upload-icon-field.js | 115 +++++++++ src/i18n/i18n_module_en.properties | 3 + 9 files changed, 603 insertions(+), 122 deletions(-) create mode 100644 src/forms/form-fields/helpers/IconPickerDialog/IconList.js create mode 100644 src/forms/form-fields/helpers/IconPickerDialog/IconPickerCustomTab.js create mode 100644 src/forms/form-fields/helpers/IconPickerDialog/uploadIcon.js create mode 100644 src/forms/form-fields/helpers/fileToBase64.js create mode 100644 src/forms/form-fields/upload-icon-field.js diff --git a/scss/IconPicker/IconPicker.scss b/scss/IconPicker/IconPicker.scss index 4d135d811..ef51ec784 100644 --- a/scss/IconPicker/IconPicker.scss +++ b/scss/IconPicker/IconPicker.scss @@ -49,7 +49,7 @@ &__scroll-box { // This takes into account the primary and secundary header, // footer and space above and below the dialog - height: calc(100vh - 320px); + height: calc(100vh - 330px); max-height: 1200px; overflow-y: auto; overflow-x: hidden; @@ -62,6 +62,7 @@ display: flex; align-items: center; justify-content: center; + overflow: hidden; } &__icon-list { @@ -86,4 +87,29 @@ border: 3px solid #64acf5; } } + } + +.icon-picker-custom { + &.wrapper { + display: flex; + flex-direction: column; + gap: 24px; + padding-block-start: 24px; + } + + .form { + display: flex; + flex-direction: column; + + .form-fields { + display: flex; + flex-direction: column; + } + } + + .upload-button { + max-width: 256px; + margin-block-start: 8px; + } +} \ No newline at end of file diff --git a/src/forms/form-fields/helpers/IconPickerDialog/Icon.js b/src/forms/form-fields/helpers/IconPickerDialog/Icon.js index 92c3ae8a6..a2284dfc7 100644 --- a/src/forms/form-fields/helpers/IconPickerDialog/Icon.js +++ b/src/forms/form-fields/helpers/IconPickerDialog/Icon.js @@ -6,24 +6,36 @@ export default class Icon extends Component { super(props); this.img = null; - this.setImgRef = (element) => { + this.setImgRef = element => { this.img = element; }; } + componentWillUnmount() { + delete this.img.onload; + } + componentDidMount() { this.img.onload = () => { - this.img.removeAttribute('data-loading'); + try { + this.img.removeAttribute('data-loading'); + } catch (e) { + // ignore, this could happen if icon are unmounted while loading + } }; } handleClick = () => { this.props.handleClick(this.props.icon.key); - } + }; render() { - const { icon: { href, key, description }, selectedIconKey } = this.props; - const classSuffix = key === selectedIconKey ? ' icon-picker__icon--selected' : ''; + const { + icon: { href, key, description }, + selectedIconKey, + } = this.props; + const classSuffix = + key === selectedIconKey ? ' icon-picker__icon--selected' : ''; const title = description || key.replace(/_/g, ' '); /* eslint-disable */ return ( @@ -33,6 +45,8 @@ export default class Icon extends Component { alt={title} title={title} data-loading + width={60} + height={60} className={`icon-picker__icon${classSuffix}`} onClick={this.handleClick} /> diff --git a/src/forms/form-fields/helpers/IconPickerDialog/IconList.js b/src/forms/form-fields/helpers/IconPickerDialog/IconList.js new file mode 100644 index 000000000..cca43174f --- /dev/null +++ b/src/forms/form-fields/helpers/IconPickerDialog/IconList.js @@ -0,0 +1,136 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import CircularProgress from 'd2-ui/lib/circular-progress/CircularProgress.js'; +import Icon from './Icon.js'; + +export class IconList extends Component { + constructor(props, context) { + super(props, context); + + this.d2 = context.d2; + + this.state = { + loading: false, + pager: { + page: 1, + }, + icons: [], + }; + + this.intersectionObserver = null; + this.loadingRef = null; + } + + componentDidMount() { + // init observer only after first page is fetched + // this is needed so that the observer doesnt return early due to loading state + this.fetchIcons(1).then(() => this.initIntersectionObserver()); + } + + initIntersectionObserver() { + this.intersectionObserver = new IntersectionObserver( + entries => { + if (this.state.loading) { + return; + } + const [{ isIntersecting }] = entries; + + if (isIntersecting) { + this.fetchIcons(this.state.pager.page + 1); + } + }, + { threshold: 0.8 } + ); + + this.intersectionObserver.observe(this.loadingRef); + } + + componentDidUpdate(prevProps) { + if (prevProps.textFilter !== this.props.textFilter) { + this.setState({ icons: [] }, () => this.fetchIcons(1)); + } + } + + componentWillUnmount() { + this.intersectionObserver.disconnect(); + } + + fetchIconAndAddToStartOfList = async key => { + const icon = await this.d2.Api.getApi().get(`/icons/${key}`); + this.setState(prevState => ({ + icons: [icon, ...prevState.icons], + })); + }; + + fetchIcons = async page => { + const typeFilter = this.props.type; + + const filter = this.props.textFilter || ''; + + this.setState({ loading: true }); + const response = await this.d2.Api.getApi().get('/icons', { + type: typeFilter, + paging: true, + page: page, + search: filter, + pageSize: 200, + }); + + this.setState(prevState => ({ + pager: response.pager, + icons: [...prevState.icons, ...response.icons], + loading: false, + })); + }; + + render() { + const isLastPage = + this.state.pager.total === 0 || + (this.state.pager.pageCount && + this.state.pager.page === this.state.pager.pageCount); + + const shouldShowLoading = !!this.state.loading || !isLastPage; + const icons = this.state.icons; + + return ( +
+
+ {this.state.selectedIcon && ( + + )} + {icons.map(icon => ( + + ))} +
+ +
(this.loadingRef = ref)} + > + +
+
+ ); + } +} + +IconList.propTypes = { + type: PropTypes.oneOf(['default', 'custom', 'all']).isRequired, + textFilter: PropTypes.string, + selectedIconKey: PropTypes.string, + onIconSelect: PropTypes.func.isRequired, +}; + +IconList.contextTypes = { + d2: PropTypes.object, +}; diff --git a/src/forms/form-fields/helpers/IconPickerDialog/IconPickerCustomTab.js b/src/forms/form-fields/helpers/IconPickerDialog/IconPickerCustomTab.js new file mode 100644 index 000000000..e47c3229b --- /dev/null +++ b/src/forms/form-fields/helpers/IconPickerDialog/IconPickerCustomTab.js @@ -0,0 +1,140 @@ +import React, { Component } from 'react'; +import RaisedButton from 'material-ui/RaisedButton'; +import PropTypes from 'prop-types'; +import Divider from 'material-ui/Divider'; +import TextField from 'material-ui/TextField/TextField'; +import { uploadIcon } from './uploadIcon.js'; +import ErrorMessage from 'd2-ui/lib/messages/ErrorMessage.component'; +import classes from 'classnames'; +import { UploadIconField } from '../../upload-icon-field.js'; + +export class IconPickerCustomTab extends Component { + constructor(props, context) { + super(props, context); + + this.t = context.d2.i18n.getTranslation.bind(context.d2.i18n); + this.d2 = context.d2; + this.state = { + iconFile: null, + iconMetadata: { + key: '', + description: '', + keywords: [], + }, + uploading: false, + uploadError: null, + }; + } + + handleIconMetadataChange = event => { + const value = event.target.value && event.target.value.trim(); + const name = event.target.name; + + this.setState(prevState => ({ + iconMetadata: { + ...prevState.iconMetadata, + [name]: value, + }, + })); + }; + + handleIconKeywordsChange = event => { + const keywords = event.target.value + .split(/[,\r\n]+/) + .map(kw => kw.trim()) + .filter(kw => !!kw); + this.setState(prevState => ({ + iconMetadata: { + ...prevState.iconMetadata, + keywords, + }, + })); + }; + + handleFileUpload = () => { + const iconData = this.state.iconMetadata; + this.setState({ uploading: true }); + uploadIcon(this.state.iconFile, iconData) + .then(() => { + if (typeof this.props.onIconUpload === 'function') { + this.props.onIconUpload(iconData.key); + } + this.setState({ uploading: false, uploadError: null }); + }) + .catch(e => this.setState({ uploading: false, uploadError: e })); + }; + + handleFileChange = async event => { + const file = event.target.files[0]; + if (!file) { + return; + } + + this.setState(prevState => ({ + iconFile: file, + iconMetadata: { + ...prevState.iconMetadata, + // remove file extension and replace hyphens with underscores + key: file.name.replace(/\..*$/, '').replaceAll(/[-\s()]/g, '_'), + }, + })); + }; + + render() { + const iconKey = this.state.iconMetadata.key; + + return ( +
+
+ + {this.state.iconFile && ( +
+ + + + {this.state.uploadError && ( + + )} + +
+ )} +
+ +
{this.props.children}
+
+ ); + } +} + +IconPickerCustomTab.propTypes = { + children: PropTypes.node, + onIconUpload: PropTypes.func, +}; + +IconPickerCustomTab.contextTypes = { + d2: PropTypes.object, +}; diff --git a/src/forms/form-fields/helpers/IconPickerDialog/IconPickerDialog.js b/src/forms/form-fields/helpers/IconPickerDialog/IconPickerDialog.js index 72ca2e6c6..7b89ed3ac 100644 --- a/src/forms/form-fields/helpers/IconPickerDialog/IconPickerDialog.js +++ b/src/forms/form-fields/helpers/IconPickerDialog/IconPickerDialog.js @@ -1,14 +1,18 @@ import React, { Component } from 'react'; -import { debounce, endsWith, sortBy } from 'lodash/fp'; +import { debounce } from 'lodash/fp'; import PropTypes from 'prop-types'; import Button from 'd2-ui/lib/button/Button'; import Dialog from 'material-ui/Dialog'; import FlatButton from 'material-ui/FlatButton'; import RaisedButton from 'material-ui/RaisedButton'; -import CircularProgress from 'material-ui/CircularProgress'; import TextField from 'material-ui/TextField'; -import Icon from './Icon'; -import filterIcons from './filterIcons'; +import { IconPickerCustomTab } from './IconPickerCustomTab.js'; +import { IconList } from './IconList.js'; + +const isSupportedApiIconType = type => + ['all', 'default', 'custom'].includes(type); +const isVarianceIconType = type => + ['positive', 'negative', 'outline'].includes(type); export default class IconPickerDialog extends Component { constructor(props, context) { @@ -20,6 +24,7 @@ export default class IconPickerDialog extends Component { icons: null, iconTypeFilter: 'all', textFilter: '', + debouncedTextFilter: '', }; this.iconsCache = { all: null, @@ -27,36 +32,10 @@ export default class IconPickerDialog extends Component { negative: null, outline: null, }; - - this.debouncedUpdateTextFilter = debounce(375, this.updateTextFilter); - } - - fetchIconLibrary = () => { - this.context.d2.Api.getApi().get('/icons') - .then((icons) => { - const sortedIcons = sortBy('key', icons).map((icon) => { - // The '_positive', '_negative' and '_outline' suffixes are stripped for the searchKeys - // to make sure that search queries for 'negative' only return icons that actually have - // 'negative' in the relevant parts of the icon key. - icon.searchKey = icon.key.substring(0, icon.key.lastIndexOf('_')); - return icon; - }); - this.iconsCache = { - all: sortedIcons, - positive: sortedIcons.filter(icon => endsWith('_positive', icon.key)), - negative: sortedIcons.filter(icon => endsWith('_negative', icon.key)), - outline: sortedIcons.filter(icon => endsWith('_outline', icon.key)), - }; - this.setState({ icons: sortedIcons }); - }); } handleOpen = () => { this.setState({ open: true }); - - if (!this.icons) { - this.fetchIconLibrary(); - } }; handleClose = () => { @@ -65,44 +44,67 @@ export default class IconPickerDialog extends Component { handleCancel = () => { this.setState({ - selectedIconKey: this.props.iconKey //if cancelling revert back to original icon + selectedIconKey: this.props.iconKey, //if cancelling revert back to original icon }); this.handleClose(); - } + }; handleConfirm = () => { - this.setState({ iconKey: this.state.selectedIconKey }); - this.props.updateStyleState({ icon: this.state.selectedIconKey }); + this.setState({ + iconKey: this.state.selectedIconKey, + }); + this.props.updateStyleState({ + icon: this.state.selectedIconKey, + }); this.handleClose(); - } + }; - handleIconSelect = (iconKey) => { - this.setState({ selectedIconKey: iconKey }); + handleIconSelect = iconKey => { + this.setState({ + selectedIconKey: iconKey, + }); }; - handleTypeFilterClick = (type) => { + handleTypeFilterClick = type => { + if (isVarianceIconType(type)) { + // this is not ideal, but we're translating the filters to just + // a prefilled search + // we cannot combine searching for eg. "positive" and a user-defined search... + this.handleTextFilterChange(type, true); + } + this.setState({ iconTypeFilter: type, - icons: filterIcons(this.iconsCache[type], this.state.textFilter), }); - } - handleTextFilterChange = (event) => { - this.setState({ textFilter: event.target.value }); - this.debouncedUpdateTextFilter(); - } + const previousFilter = this.state.iconTypeFilter; + // if moving from a prefilled-search, clear filter when changing tab + if ( + isVarianceIconType(previousFilter) && + isSupportedApiIconType(type) + ) { + this.handleTextFilterChange('', true); + } + }; - updateTextFilter = () => { - const icons = this.iconsCache[this.state.iconTypeFilter]; + debouncedFilterChange = debounce(375, value => { this.setState({ - icons: filterIcons(icons, this.state.textFilter), + debouncedTextFilter: value, }); - } + }); + + handleTextFilterChange = (value, immediate = false) => { + this.setState({ textFilter: value }); + this.debouncedFilterChange(value); + if (immediate) { + this.debouncedFilterChange.flush(); + } + }; - renderIconButtonImage = (iconKey) => { + renderIconButtonImage = iconKey => { const contextPath = this.context.d2.system.systemInfo.contextPath; const altText = this.context.d2.i18n.getTranslation('current_icon'); - const fallbackIconPath = `${contextPath}/api/icons/dhis2_logo_outline/icon.svg` + const fallbackIconPath = `${contextPath}/api/icons/dhis2_logo_outline/icon.svg`; return ( { - target.onerror = ""; - target.src=fallbackIconPath; + target.onerror = ''; + target.src = fallbackIconPath; return true; }} /> @@ -134,20 +136,18 @@ export default class IconPickerDialog extends Component { height: 36, lineHeight: 2.5, marginTop: 10, - boxShadow: '0 1px 6px rgba(0,0,0,0.12),0 1px 4px rgba(0,0,0,0.12)', + boxShadow: + '0 1px 6px rgba(0,0,0,0.12),0 1px 4px rgba(0,0,0,0.12)', cursor: 'pointer', textTransform: 'uppercase', }} > - {iconKey && - this.renderIconButtonImage(iconKey) - } + {iconKey && this.renderIconButtonImage(iconKey)} {iconKey ? this.context.d2.i18n.getTranslation('change_icon') - : this.context.d2.i18n.getTranslation('add_icon') - } + : this.context.d2.i18n.getTranslation('add_icon')} ); @@ -166,88 +166,70 @@ export default class IconPickerDialog extends Component { height: 36, lineHeight: 2.5, marginTop: 10, - boxShadow: '0 1px 6px rgba(0,0,0,0.12),0 1px 4px rgba(0,0,0,0.12)', + boxShadow: + '0 1px 6px rgba(0,0,0,0.12),0 1px 4px rgba(0,0,0,0.12)', cursor: 'pointer', }} > {this.context.d2.i18n.getTranslation('deselect_icon')} - ) + ); } return buttons; - } + }; handleDeselect = () => { this.setState({ iconKey: '' }); this.props.updateStyleState({ icon: '' }); - } + }; - renderActions = () => ( - [ - , - , - ] - ) + renderActions = () => [ + , + , + ]; renderTypeFilter = () => (
- { - ['all', 'positive', 'negative', 'outline'].map(type => ( - this.handleTypeFilterClick(type)} - /* eslint-enable */ - /> - )) - } + {['all', 'positive', 'negative', 'outline', 'custom'].map(type => ( + this.handleTypeFilterClick(type)} + /> + ))}
- ) + ); renderTextFilter = () => ( { + const value = event.target.value; + this.setState({ textFilter: value }); + this.handleTextFilterChange(value); + }} /> - ) - - renderIconLibrary = () => { - const { icons, selectedIconKey } = this.state; - if (!icons) { - return ( -
- -
- ); - } - - return ( -
- {icons.map(icon => ( - - ))} -
- ); - } + ); render() { + // iconTypeFilter is used to control the tabs + // however, the API does not have a concept of "positive" and "negative", so we just do a search instead + const iconApiType = isSupportedApiIconType(this.state.iconTypeFilter) + ? this.state.iconTypeFilter + : 'all'; return (
{this.renderIconButton()} @@ -269,7 +251,34 @@ export default class IconPickerDialog extends Component { {this.renderTextFilter()}
- {this.renderIconLibrary()} + {this.state.iconTypeFilter === 'custom' ? ( + { + if (this.iconListRef) { + this.iconListRef.fetchIconAndAddToStartOfList( + iconKey + ); + } + }} + > + (this.iconListRef = ref)} + onIconSelect={this.handleIconSelect} + type={'custom'} + textFilter={this.state.debouncedTextFilter} + selectedIconKey={this.state.selectedIconKey} + /> + + ) : ( + + )}
diff --git a/src/forms/form-fields/helpers/IconPickerDialog/uploadIcon.js b/src/forms/form-fields/helpers/IconPickerDialog/uploadIcon.js new file mode 100644 index 000000000..12aef9459 --- /dev/null +++ b/src/forms/form-fields/helpers/IconPickerDialog/uploadIcon.js @@ -0,0 +1,29 @@ +import { getInstance as getD2 } from 'd2/lib/d2'; +/** + * Uploads a customIcon. + * This will first upload the File as a FileResource, then + * use that fileResource to create an Icon with that FileResource. + * @param {File} iconFile a File object + * @param {{ key: string, description: string, keywords: string[] }} iconMetadata + * @returns {Promise} + */ +export async function uploadIcon(iconFile, { key, description, keywords }) { + const formData = new FormData(); + formData.append('file', iconFile); + formData.append('domain', 'ICON'); + const d2 = await getD2(); + const d2Api = d2.Api.getApi(); + + const fileResourceResponse = await d2Api.post('/fileResources', formData); + + if (!fileResourceResponse.response) { + throw new Error('Failed to upload fileResource', fileResourceResponse); + } + const iconData = { + fileResourceId: fileResourceResponse.response.fileResource.id, + key, + description, + keywords, + }; + return d2Api.post('/icons', iconData); +} diff --git a/src/forms/form-fields/helpers/fileToBase64.js b/src/forms/form-fields/helpers/fileToBase64.js new file mode 100644 index 000000000..589389277 --- /dev/null +++ b/src/forms/form-fields/helpers/fileToBase64.js @@ -0,0 +1,9 @@ +export const fileToBase64 = file => { + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + }); +}; diff --git a/src/forms/form-fields/upload-icon-field.js b/src/forms/form-fields/upload-icon-field.js new file mode 100644 index 000000000..ead1c2a99 --- /dev/null +++ b/src/forms/form-fields/upload-icon-field.js @@ -0,0 +1,115 @@ +import React, { Component } from 'react'; +import Button from 'd2-ui/lib/button/Button'; +import PropTypes from 'prop-types'; +import { fileToBase64 } from './helpers/fileToBase64.js'; + +const styles = { + selectFileButton: { + backgroundColor: '#ff9800', + color: '#fff', + textAlign: 'center', + position: 'relative', + minWidth: 129, + height: 36, + lineHeight: 2.5, + boxShadow: '0 1px 6px rgba(0,0,0,0.12),0 1px 4px rgba(0,0,0,0.12)', + cursor: 'pointer', + textTransform: 'uppercase', + }, + iconFileInput: { + display: 'none', + }, +}; + +export class UploadIconField extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + fileBase64: null, + }; + + this.fileInputRef = null; + } + + setFileInputRef = element => { + this.fileInputRef = element; + }; + + handleChooseFileClick = () => { + if (this.fileInputRef) { + this.fileInputRef.click(); + } + }; + + handleFileChange = async event => { + const file = event.target.files[0]; + if (!file) { + return; + } + if (typeof this.props.onChange === 'function') { + this.props.onChange(event); + } + // used to preview image + // since this is async, we can't do it during render + const fileBase64 = await fileToBase64(file); + this.setState({ + fileBase64, + }); + }; + + renderIconFromBase64 = srcBase64 => { + const contextPath = this.context.d2.system.systemInfo.contextPath; + const altText = this.context.d2.i18n.getTranslation('current_icon'); + const fallbackIconPath = `${contextPath}/api/icons/dhis2_logo_outline/icon.svg`; + return ( + {altText} { + target.onerror = ''; + target.src = fallbackIconPath; + return true; + }} + /> + ); + }; + + render() { + const accept = this.props.accept || 'image/png'; + const label = + this.props.label || + this.context.d2.i18n.getTranslation('choose_file_to_upload'); + return ( +
+ + +
+ ); + } +} + +UploadIconField.propTypes = { + label: PropTypes.string, + onChange: PropTypes.func.isRequired, + accept: PropTypes.string, +}; +UploadIconField.contextTypes = { + d2: PropTypes.object, +}; diff --git a/src/i18n/i18n_module_en.properties b/src/i18n/i18n_module_en.properties index 93d9f8131..fb3ca7249 100644 --- a/src/i18n/i18n_module_en.properties +++ b/src/i18n/i18n_module_en.properties @@ -2183,6 +2183,9 @@ icons_positive=Positive icons_negative=Negative icons_outline=Outline icon_search=Search for icons +upload_icon=Upload icon +choose_file_to_upload=Choose file to upload +icons_custom=Custom differs_from_program=Differs from program apply_to_selected_stages=Apply to selected stages