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

icon picker modal #2908

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 packages/@coorpacademy-components/locales/en/global.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"learning_priority_modal_title": "New learning priority",
"learning_priority_modal_description": "Create a learning priority by selecting either one skill, playlist or certification.",
"locked": "Locked",
"icon_picker_title": "Change icon",
"icon_picker_description": "Select a new icon for your skill",
"skills_change_focus": "Change skill focus",
"skills_choose_focus": "Choose your focus",
"cancel": "Cancel",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import ButtonLink from '../../atom/button-link';
import style from './style.css';

const BaseModal = (props, context) => {
const {title, description, headerIcon, children, isOpen, footer, onClose} = props;
const {title, description, headerIcon, children, isOpen, footer, onClose, onScroll} = props;
const {skin} = context;

const Footer = useCallback(() => {
Expand Down Expand Up @@ -107,11 +107,13 @@ const BaseModal = (props, context) => {
<div className={style.headerTitle}>{title}</div>
{description ? <div className={style.headerDescription}>{description}</div> : null}
</div>
<div className={style.headerCloseIcon} onClick={handleOnClose}>
<div className={style.headerCloseIcon} onClick={handleOnClose} data-testid="close-icon">
<Icon iconName="close" backgroundColor="#F4F4F5" size={{faSize: 14, wrapperSize: 28}} />
</div>
</header>
<div className={style.body}>{children}</div>
<div className={style.body} onScroll={onScroll} data-testid="modal-body">
{children}
</div>
<Footer />
</div>
</div>
Expand Down Expand Up @@ -151,7 +153,8 @@ BaseModal.propTypes = {
})
})
]),
onClose: PropTypes.func
onClose: PropTypes.func,
onScroll: PropTypes.func
};

export default BaseModal;
Copy link
Member

Choose a reason for hiding this comment

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

tu pourrai sortir tout ce qui concerne la search ds un custom hooks. pour separer la logic de search de ton composant UI. car cest un peu mélangé la.

search + deboucing + search Result genre useIconSearch

Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React, {useMemo, useState, useCallback, useEffect} from 'react';
import PropTypes from 'prop-types';
import {fas} from '@fortawesome/pro-solid-svg-icons';
import {debounce, map, pipe, get, values, slice, filter} from 'lodash/fp';
import BaseModal from '../base-modal';
import SelectIcon from '../../atom/select-icon';
import Provider from '../../atom/provider';
import SearchForm from '../search-form';
import {COLORS} from '../../variables/colors';
import style from './style.css';

export const filterIcons = (query, allIcons) => {
return query
? filter(iconName => iconName.toLowerCase().includes(query.toLowerCase()), allIcons)
: allIcons;
};

const ICONS_PER_LOAD = 48;

const IconPickerModal = (props, context) => {
const {isOpen, onCancel, onConfirm, onClose} = props;
const {translate} = context;

const [selectedIcon, setSelectedIcon] = useState(null);
const [searchValue, setSearchValue] = useState('');
const [displayedIcons, setDisplayedIcons] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);

const allIcons = useMemo(() => pipe(values, map(get('iconName')))(fas), []);
const [searchResults, setSearchResults] = useState(allIcons);

const handleCancel = useCallback(() => {
onCancel();
}, [onCancel]);

const handleClose = useCallback(() => {
onClose();
}, [onClose]);

const handleIconClick = useCallback(
index => () => {
setSelectedIcon(prevSelectedIcon => (prevSelectedIcon === index ? null : index));
},
[]
);

const loadMoreIcons = useCallback(() => {
const nextIndex = currentIndex + ICONS_PER_LOAD;
const newIcons = slice(currentIndex, nextIndex, searchResults);
setDisplayedIcons(prevIcons => [...prevIcons, ...newIcons]);
setCurrentIndex(nextIndex);
}, [currentIndex, searchResults]);

useEffect(() => {
setDisplayedIcons(() => slice(0, ICONS_PER_LOAD, searchResults));
setCurrentIndex(ICONS_PER_LOAD);
}, [searchResults]);

const handleScroll = useCallback(
event => {
const {scrollTop, clientHeight, scrollHeight} = event.currentTarget;
if (scrollHeight - scrollTop <= clientHeight + 1) {
loadMoreIcons();
}
},
[loadMoreIcons]
);

const updateSearchResults = useCallback(
query => {
const results = filterIcons(query, allIcons);
setSearchResults(results);
},
[allIcons]
);

const debouncedSearch = useMemo(() => debounce(300, updateSearchResults), [updateSearchResults]);

const handleSearch = useCallback(
value => {
setSearchValue(value);
debouncedSearch(value);
},
[debouncedSearch]
);

const handleReset = useCallback(() => {
setSearchValue('');
updateSearchResults('');
}, [updateSearchResults]);

const icons = useMemo(
() =>
displayedIcons.map((iconName, index) => (
<SelectIcon
key={`icon-${index}`}
size="responsive"
data-name={`icon-${index}`}
aria-label={`aria icon ${index}`}
faIcon={iconName}
onClick={handleIconClick(index)}
options={{isSelected: selectedIcon === index}}
/>
)),
[displayedIcons, selectedIcon, handleIconClick]
);

const footer = useMemo(() => {
return {
cancelButton: {
onCancel: handleCancel,
label: translate('cancel')
},
confirmButton: {
onConfirm: () => {
onConfirm(selectedIcon);
setSelectedIcon(null);
onClose();
},
label: translate('confirm'),
iconName: 'plus',
disabled: selectedIcon === null,
color: COLORS.cm_primary_blue
}
};
}, [handleCancel, onConfirm, onClose, translate, selectedIcon]);

if (!isOpen) return null;

return (
<BaseModal
title={translate('icon_picker_title')}
description={translate('icon_picker_description')}
isOpen={isOpen}
onClose={handleClose}
onScroll={handleScroll}
footer={footer}
headerIcon={{
name: 'arrows-rotate',
backgroundColor: '#D6E6FF'
}}
>
<div className={style.iconPicker}>
{
<>
<div className={style.searchWrapper}>
<SearchForm
search={{
placeholder: translate('search_place_holder'),
value: searchValue,
onChange: handleSearch
}}
onReset={handleReset}
dataTestId="search-input"
/>
</div>
{searchValue && searchResults.length === 0 ? (
<div className={style.emptySearchResultContainer}>
<div className={style.emptySearchResultTitle}>
{translate('empty_search_result_title', {searchValue})}
</div>
<div className={style.emptySearchResultDescription}>
{translate('empty_search_result_description')}
</div>
<div className={style.emptySearchResultClearSearch} onClick={handleReset}>
{translate('empty_search_result_clear_search')}
</div>
</div>
) : (
<div className={style.iconsListWrapper}>{icons}</div>
)}
</>
}
</div>
</BaseModal>
);
};

IconPickerModal.contextTypes = {
translate: Provider.childContextTypes.translate
};

IconPickerModal.propTypes = {
isOpen: PropTypes.bool,
onCancel: PropTypes.func,
onConfirm: PropTypes.func,
onClose: PropTypes.func
};

export default IconPickerModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@value colors: "../../variables/colors.css";
@value cm_grey_100 from colors;
@value cm_primary_blue from colors;
@value cm_grey_500 from colors;

.iconPicker {
height: 485px;
width: calc(71vw - 68px);
max-width: 612px;
}

.searchWrapper {
border-radius: 12px;
width: 300px;
background-color: cm_grey_100;
margin-bottom: 20px;
}

.iconsListWrapper {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
margin: auto;
gap: 12px;
overflow: visible;
}

.emptySearchResultContainer {
display: flex;
flex-direction: column;
align-items: center;
}

.emptySearchResultTitle {
font-size: 20px;
font-weight: 700;
line-height: 28px;
margin-bottom: 8px;
}

.emptySearchResultDescription {
font-size: 16px;
font-weight: 500;
line-height: 22px;
margin-bottom: 16px;
}

.emptySearchResultClearSearch {
color: #0061FF;
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 20px;
cursor: pointer;
}

@media mobile {
.iconPicker {
width: 100%;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
props: {
isOpen: true,
onCancel: () => {},
onConfirm: () => {},
onClose: () => {}
}
};
Loading