Skip to content

Commit

Permalink
feat(icons): support uploading icons (#2818)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Birkbjo authored Mar 21, 2024
1 parent ef4c98e commit 73990c0
Show file tree
Hide file tree
Showing 9 changed files with 603 additions and 122 deletions.
28 changes: 27 additions & 1 deletion scss/IconPicker/IconPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -62,6 +62,7 @@
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}

&__icon-list {
Expand All @@ -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;
}
}
24 changes: 19 additions & 5 deletions src/forms/form-fields/helpers/IconPickerDialog/Icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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}
/>
Expand Down
136 changes: 136 additions & 0 deletions src/forms/form-fields/helpers/IconPickerDialog/IconList.js
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div>
{this.state.selectedIcon && (
<Icon
icon={this.state.selectedIcon}
selectedIconKey={this.props.selectedIconKey}
handleClick={this.props.onIconSelect}
/>
)}
{icons.map(icon => (
<Icon
icon={icon}
key={icon.key}
selectedIconKey={this.props.selectedIconKey}
handleClick={this.props.onIconSelect}
/>
))}
</div>

<div
className="icon-picker__list-loader"
style={shouldShowLoading ? undefined : { display: 'none' }}
ref={ref => (this.loadingRef = ref)}
>
<CircularProgress />
</div>
</div>
);
}
}

IconList.propTypes = {
type: PropTypes.oneOf(['default', 'custom', 'all']).isRequired,
textFilter: PropTypes.string,
selectedIconKey: PropTypes.string,
onIconSelect: PropTypes.func.isRequired,
};

IconList.contextTypes = {
d2: PropTypes.object,
};
140 changes: 140 additions & 0 deletions src/forms/form-fields/helpers/IconPickerDialog/IconPickerCustomTab.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className={classes('icon-picker-custom', 'wrapper')}>
<div className={'form'}>
<UploadIconField onChange={this.handleFileChange} />
{this.state.iconFile && (
<div className="form-fields">
<TextField
name="key"
onChange={this.handleIconMetadataChange}
floatingLabelText="Icon Key"
value={iconKey}
required
/>
<TextField
name="description"
onChange={this.handleIconMetadataChange}
floatingLabelText="Description"
/>
<TextField
name="keywords"
onChange={this.handleIconKeywordsChange}
floatingLabelText="Keywords"
multiLine
hintText="Separate keywords by ,"
/>
{this.state.uploadError && (
<ErrorMessage
message={this.state.uploadError.message}
/>
)}
<RaisedButton
className="upload-button"
onClick={this.handleFileUpload}
label={this.t('upload_icon')}
primary
disabled={this.state.uploading || !this.state.iconMetadata.key}
/>
</div>
)}
</div>
<Divider />
<div>{this.props.children}</div>
</div>
);
}
}

IconPickerCustomTab.propTypes = {
children: PropTypes.node,
onIconUpload: PropTypes.func,
};

IconPickerCustomTab.contextTypes = {
d2: PropTypes.object,
};
Loading

0 comments on commit 73990c0

Please sign in to comment.