diff --git a/packages/app-admin/package.json b/packages/app-admin/package.json index 24f5fce020d..fcce47cbb79 100644 --- a/packages/app-admin/package.json +++ b/packages/app-admin/package.json @@ -16,6 +16,7 @@ "@editorjs/editorjs": "^2.19.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", + "@iconify/json": "^2.2.142", "@material-design-icons/svg": "^0.14.3", "@svgr/webpack": "^6.1.1", "@types/mime": "^2.0.3", @@ -28,6 +29,7 @@ "@webiny/lexical-editor": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/react-composition": "0.0.0", + "@webiny/react-properties": "0.0.0", "@webiny/react-router": "0.0.0", "@webiny/telemetry": "0.0.0", "@webiny/ui": "0.0.0", @@ -44,15 +46,19 @@ "emotion": "^10.0.17", "graphlib": "^2.1.7", "graphql": "^15.7.2", + "graphql-tag": "^2.12.6", "is-hotkey": "^0.1.3", "lodash": "^4.17.11", "mobx": "^6.9.0", + "mobx-react-lite": "^3.4.3", "prop-types": "^15.7.2", "react": "17.0.2", "react-dom": "17.0.2", "react-hotkeyz": "^1.0.4", "react-transition-group": "^4.3.0", - "store": "^2.0.12" + "react-virtualized": "^9.21.2", + "store": "^2.0.12", + "unicode-emoji-json": "^0.4.0" }, "devDependencies": { "@babel/cli": "^7.22.6", diff --git a/packages/app-admin/src/base/Admin.tsx b/packages/app-admin/src/base/Admin.tsx index cf5dce7428d..8ce88262bda 100644 --- a/packages/app-admin/src/base/Admin.tsx +++ b/packages/app-admin/src/base/Admin.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { App, Provider } from "@webiny/app"; +import { App } from "@webiny/app"; import { WcpProvider } from "@webiny/app-wcp"; import { ApolloClientFactory, createApolloProvider } from "./providers/ApolloProvider"; import { Base } from "./Base"; @@ -8,6 +8,7 @@ import { createUiStateProvider } from "./providers/UiStateProvider"; import { SearchProvider } from "./ui/Search"; import { UserMenuProvider } from "./ui/UserMenu"; import { NavigationProvider } from "./ui/Navigation"; +import { DefaultIcons, IconPickerConfigProvider } from "~/components/IconPicker/config"; import { CircularProgress } from "@webiny/ui/Progress"; import { ThemeProvider } from "@webiny/app-theme"; @@ -25,12 +26,17 @@ export const Admin = ({ children, createApolloClient }: AdminProps) => { }> - - - - - - + + {children} diff --git a/packages/app-admin/src/base/ui/FileManager.tsx b/packages/app-admin/src/base/ui/FileManager.tsx index 786c609f8b8..bf63e0478f8 100644 --- a/packages/app-admin/src/base/ui/FileManager.tsx +++ b/packages/app-admin/src/base/ui/FileManager.tsx @@ -12,6 +12,7 @@ export interface FileManagerOnChange { export interface FileManagerFileItem { id: string; src: string; + name?: string; meta?: Array; } @@ -94,7 +95,7 @@ export type FileManagerRendererProps = DistributiveOmit("FileManagerRenderer"); -export const FileManager = ({ children, render, onChange, ...rest }: FileManagerProps) => { +export const FileManager = ({ children, render, onChange, onClose, ...rest }: FileManagerProps) => { const containerRef = useRef(getPortalTarget()); const [show, setShow] = useState(rest.show ?? false); const onChangeRef = useRef(onChange); @@ -110,6 +111,14 @@ export const FileManager = ({ children, render, onChange, ...rest }: FileManager setShow(true); }, []); + const handleClose = useCallback(() => { + if (onClose) { + onClose(); + } + + setShow(false); + }, [onClose]); + return ( <> {show && @@ -119,7 +128,7 @@ export const FileManager = ({ children, render, onChange, ...rest }: FileManager */ // @ts-expect-error setShow(false)} + onClose={handleClose} onChange={ /* TODO: figure out how to create a conditional type based on the value of `rest.multiple` */ onChangeRef.current diff --git a/packages/app-admin/src/components/IconPicker/IconPicker.styles.ts b/packages/app-admin/src/components/IconPicker/IconPicker.styles.ts new file mode 100644 index 00000000000..17d0b3c68b7 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconPicker.styles.ts @@ -0,0 +1,115 @@ +import { css } from "emotion"; +import styled from "@emotion/styled"; + +export const IconPickerWrapper = styled.div` + .mdc-menu-surface { + overflow: visible !important; + } +`; + +export const iconPickerLabel = css` + margin-bottom: 5px; + margin-left: 2px; +`; + +export const IconPickerInput = styled.div` + background-color: ${props => props.theme.styles.colors.color5}; + border-bottom: 1px solid ${props => props.theme.styles.colors.color3}; + padding: 8px; + height: 32px; + width: fit-content; + cursor: pointer; + :hover { + border-bottom: 1px solid ${props => props.theme.styles.colors.color3}; + } +`; + +export const MenuHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + text-transform: uppercase; + padding: 12px; + border-bottom: 1px solid ${props => props.theme.styles.colors.color5}; + color: ${props => props.theme.styles.colors.color4}; + + & > svg { + cursor: pointer; + fill: ${props => props.theme.styles.colors.color4}; + } +`; + +export const MenuContent = styled.div` + position: relative; + width: 364px; + height: 524px; +`; + +export const Row = styled.div` + display: flex; + align-items: center; +`; + +export const Cell = styled.div<{ isActive: boolean }>` + cursor: pointer; + background-color: ${({ isActive, theme }) => + isActive ? theme.styles.colors.color5 : theme.styles.colors.color6}; + + &:hover { + background: ${({ theme }) => theme.styles.colors.color5}; + } + + & > * { + padding: 4px; + } +`; + +export const CategoryLabel = styled.div` + align-self: flex-end; + margin-bottom: 4px; + text-transform: uppercase; + ${props => props.theme.styles.typography.paragraphs.stylesById("paragraph2")}; + color: ${props => props.theme.styles.colors.color4}; +`; + +export const TabContentWrapper = styled.div` + width: 340px; + padding: 12px; +`; + +export const ListWrapper = styled.div` + position: relative; +`; + +export const NoResultsWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 400px; +`; + +export const InputsWrapper = styled.div` + display: flex; + column-gap: 12px; + padding-bottom: 12px; + height: 40px; + + [class$="color"] { + height: 24px; + width: 24px; + margin: 3px; + border-radius: 50%; + } + + [class$="classNames"] { + display: none; + } + + .webiny-ui-input { + height: 40px !important; + } +`; + +export const placeholderIcon = css` + fill: #00000040; +`; diff --git a/packages/app-admin/src/components/IconPicker/IconPicker.tsx b/packages/app-admin/src/components/IconPicker/IconPicker.tsx new file mode 100644 index 00000000000..8c84af23b47 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconPicker.tsx @@ -0,0 +1,40 @@ +import React, { useMemo, useEffect } from "react"; +import { useIconPickerConfig } from "./config"; +import { iconRepositoryFactory } from "./IconRepositoryFactory"; +import { IconPickerPresenter } from "./IconPickerPresenter"; +import { IconPickerComponent, IconPickerProps } from "./IconPickerComponent"; +import { IconProvider, IconRenderer } from "./IconRenderer"; +import { IconPickerTab } from "./IconPickerTab"; +import { Icon } from "./types"; + +const IconPicker = (props: IconPickerProps) => { + const { iconTypes, iconPackProviders } = useIconPickerConfig(); + const repository = iconRepositoryFactory.getRepository(iconTypes, iconPackProviders); + + const presenter = useMemo(() => { + return new IconPickerPresenter(repository); + }, [repository]); + + useEffect(() => { + presenter.load(props.value); + }, [repository, props.value]); + + return ; +}; + +interface IconRendererWithProviderProps { + icon: Icon; +} + +const IconRendererWithProvider = ({ icon }: IconRendererWithProviderProps) => { + return ( + + + + ); +}; + +IconPicker.Icon = IconRendererWithProvider; +IconPicker.IconPickerTab = IconPickerTab; + +export { IconPicker }; diff --git a/packages/app-admin/src/components/IconPicker/IconPickerComponent.tsx b/packages/app-admin/src/components/IconPicker/IconPickerComponent.tsx new file mode 100644 index 00000000000..0c8b0291061 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconPickerComponent.tsx @@ -0,0 +1,119 @@ +import React, { useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import isEqual from "lodash/isEqual"; +import { ReactComponent as CloseIcon } from "@material-design-icons/svg/outlined/close.svg"; +import { ReactComponent as SearchIcon } from "@material-design-icons/svg/outlined/search.svg"; + +import { Menu } from "@webiny/ui/Menu"; +import { Tabs } from "@webiny/ui/Tabs"; +import { Typography } from "@webiny/ui/Typography"; +import { FormElementMessage } from "@webiny/ui/FormElementMessage"; +import { FormComponentProps } from "@webiny/ui/types"; +import { CircularProgress } from "@webiny/ui/Progress"; + +import { IconPickerPresenter } from "./IconPickerPresenter"; +import { IconProvider, IconRenderer } from "./IconRenderer"; +import { + IconPickerWrapper, + iconPickerLabel, + IconPickerInput, + MenuContent, + MenuHeader, + placeholderIcon +} from "./IconPicker.styles"; +import { IconPickerTabRenderer } from "./IconPickerTab"; +import { IconPickerPresenterProvider } from "./IconPickerPresenterProvider"; +import { IconTypeProvider } from "./config/IconType"; + +export interface IconPickerProps extends FormComponentProps { + label?: string; + description?: string; +} + +export interface IconPickerComponentProps extends IconPickerProps { + presenter: IconPickerPresenter; +} + +export const IconPickerComponent = observer( + ({ presenter, label, description, ...props }: IconPickerComponentProps) => { + const { value, onChange } = props; + const { isValid: validationIsValid, message: validationMessage } = props.validation || {}; + const { activeTab, isMenuOpened, isLoading, iconTypes, selectedIcon } = presenter.vm; + + useEffect(() => { + if (onChange && selectedIcon && !isEqual(selectedIcon, value)) { + onChange(selectedIcon); + } + }, [selectedIcon]); + + const setActiveTab = (index: number) => presenter.setActiveTab(index); + + const openMenu = () => presenter.openMenu(); + const closeMenu = () => presenter.closeMenu(); + + return ( + + + {label && ( +
+ {label} +
+ )} + + + {selectedIcon ? ( + + + + ) : ( + + )} + + } + onClose={closeMenu} + onOpen={openMenu} + > + {() => ( + <> + + Select an icon + + + + {isLoading && } + setActiveTab(value)} + > + {iconTypes.map(iconType => ( + + + + ))} + + + + )} + + + {validationIsValid === false && ( + {validationMessage} + )} + {validationIsValid !== false && description && ( + {description} + )} +
+
+ ); + } +); diff --git a/packages/app-admin/src/components/IconPicker/IconPickerPresenter.test.ts b/packages/app-admin/src/components/IconPicker/IconPickerPresenter.test.ts new file mode 100644 index 00000000000..104efc3fa75 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconPickerPresenter.test.ts @@ -0,0 +1,118 @@ +import { IconPickerPresenter } from "./IconPickerPresenter"; +import { IconRepository } from "./IconRepository"; +import { Icon } from "./types"; + +const mockIconTypes = [{ name: "icon" }, { name: "emoji" }, { name: "custom" }]; + +const mockIcons: Icon[] = [ + { + type: "emoji", + name: "thumbs_up", + value: "👍", + category: "People & Body", + skinToneSupport: true + }, + { + type: "icon", + name: "regular_address-book", + value: '', + category: "Business" + } +]; + +const mockIconPackProviders = [ + { + name: "mock_icons", + getIcons: async () => { + return mockIcons; + } + } +]; + +describe("IconPickerPresenter", () => { + const icon: Icon = { + type: "icon", + name: "solid_bullseye", + value: '', + category: "Business", + color: "#282fe6" + }; + + let presenter: IconPickerPresenter; + + beforeEach(() => { + const repository = new IconRepository(mockIconTypes, mockIconPackProviders); + presenter = new IconPickerPresenter(repository); + }); + + it("should create an IconPickerPresenter with the `vm` definition", async () => { + // let's load icons and set a predefined `selectedIcon` + await presenter.load(icon); + + // `vm` should have the expected `selectedIcon` definition + expect(presenter.vm.selectedIcon).toEqual(icon); + + // `vm` should have the expected `icons` definition + expect(presenter.vm.icons).toEqual(mockIcons); + }); + + it("should be able to select an icon", async () => { + // let's load icons + await presenter.load(); + + // should be able to set the icon + presenter.setIcon(presenter.vm.icons[0]); + + // `vm` should have the expected `selectedIcon` value + expect(presenter.vm.selectedIcon).toEqual(presenter.vm.icons[0]); + }); + + it("should be able to add an icon", async () => { + // let's load icons + await presenter.load(); + + // should be able to set the icon + presenter.addIcon(icon); + + // `vm` should have three icons + expect(presenter.vm.icons.length).toBe(3); + + // `vm` should have the expected `icons` value + expect(presenter.vm.icons).toEqual([...mockIcons, icon]); + }); + + it("should be able to filter icons by name", async () => { + // let's load icons + await presenter.load(); + + // should be able to set the filter + presenter.setFilter("book"); + + // `vm` should have only one icon + expect(presenter.vm.icons.length).toBe(1); + + // `vm` should have filtered icon + expect(presenter.vm.icons[0]).toEqual(mockIcons[1]); + }); + + it("should be able to set active tab on menu open", async () => { + // let's load icons and set a predefined `selectedIcon` + await presenter.load(mockIcons[0]); + + // default `isMenuOpened` should be false + expect(presenter.vm.isMenuOpened).toBe(false); + + // default `activeTab` should be 0 + expect(presenter.vm.activeTab).toBe(0); + + // should be able to set `isMenuOpened` + // should be able to set `activeTab` based on `selectedIcon` type + presenter.openMenu(); + + // `vm` should have the expected `isMenuOpened` value + expect(presenter.vm.isMenuOpened).toBe(true); + + // `vm` should have the expected `activeTab` value + expect(presenter.vm.activeTab).toBe(1); + }); +}); diff --git a/packages/app-admin/src/components/IconPicker/IconPickerPresenter.ts b/packages/app-admin/src/components/IconPicker/IconPickerPresenter.ts new file mode 100644 index 00000000000..3c2756044e5 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconPickerPresenter.ts @@ -0,0 +1,104 @@ +import { makeAutoObservable, toJS } from "mobx"; + +import { IconRepository } from "./IconRepository"; +import { Icon } from "./types"; +import { IconType } from "./config"; + +export interface IconPickerPresenterInterface { + load(icon: Icon): Promise; + setIcon(icon: Icon): void; + addIcon(icon: Icon): void; + setFilter(value: string): void; + setActiveTab(index: number): void; + openMenu(): void; + closeMenu(): void; + get vm(): { + isLoading: boolean; + activeTab: number; + isMenuOpened: boolean; + icons: Icon[]; + iconTypes: IconType[]; + selectedIcon: Icon | null; + filter: string; + }; +} + +export class IconPickerPresenter implements IconPickerPresenterInterface { + private repository: IconRepository; + private selectedIcon: Icon | null = null; + private filter = ""; + private activeTab = 0; + private isMenuOpened = false; + + constructor(repository: IconRepository) { + this.repository = repository; + makeAutoObservable(this); + } + + async load(value: Icon | null = null) { + this.selectedIcon = value; + + await this.repository.loadIcons(); + } + + get vm() { + return { + activeTab: this.activeTab, + isMenuOpened: this.isMenuOpened, + isLoading: this.repository.getLoading().isLoading, + icons: this.getFilteredIcons(), + iconTypes: this.repository.getIconTypes(), + // `toJS` will unwrap an observable into a POJO. This will make it simple to use in child components. + selectedIcon: toJS(this.selectedIcon), + filter: this.filter + }; + } + + addIcon(icon: Icon) { + this.repository.addIcon(icon); + } + + closeMenu(): void { + this.isMenuOpened = false; + } + + openMenu(): void { + this.isMenuOpened = true; + this.resetActiveTab(); + } + + setActiveTab(index: number) { + this.activeTab = index; + } + + setIcon(icon: Icon) { + this.selectedIcon = icon; + } + + setFilter(value: string) { + this.filter = value; + } + + private getFilteredIcons() { + const hyphenUnderscoreRegex = /[-_]/g; + const icons = this.repository.getIcons(); + + return icons.filter(icon => + icon.name + .replace(hyphenUnderscoreRegex, " ") + .toLowerCase() + .includes(this.filter.toLowerCase()) + ); + } + + private getActiveTabByType(type: string) { + const iconTypes = this.repository.getIconTypes(); + const index = iconTypes.findIndex(iconsByType => iconsByType.name === type); + + return index !== -1 ? index : 0; + } + + private resetActiveTab() { + this.setActiveTab(this.selectedIcon ? this.getActiveTabByType(this.selectedIcon.type) : 0); + } +} diff --git a/packages/app-admin/src/components/IconPicker/IconPickerPresenterProvider.tsx b/packages/app-admin/src/components/IconPicker/IconPickerPresenterProvider.tsx new file mode 100644 index 00000000000..f3e89603c21 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconPickerPresenterProvider.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +import { IconPickerPresenterInterface } from "./IconPickerPresenter"; + +interface IconPickerPresenterProviderProps { + presenter: IconPickerPresenterInterface; + children: React.ReactNode; +} + +const IconPickerPresenterContext = React.createContext( + undefined +); + +export const IconPickerPresenterProvider = ({ + presenter, + children +}: IconPickerPresenterProviderProps) => { + return ( + + {children} + + ); +}; + +export function useIconPicker() { + const context = React.useContext(IconPickerPresenterContext); + if (!context) { + throw Error(`Missing in the component tree!`); + } + return context; +} diff --git a/packages/app-admin/src/components/IconPicker/IconPickerTab.tsx b/packages/app-admin/src/components/IconPicker/IconPickerTab.tsx new file mode 100644 index 00000000000..b4b2ac496d6 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconPickerTab.tsx @@ -0,0 +1,185 @@ +import React, { Fragment } from "react"; +import { List } from "react-virtualized"; +import groupBy from "lodash/groupBy"; + +import { Tab } from "@webiny/ui/Tabs"; +import { Typography } from "@webiny/ui/Typography"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; +import { Input } from "@webiny/ui/Input"; +import { makeComposable } from "@webiny/react-composition"; + +import { IconProvider, IconRenderer } from "./IconRenderer"; +import { + Row, + Cell, + CategoryLabel, + TabContentWrapper, + ListWrapper, + NoResultsWrapper, + InputsWrapper +} from "./IconPicker.styles"; +import { useIconPicker } from "./IconPickerPresenterProvider"; +import { useIconType } from "./config/IconType"; +import { Icon, IconPickerGridRow } from "./types"; + +const COLUMN_COUNT = 8; + +export const IconPickerTabRenderer = makeComposable("IconPickerTabRenderer"); + +const getRows = (icons: Icon[]) => { + // Group the icons by their category. + const groupedObjects = groupBy(icons, "category"); + const rows: IconPickerGridRow[] = []; + + // Iterate over each category in the grouped icons. + for (const key in groupedObjects) { + // Skip any group where the key is `undefined` (these icons will be handled separately). + if (key !== "undefined") { + const rowIcons = groupedObjects[key]; + + // Add a row for the category name. + rows.push({ type: "category-name", name: key }); + + // Split the icons in this category into groups of COLUMN_COUNT and add them as rows. + while (rowIcons.length) { + rows.push({ type: "icons", icons: rowIcons.splice(0, COLUMN_COUNT) }); + } + } + } + + // Handle icons that don't have a category (key is `undefined`). + if (groupedObjects.undefined) { + const rowIcons = groupedObjects.undefined; + + // Add a row for the `Uncategorized` category name. + rows.push({ type: "category-name", name: "Uncategorized" }); + + // Split these icons into groups of COLUMN_COUNT and add them as rows. + while (rowIcons.length) { + rows.push({ type: "icons", icons: rowIcons.splice(0, COLUMN_COUNT) }); + } + } + + return rows; +}; + +const useIconTypeRows = (type: string) => { + const presenter = useIconPicker(); + const icons = presenter.vm.icons.filter(icon => icon.type === type); + const rows = getRows(icons); + + return { + isEmpty: rows.length === 0, + rows, + rowCount: rows.length + }; +}; + +interface RenderRowProps { + onIconClick: (icon: Icon) => void; + style: Record; + row: IconPickerGridRow; + cellDecorator: CellDecorator; +} + +const RowRenderer = ({ row, style, cellDecorator, onIconClick }: RenderRowProps) => { + const presenter = useIconPicker(); + const value = presenter.vm.selectedIcon; + + if (row.type === "category-name") { + return ( + + {row.name} + + ); + } + + return ( + + {row.icons.map((item, itemKey) => ( + + {cellDecorator( + onIconClick(item)} + > + + + + + )} + + ))} + + ); +}; + +interface CellDecorator { + (cell: React.ReactElement): React.ReactElement; +} + +const noopDecorator: CellDecorator = cell => cell; + +export interface IconPickerTabProps { + label: string; + onChange: (icon: Icon) => void; + actions?: React.ReactElement; + cellDecorator?: CellDecorator; +} + +export const IconPickerTab = ({ + label, + actions, + onChange, + cellDecorator = noopDecorator +}: IconPickerTabProps) => { + const { type } = useIconType(); + const { isEmpty, rowCount, rows } = useIconTypeRows(type); + const presenter = useIconPicker(); + + return ( + + + + presenter.setFilter(value)} + > + {({ value, onChange }) => ( + + )} + + {actions ? actions : null} + + + {isEmpty ? ( + + No results found. + + ) : ( + ( + + )} + height={400} + rowCount={rowCount} + rowHeight={40} + width={340} + /> + )} + + + + ); +}; diff --git a/packages/app-admin/src/components/IconPicker/IconRenderer.tsx b/packages/app-admin/src/components/IconPicker/IconRenderer.tsx new file mode 100644 index 00000000000..3f5e9c3dc1a --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconRenderer.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { toJS } from "mobx"; + +import { makeComposable } from "@webiny/react-composition"; + +import { Icon } from "./types"; + +export const IconRenderer = makeComposable("IconPickerIcon"); + +interface IconContext { + icon: T; +} + +const IconContext = React.createContext(undefined); + +interface IconProviderProps { + icon: Icon; + children: React.ReactNode; +} + +export const IconProvider = ({ icon, children }: IconProviderProps) => { + // I want to use the POJO via the context, to reduce the need of using `observer` HOC everywhere. + return {children}; +}; + +export function useIcon(): IconContext { + const context = React.useContext(IconContext); + if (!context) { + throw Error(`Missing in the component tree!`); + } + return context as IconContext; +} diff --git a/packages/app-admin/src/components/IconPicker/IconRepository.test.ts b/packages/app-admin/src/components/IconPicker/IconRepository.test.ts new file mode 100644 index 00000000000..f8864c90e5e --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconRepository.test.ts @@ -0,0 +1,70 @@ +import { IconRepository } from "./IconRepository"; +import { Icon } from "./types"; + +const mockIconTypes = [{ name: "icon" }, { name: "emoji" }, { name: "custom" }]; + +const mockIcons: Icon[] = [ + { + type: "emoji", + name: "thumbs_up", + value: "👍", + category: "People & Body", + skinToneSupport: true + }, + { + type: "icon", + name: "regular_address-book", + value: '', + category: "Business" + } +]; + +const mockIconPackProviders = [ + { + name: "mock_icons", + getIcons: async () => { + return mockIcons; + } + } +]; + +describe("IconRepository", () => { + const icon: Icon = { + type: "icon", + name: "solid_bullseye", + value: '', + category: "Business", + color: "#282fe6" + }; + + it("should create an IconRepository and load icons and iconTypes", async () => { + // create repository + const repository = new IconRepository(mockIconTypes, mockIconPackProviders); + + // repository should get the expected iconTypes array + expect(repository.getIconTypes()).toEqual(mockIconTypes); + + // getIcons should return empty array + expect(repository.getIcons()).toEqual([]); + + // load icons + await repository.loadIcons(); + + // getIcons should return the expected icons array + expect(repository.getIcons()).toEqual(mockIcons); + }); + + it("should create an IconRepository and add icon", () => { + // create repository + const repository = new IconRepository(mockIconTypes, mockIconPackProviders); + + // getIcons should return empty array + expect(repository.getIcons()).toEqual([]); + + // add icon + repository.addIcon(icon); + + // getIcons should return the expected icons array + expect(repository.getIcons()).toEqual([icon]); + }); +}); diff --git a/packages/app-admin/src/components/IconPicker/IconRepository.ts b/packages/app-admin/src/components/IconPicker/IconRepository.ts new file mode 100644 index 00000000000..e64ddf3ca73 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconRepository.ts @@ -0,0 +1,74 @@ +import cloneDeep from "lodash/cloneDeep"; +import { makeAutoObservable, runInAction } from "mobx"; + +import { Loading } from "./Loading"; +import { IconPackProviderInterface as IconPackProvider, IconType } from "./config"; +import { Icon } from "./types"; + +export class IconRepository { + private readonly iconPackProviders: IconPackProvider[]; + private readonly iconTypes: IconType[]; + private loading: Loading; + private icons: Icon[] = []; + + constructor(iconTypes: IconType[], iconPackProviders: IconPackProvider[]) { + this.iconTypes = iconTypes; + this.loading = new Loading(true); + this.iconPackProviders = iconPackProviders; + makeAutoObservable(this); + } + + async loadIcons() { + if (this.icons.length > 0) { + return; + } + + const icons = await this.runWithLoading(async () => { + const icons = await Promise.all( + this.iconPackProviders.map(provider => provider.getIcons()) + ); + return icons.flat(); + }); + + const iconTypes = this.iconTypes.map(iconType => iconType.name); + + runInAction(() => { + // Make sure we only work with known icon types. + this.icons = icons.filter(icon => iconTypes.includes(icon.type)); + }); + } + + getIcons() { + return cloneDeep(this.icons); + } + + addIcon(icon: Icon) { + this.icons = [...this.icons, icon]; + } + + getIconTypes() { + return this.iconTypes; + } + + getLoading() { + return { + isLoading: this.loading.isLoading, + loadingLabel: this.loading.loadingLabel, + message: this.loading.feedback + }; + } + + private async runWithLoading( + action: () => Promise, + loadingLabel?: string, + successMessage?: string, + failureMessage?: string + ) { + return await this.loading.runCallbackWithLoading( + action, + loadingLabel, + successMessage, + failureMessage + ); + } +} diff --git a/packages/app-admin/src/components/IconPicker/IconRepositoryFactory.ts b/packages/app-admin/src/components/IconPicker/IconRepositoryFactory.ts new file mode 100644 index 00000000000..4abbb930d1b --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/IconRepositoryFactory.ts @@ -0,0 +1,25 @@ +import { IconRepository } from "./IconRepository"; +import { IconPackProviderInterface as IconPackProvider, IconType } from "./config"; + +class IconRepositoryFactory { + private cache: Map = new Map(); + + getRepository(iconTypes: IconType[], iconPackProviders: IconPackProvider[]) { + const cacheKey = this.getCacheKey(iconTypes, iconPackProviders); + + if (!this.cache.has(cacheKey)) { + this.cache.set(cacheKey, new IconRepository(iconTypes, iconPackProviders)); + } + + return this.cache.get(cacheKey) as IconRepository; + } + + private getCacheKey(iconTypes: IconType[], iconPackProviders: IconPackProvider[]) { + return [ + ...iconTypes.map(iconType => iconType.name).sort(), + ...iconPackProviders.map(provider => provider.name).sort() + ].join("#"); + } +} + +export const iconRepositoryFactory = new IconRepositoryFactory(); diff --git a/packages/app-admin/src/components/IconPicker/Loading.ts b/packages/app-admin/src/components/IconPicker/Loading.ts new file mode 100644 index 00000000000..f7cdb135f85 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/Loading.ts @@ -0,0 +1,66 @@ +import { makeAutoObservable } from "mobx"; + +export class Loading { + private _isLoading: boolean; + private _loadingLabel: string; + private _feedback: string; + private _success: boolean; + + constructor(isLoading = false) { + this._isLoading = isLoading; + this._loadingLabel = ""; + this._feedback = ""; + this._success = false; + makeAutoObservable(this); + } + + startLoading(label?: string) { + this._isLoading = true; + this._loadingLabel = label || ""; + this._feedback = ""; + this._success = false; + } + + stopLoadingWithSuccess(message?: string) { + this._isLoading = false; + this._loadingLabel = ""; + this._feedback = message || ""; + this._success = true; + } + + stopLoadingWithError(message?: string) { + this._isLoading = false; + this._loadingLabel = ""; + this._feedback = message || ""; + this._success = false; + } + + get isLoading() { + return this._isLoading; + } + + get loadingLabel() { + return this._loadingLabel; + } + + get feedback() { + return this._feedback; + } + + async runCallbackWithLoading( + callback: () => Promise, + loadingLabel?: string, + successMessage?: string, + failureMessage?: string + ): Promise { + try { + this.startLoading(loadingLabel); + const result = await callback(); + this.stopLoadingWithSuccess(successMessage); + return result; + } catch (e) { + this.stopLoadingWithError(e.message || failureMessage); + throw e; + } + } +} diff --git a/packages/app-admin/src/components/IconPicker/config/IconPackProvider.tsx b/packages/app-admin/src/components/IconPicker/config/IconPackProvider.tsx new file mode 100644 index 00000000000..8a4e2b78a64 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/config/IconPackProvider.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { Property, useIdGenerator } from "@webiny/react-properties"; +import { Icon } from "~/components/IconPicker/types"; + +export type IconPackProviderProps = { + name: string; + provider: () => Promise | Icon[]; +}; + +export const IconPackProvider = ({ name, provider }: IconPackProviderProps) => { + const getId = useIdGenerator("iconPackProvider"); + + return ( + + + + + ); +}; diff --git a/packages/app-admin/src/components/IconPicker/config/IconType.tsx b/packages/app-admin/src/components/IconPicker/config/IconType.tsx new file mode 100644 index 00000000000..a66f7a0380a --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/config/IconType.tsx @@ -0,0 +1,119 @@ +import React from "react"; + +import { Property, useIdGenerator } from "@webiny/react-properties"; +import { createComponentPlugin } from "@webiny/react-composition"; + +import { IconRenderer, useIcon } from "../IconRenderer"; +import { IconPickerTabRenderer } from "../IconPickerTab"; + +export type IconTypeProps = { + name: string; + before?: string; + after?: string; + remove?: boolean; + children?: React.ReactNode; +}; + +interface IconTypeContext { + type: string; +} + +const IconTypeContext = React.createContext(undefined); + +interface IconTypeProviderProps { + type: string; + children: React.ReactNode; +} + +export const IconTypeProvider = ({ type, children }: IconTypeProviderProps) => { + return {children}; +}; + +export function useIconType() { + const context = React.useContext(IconTypeContext); + if (!context) { + throw Error(`Missing in the component tree!`); + } + return context; +} + +export interface IconType extends React.FC { + Icon: typeof Icon; + Tab: typeof Tab; +} + +export const IconType: IconType = ({ + name, + before = undefined, + after = undefined, + remove = false, + children +}) => { + const getId = useIdGenerator("iconType"); + + const placeBefore = before !== undefined ? getId(before) : undefined; + const placeAfter = after !== undefined ? getId(after) : undefined; + + return ( + + + + {children} + + + ); +}; + +export type IconProps = { + element: React.ReactElement; +}; + +export const Icon = ({ element }: IconProps) => { + const { type: configType } = useIconType(); + + const IconDecorator = createComponentPlugin(IconRenderer, Original => { + return function IconRenderer(props) { + const { icon } = useIcon(); + + if (icon.type !== configType) { + return ; + } + + return element; + }; + }); + + return ; +}; + +export type TabProps = { + element: React.ReactElement; +}; + +export const Tab = ({ element }: TabProps) => { + const { type: configType } = useIconType(); + + const IconPickerTabDecorator = createComponentPlugin(IconPickerTabRenderer, Original => { + return function IconPickerTabRenderer(props: React.ComponentProps) { + const { type } = useIconType(); + + if (type !== configType) { + return ; + } + + return element; + }; + }); + + return ; +}; + +IconType.Icon = Icon; +IconType.Tab = Tab; diff --git a/packages/app-admin/src/components/IconPicker/config/index.tsx b/packages/app-admin/src/components/IconPicker/config/index.tsx new file mode 100644 index 00000000000..5573c8990f9 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/config/index.tsx @@ -0,0 +1,177 @@ +import React, { useMemo } from "react"; +import { + icons as fa6RegularIconsJson, + categories as fa6RegularCategoriesJson +} from "@iconify/json/json/fa6-regular.json"; +import { + icons as fa6SolidIconsJson, + categories as fa6SolidCategoriesJson +} from "@iconify/json/json/fa6-solid.json"; +import emojisJson from "unicode-emoji-json/data-by-emoji.json"; +import { Decorator } from "@webiny/react-composition"; +import { createConfigurableComponent } from "@webiny/react-properties"; +import { IconPackProvider as IconPack } from "./IconPackProvider"; +import { IconType } from "./IconType"; +import { SimpleIconPlugin } from "../plugins/iconsPlugin"; +import { EmojiPlugin } from "../plugins/emojisPlugin"; +import { CustomIconPlugin } from "../plugins/customPlugin"; +import { Icon } from "../types"; + +type FaIconSet = { + [key: string]: { + body: string; + width?: number; + }; +}; + +type FaCategorySet = { + [key: string]: string[]; +}; + +type EmojiSet = { + [key: string]: { + name: string; + slug: string; + group: string; + emoji_version: string; + unicode_version: string; + skin_tone_support: boolean; + }; +}; + +const fa6RegularIcons: FaIconSet = fa6RegularIconsJson; +const fa6RegularCategories: FaCategorySet = fa6RegularCategoriesJson; +const fa6SolidIcons: FaIconSet = fa6SolidIconsJson; +const fa6SolidCategories: FaCategorySet = fa6SolidCategoriesJson; +const emojis: EmojiSet = emojisJson; + +const base = createConfigurableComponent("IconPicker"); + +export const IconPickerConfig = Object.assign(base.Config, { IconPack, IconType }); +export const IconPickerWithConfig = base.WithConfig; + +export const IconPickerConfigProvider: Decorator = Original => { + return function IconPickerConfigProvider({ children }) { + return ( + + {children} + + ); + }; +}; + +export interface IconPackLoader { + (): Promise; +} + +interface IconTypeInterface { + name: string; +} + +export { IconTypeInterface as IconType }; + +interface IconPickerConfig { + iconTypes: IconTypeInterface[]; + iconPackProviders: { + name: string; + load: IconPackLoader; + }[]; +} + +export interface IconPackProviderInterface { + name: string; + getIcons(): Promise; +} + +class IconPackProvider implements IconPackProviderInterface { + public readonly name: string; + private readonly loader: IconPackLoader; + + constructor(name: string, loader: IconPackLoader) { + this.name = name; + this.loader = loader; + } + + getIcons(): Promise { + return this.loader(); + } +} + +export function useIconPickerConfig() { + const config = base.useConfig(); + + const iconPackProviders = config.iconPackProviders || []; + + return useMemo( + () => ({ + iconTypes: config.iconTypes || [], + iconPackProviders: iconPackProviders.map( + provider => new IconPackProvider(provider.name, provider.load) + ) + }), + [config] + ); +} + +export const DefaultIcons = () => { + return ( + <> + + {/* Default Emojis Provider */} + + Object.keys(emojis).map(key => { + const emoji = emojis[key]; + return { + type: "emoji", + name: emoji.slug, + value: key, + category: emoji.group, + skinToneSupport: emoji.skin_tone_support + }; + }) + } + /> + {/* Default Icons Providers */} + + Object.keys(fa6RegularIcons).map(key => { + const icon = fa6RegularIcons[key]; + return { + type: "icon", + name: `regular_${key}`, + value: icon.body, + category: Object.keys(fa6RegularCategories).find(categoryKey => + fa6RegularCategories[categoryKey].includes(key) + ), + width: icon.width + }; + }) + } + /> + + Object.keys(fa6SolidIcons).map(key => { + const icon = fa6SolidIcons[key]; + return { + type: "icon", + name: `solid_${key}`, + value: icon.body, + category: Object.keys(fa6SolidCategories).find(categoryKey => + fa6SolidCategories[categoryKey].includes(key) + ), + width: icon.width + }; + }) + } + /> + + + + + + ); +}; diff --git a/packages/app-admin/src/components/IconPicker/index.tsx b/packages/app-admin/src/components/IconPicker/index.tsx new file mode 100644 index 00000000000..4ec90fc9fd1 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/index.tsx @@ -0,0 +1,5 @@ +export { IconPicker } from "./IconPicker"; +export { IconPickerConfig } from "./config"; +export { useIcon } from "./IconRenderer"; +export { useIconPicker } from "./IconPickerPresenterProvider"; +export { useIconType } from "./config/IconType"; diff --git a/packages/app-admin/src/components/IconPicker/plugins/customPlugin.tsx b/packages/app-admin/src/components/IconPicker/plugins/customPlugin.tsx new file mode 100644 index 00000000000..a9d42f94f39 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/plugins/customPlugin.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { useApolloClient } from "@apollo/react-hooks"; +import { observer } from "mobx-react-lite"; +import { css } from "emotion"; + +import { ButtonSecondary } from "@webiny/ui/Button"; + +import { FileManager, FileManagerFileItem } from "~/base/ui/FileManager"; +import { IconPickerTab } from "../IconPickerTab"; +import { useIcon } from ".."; +import { useIconPicker } from "../IconPickerPresenterProvider"; +import { IconPickerConfig } from "../config"; +import { ListCustomIconsQueryResponse, LIST_CUSTOM_ICONS } from "./graphql"; +import { Icon } from "../types"; + +const addButtonStyle = css` + &.mdc-button { + height: 40px; + } +`; + +const CustomIcon = () => { + const { icon } = useIcon(); + + return {icon.name}; +}; + +interface IconFilePickerProps { + onUpload: (file: FileManagerFileItem) => void; + onChange: (file: FileManagerFileItem) => void; +} + +const IconFilePicker = ({ onUpload, onChange }: IconFilePickerProps) => { + return ( + { + onUpload(file); + }} + onChange={onChange} + scope="scope:iconPicker" + accept={["image/svg+xml"]} + > + {({ showFileManager }) => ( + { + showFileManager(); + }} + > + Browse + + )} + + ); +}; + +const CustomIconTab = observer(() => { + const presenter = useIconPicker(); + + const onIconSelect = (icon: Icon) => { + presenter.setIcon(icon); + presenter.closeMenu(); + }; + + const onIconFileSelect = (file: FileManagerFileItem) => { + presenter.setIcon({ + type: "custom", + name: file.name || file.id, + value: file.src + }); + presenter.closeMenu(); + }; + + const onIconFileUpload = (file: FileManagerFileItem) => { + const icon = { + type: "custom", + name: file.name || file.id, + value: file.src + }; + + presenter.addIcon(icon); + presenter.setIcon(icon); + presenter.closeMenu(); + }; + + return ( + } + /> + ); +}); + +export const CustomIconPlugin = () => { + const client = useApolloClient(); + + return ( + + { + const { data: response } = await client.query({ + query: LIST_CUSTOM_ICONS, + variables: { + limit: 10000 + } + }); + + if (!response) { + throw new Error("Network error while listing custom icons."); + } + + const { data, error } = response.fileManager.listFiles; + + if (!data) { + throw new Error(error?.message || "Could not fetch custom icons."); + } + + return data.map(customIcon => ({ + type: "custom", + name: customIcon.name, + value: customIcon.src + })); + }} + /> + + } /> + } /> + + + ); +}; diff --git a/packages/app-admin/src/components/IconPicker/plugins/emojisPlugin.tsx b/packages/app-admin/src/components/IconPicker/plugins/emojisPlugin.tsx new file mode 100644 index 00000000000..a557b30b9c4 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/plugins/emojisPlugin.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import styled from "@emotion/styled"; + +import { Menu } from "@webiny/ui/Menu"; + +import { useIcon } from ".."; +import { IconPickerTab } from "../IconPickerTab"; +import { IconProvider } from "../IconRenderer"; +import { useIconPicker } from "../IconPickerPresenterProvider"; +import { IconPickerConfig } from "../config"; +import { Icon } from "../types"; + +const SKIN_TONES = ["", "\u{1f3fb}", "\u{1f3fc}", "\u{1f3fd}", "\u{1f3fe}", "\u{1f3ff}"]; + +const EmojiStyled = styled.div` + color: black; + width: 32px; + height: 32px; + font-size: 26px; + line-height: 32px; +`; + +const SkinToneSelectWrapper = styled.div` + padding: 4px; + width: 32px; + flex-shrink: 0; + background: #fff; + border-radius: 1px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); + display: inline-block; + cursor: pointer; +`; + +const SkinTonesGrid = styled.div` + display: grid; + gap: 4px; + padding: 4px; +`; + +const SkinTone = styled.div` + cursor: pointer; +`; + +interface Emoji extends Icon { + skinTone: string; + skinToneSupport: boolean; +} + +const Emoji = () => { + const { icon } = useIcon(); + + return {icon.skinTone ? icon.value + icon.skinTone : icon.value}; +}; + +interface SkinToneSelectProps { + icon: Icon | null; + hasSkinToneSupport: boolean; + onChange: (skinTone: string) => void; +} + +const SkinToneSelect = ({ icon, hasSkinToneSupport, onChange }: SkinToneSelectProps) => { + if (!icon || !isEmoji(icon)) { + return ; + } + + if (!hasSkinToneSupport) { + return ( + + + + + + ); + } + + return ( + + + + + + } + > + {({ closeMenu }) => ( + + {SKIN_TONES.map((skinTone, index) => ( + { + onChange(skinTone); + closeMenu(); + }} + > + + + + + ))} + + )} + + ); +}; + +/** + * @see https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates + */ +const isEmoji = (icon: Icon | null): icon is Emoji => { + if (!icon) { + return false; + } + return icon.type === "emoji"; +}; + +const EmojiTab = observer(() => { + const presenter = useIconPicker(); + const { selectedIcon } = presenter.vm; + + const onSkinToneChange = (skinTone: string) => { + if (isEmoji(selectedIcon)) { + presenter.setIcon({ ...selectedIcon, skinTone }); + presenter.closeMenu(); + } else { + presenter.closeMenu(); + } + }; + + const onIconSelect = (icon: Icon) => { + presenter.setIcon(icon); + presenter.closeMenu(); + }; + + const hasSkinToneSupport = isEmoji(selectedIcon) ? selectedIcon.skinToneSupport : false; + + return ( + + } + /> + ); +}); + +export const EmojiPlugin = () => { + return ( + + + } /> + } /> + + + ); +}; diff --git a/packages/app-admin/src/components/IconPicker/plugins/graphql.ts b/packages/app-admin/src/components/IconPicker/plugins/graphql.ts new file mode 100644 index 00000000000..b4be94766f0 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/plugins/graphql.ts @@ -0,0 +1,33 @@ +import gql from "graphql-tag"; + +/** + * ########################### + * List Custom Icons Query Response + */ +export interface ListCustomIconsQueryResponse { + fileManager: { + listFiles: { + data: [{ name: string; src: string }] | null; + error: { message: string; data: Record; code: string } | null; + }; + }; +} + +export const LIST_CUSTOM_ICONS = gql` + query ListCustomIcons($limit: Int!) { + fileManager { + listFiles(where: { tags_startsWith: "scope:iconPicker" }, limit: $limit) { + data { + name + src + tags + } + error { + code + data + message + } + } + } + } +`; diff --git a/packages/app-admin/src/components/IconPicker/plugins/iconsPlugin.tsx b/packages/app-admin/src/components/IconPicker/plugins/iconsPlugin.tsx new file mode 100644 index 00000000000..49ad0c27428 --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/plugins/iconsPlugin.tsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import styled from "@emotion/styled"; + +import { ColorPicker } from "@webiny/ui/ColorPicker"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; + +import { useIcon } from ".."; +import { IconPickerTab } from "../IconPickerTab"; +import { useIconPicker } from "../IconPickerPresenterProvider"; +import { IconPickerConfig } from "../config"; +import { Icon } from "../types"; + +interface SimpleIcon extends Icon { + color: string; +} + +const IconSvg = () => { + const { icon } = useIcon(); + + return ( + + ); +}; + +interface IconColorPickerProps { + color: string; + onChange: (value: string) => void; +} + +const IconColorPicker = ({ color, onChange }: IconColorPickerProps) => { + return ( + + {({ value, onChange }) => } + + ); +}; + +const Color = styled.span<{ color: string }>` + color: ${({ color }) => color}; +`; + +/** + * @see https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates + */ +const isSimpleIcon = (icon: Icon | null): icon is SimpleIcon => { + if (!icon) { + return false; + } + return icon.type === "icon"; +}; + +const IconTab = observer(() => { + const presenter = useIconPicker(); + const { selectedIcon } = presenter.vm; + + const [color, setColor] = useState("inherit"); + + useEffect(() => { + if (color === "inherit" && isSimpleIcon(selectedIcon)) { + setColor(selectedIcon.color); + } + }, [selectedIcon]); + + const onColorChange = (color: string) => { + setColor(color); + if (isSimpleIcon(selectedIcon)) { + presenter.setIcon({ ...selectedIcon, color }); + } else { + presenter.closeMenu(); + } + }; + + const onIconSelect = (icon: Icon) => { + // Set icon and assign current color. + presenter.setIcon({ ...icon, color }); + presenter.closeMenu(); + }; + + return ( + {cell}} + actions={} + /> + ); +}); + +export const SimpleIconPlugin = () => { + return ( + + + } /> + } /> + + + ); +}; diff --git a/packages/app-admin/src/components/IconPicker/types.ts b/packages/app-admin/src/components/IconPicker/types.ts new file mode 100644 index 00000000000..f0dc34f774b --- /dev/null +++ b/packages/app-admin/src/components/IconPicker/types.ts @@ -0,0 +1,46 @@ +import { ReactNode } from "react"; + +import { Plugin } from "@webiny/plugins/types"; + +/** + * We want to have an abstract type, which does not define specifics of each possible icon (like color or skin tone). + */ +export type Icon = { + type: "icon" | "emoji" | "custom" | string; + name: string; + value: string; + [key: string]: any; +}; + +export type IconPickerTabProps = { + label: string; + rows: IconPickerGridRow[]; + value: Icon | null; + onChange: (icon: Icon, close?: boolean) => void; + filter: string; + onFilterChange: (value: string) => void; + color: string; + onColorChange: (value: string) => void; + checkSkinToneSupport: (icon: Icon) => boolean; + children?: ReactNode; +}; + +export type IconPickerPlugin = Plugin & { + type: "admin-icon-picker"; + name: string; + iconType: string; + renderIcon: (icon: Icon, size: number) => JSX.Element; + renderTab: (props: IconPickerTabProps) => ReactNode; +}; + +type IconsRow = { + type: "icons"; + icons: Icon[]; +}; + +type CategoryNameRow = { + type: "category-name"; + name: string; +}; + +export type IconPickerGridRow = IconsRow | CategoryNameRow; diff --git a/packages/app-admin/src/index.ts b/packages/app-admin/src/index.ts index 314046086c8..5a110423d9b 100644 --- a/packages/app-admin/src/index.ts +++ b/packages/app-admin/src/index.ts @@ -41,6 +41,7 @@ export { SingleImageUploadProps } from "./components/SingleImageUpload"; export { LexicalEditor } from "./components/LexicalEditor/LexicalEditor"; +export * from "./components/IconPicker"; export { FileManager, FileManagerRenderer } from "./base/ui/FileManager"; export type { diff --git a/packages/app-admin/src/types.ts b/packages/app-admin/src/types.ts index e01bb790705..441484feb03 100644 --- a/packages/app-admin/src/types.ts +++ b/packages/app-admin/src/types.ts @@ -3,6 +3,8 @@ import { Plugin } from "@webiny/plugins/types"; import { ApolloClient } from "apollo-client"; import { ItemProps, MenuProps, SectionProps } from "~/plugins/MenuPlugin"; +export { Icon } from "~/components/IconPicker/types"; + export type AdminGlobalSearchPlugin = Plugin & { type: "admin-global-search"; label: string; diff --git a/packages/app-admin/tsconfig.build.json b/packages/app-admin/tsconfig.build.json index 18bb364752f..1e7e10bdfce 100644 --- a/packages/app-admin/tsconfig.build.json +++ b/packages/app-admin/tsconfig.build.json @@ -10,6 +10,7 @@ { "path": "../lexical-editor/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, { "path": "../react-composition/tsconfig.build.json" }, + { "path": "../react-properties/tsconfig.build.json" }, { "path": "../react-router/tsconfig.build.json" }, { "path": "../ui/tsconfig.build.json" }, { "path": "../ui-composer/tsconfig.build.json" }, diff --git a/packages/app-admin/tsconfig.json b/packages/app-admin/tsconfig.json index c8333b6d7c0..43961e79fa1 100644 --- a/packages/app-admin/tsconfig.json +++ b/packages/app-admin/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../lexical-editor" }, { "path": "../plugins" }, { "path": "../react-composition" }, + { "path": "../react-properties" }, { "path": "../react-router" }, { "path": "../ui" }, { "path": "../ui-composer" }, @@ -39,6 +40,8 @@ "@webiny/plugins": ["../plugins/src"], "@webiny/react-composition/*": ["../react-composition/src/*"], "@webiny/react-composition": ["../react-composition/src"], + "@webiny/react-properties/*": ["../react-properties/src/*"], + "@webiny/react-properties": ["../react-properties/src"], "@webiny/react-router/*": ["../react-router/src/*"], "@webiny/react-router": ["../react-router/src"], "@webiny/ui/*": ["../ui/src/*"], diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx index 1b79e2f8eef..bd219e53efe 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx @@ -21,12 +21,13 @@ import { CompositionScope } from "@webiny/react-composition"; /** * Convert a FileItem object to a FileManagerFileItem, which is then passed to `onChange` callback. */ -const formatFileItem = ({ id, src, ...rest }: FileItem): FileManagerFileItem => { +const formatFileItem = ({ id, src, name, ...rest }: FileItem): FileManagerFileItem => { const props: { [key: string]: any } = rest; return { id, src, + name, meta: Object.keys(rest).map(key => ({ key, value: props[key] })) }; }; @@ -51,6 +52,7 @@ export const FileManagerRenderer = createComponentPlugin(BaseFileManagerRenderer const { onChange, images, accept, ...forwardProps } = props; const handleFileOnChange = (value?: FileItem[] | FileItem) => { + console.log(value); if (!onChange || !value || (Array.isArray(value) && !value.length)) { return; } diff --git a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx index 756d357ed27..c7e6d3ace56 100644 --- a/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx +++ b/packages/app-page-builder/src/admin/views/BlockCategories/BlockCategoriesForm.tsx @@ -12,7 +12,7 @@ import { SimpleFormContent, SimpleFormHeader } from "@webiny/app-admin/components/SimpleForm"; -import IconPicker from "./IconPicker"; +import { IconPicker } from "@webiny/app-admin/components/IconPicker"; import { validation } from "@webiny/validation"; import { blockCategorySlugValidator, blockCategoryDescriptionValidator } from "./validators"; import { @@ -208,15 +208,8 @@ const CategoriesForm = ({ canCreate }: CategoriesFormProps) => { - - + + diff --git a/packages/ui/src/ColorPicker/ColorPicker.tsx b/packages/ui/src/ColorPicker/ColorPicker.tsx index b1c88e779ec..894b4400674 100644 --- a/packages/ui/src/ColorPicker/ColorPicker.tsx +++ b/packages/ui/src/ColorPicker/ColorPicker.tsx @@ -59,10 +59,29 @@ interface ColorPickerProps extends FormComponentProps { * Use ColorPicker component to display a list of choices, once the handler is triggered. */ class ColorPicker extends React.Component { + colorPickerRef = React.createRef(); + public override state = { showColorPicker: false }; + public override componentDidMount() { + document.addEventListener("click", this.handleClickOutside); + } + + public override componentWillUnmount() { + document.removeEventListener("click", this.handleClickOutside); + } + + handleClickOutside = (event: MouseEvent) => { + if ( + this.colorPickerRef.current && + !this.colorPickerRef.current.contains(event.target as Node) + ) { + this.setState({ showColorPicker: false }); + } + }; + handleClick = () => { this.setState({ showColorPicker: !this.state.showColorPicker }); }; @@ -89,7 +108,7 @@ class ColorPicker extends React.Component { const { isValid: validationIsValid, message: validationMessage } = validation || {}; return ( -
+
{label && (
void; +}; + type MenuProps = RmwcMenuProps & { // One or more MenuItem components. - children: React.ReactNode; + children: React.ReactNode | ((props: MenuChildrenFunctionProps) => React.ReactNode); // A handler which triggers the menu, eg. button or link. handle?: React.ReactElement; @@ -49,79 +53,78 @@ type MenuProps = RmwcMenuProps & { "data-testid"?: string; }; -interface MenuState { - menuIsOpen: boolean; -} - /** * Use Menu component to display a list of choices, once the handler is triggered. */ -class Menu extends React.Component { - static defaultProps: Partial = { - anchor: "topStart" - }; - - public override state: MenuState = { - menuIsOpen: false - }; - - private readonly openMenu = () => { - if (this.props.disabled !== true) { - this.setState({ menuIsOpen: true }, () => this.props.onOpen && this.props.onOpen()); +const Menu = (props: MenuProps) => { + const { + children, + handle, + anchor = "topStart", + className, + disabled, + onOpen, + onClose, + onSelect, + open, + renderToPortal + } = props; + + const [menuIsOpen, setMenuIsOpen] = useState(false); + + useEffect(() => { + if (typeof open === "boolean") { + setMenuIsOpen(open); } - }; - - private readonly closeMenu = () => { - this.setState({ menuIsOpen: false }, () => this.props.onClose && this.props.onClose()); - }; - - private readonly renderMenuWithPortal = () => { - return ( - - {this.props.children} - - ); - }; - - private readonly renderCustomContent = () => { - const { children } = this.props; - return ( - - {typeof children === "function" - ? children({ closeMenu: this.closeMenu }) - : children} - - ); - }; - - private readonly renderMenuContent = () => { - return Array.isArray(this.props.children) - ? this.renderMenuWithPortal() - : this.renderCustomContent(); - }; - - public override render(): React.ReactNode { - return ( - - {this.renderMenuContent()} - {this.props.handle && - React.cloneElement(this.props.handle, { onClick: this.openMenu })} - - ); - } -} + }, [open]); + + const openMenu = useCallback(() => { + if (disabled) { + return; + } + + setMenuIsOpen(true); + + if (onOpen) { + onOpen(); + } + }, [disabled, onOpen]); + + const closeMenu = useCallback(() => { + setMenuIsOpen(false); + + if (onClose) { + onClose(); + } + }, [onClose]); + + const renderMenuWithPortal = () => ( + + {children} + + ); + + const renderCustomContent = () => ( + + {typeof children === "function" ? children({ closeMenu }) : children} + + ); + + return ( + + {Array.isArray(children) ? renderMenuWithPortal() : renderCustomContent()} + {handle && React.cloneElement(handle, { onClick: openMenu })} + + ); +}; const MenuDivider = () => { return
  • ; diff --git a/tsconfig.build.json b/tsconfig.build.json index 28a625c95a6..d5241206204 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,6 +7,7 @@ "moduleResolution": "node", "module": "esnext", "lib": ["esnext", "dom", "dom.iterable"], + "resolveJsonModule": true, "esModuleInterop": true, "declaration": true, "composite": true, diff --git a/yarn.lock b/yarn.lock index df9b28b706c..7d1616fdb83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6469,6 +6469,23 @@ __metadata: languageName: node linkType: hard +"@iconify/json@npm:^2.2.142": + version: 2.2.142 + resolution: "@iconify/json@npm:2.2.142" + dependencies: + "@iconify/types": "*" + pathe: ^1.1.0 + checksum: b6bb18dd28d0038e1f88b1f4e1b5b3e1e4013fc9f9ed8a9711439bfbe4abfda929a28e24fa68bef43b5666bdc2947ffd1fb5b88ef7b86d6fd49cb0c0ee2a20d8 + languageName: node + linkType: hard + +"@iconify/types@npm:*": + version: 2.0.0 + resolution: "@iconify/types@npm:2.0.0" + checksum: 029f58542c160e9d4a746869cf2e475b603424d3adf3994c5cc8d0406c47e6e04a3b898b2707840c1c5b9bd5563a1660a34b110d89fce43923baca5222f4e597 + languageName: node + linkType: hard + "@icons/material@npm:^0.2.4": version: 0.2.4 resolution: "@icons/material@npm:0.2.4" @@ -15503,6 +15520,7 @@ __metadata: "@editorjs/editorjs": ^2.19.0 "@emotion/react": ^11.10.6 "@emotion/styled": ^11.10.6 + "@iconify/json": ^2.2.142 "@material-design-icons/svg": ^0.14.3 "@svgr/webpack": ^6.1.1 "@types/bytes": ^3.1.1 @@ -15521,6 +15539,7 @@ __metadata: "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/react-composition": 0.0.0 + "@webiny/react-properties": 0.0.0 "@webiny/react-router": 0.0.0 "@webiny/telemetry": 0.0.0 "@webiny/ui": 0.0.0 @@ -15538,18 +15557,22 @@ __metadata: emotion: ^10.0.17 graphlib: ^2.1.7 graphql: ^15.7.2 + graphql-tag: ^2.12.6 is-hotkey: ^0.1.3 lodash: ^4.17.11 mobx: ^6.9.0 + mobx-react-lite: ^3.4.3 prop-types: ^15.7.2 react: 17.0.2 react-dom: 17.0.2 react-hotkeyz: ^1.0.4 react-transition-group: ^4.3.0 + react-virtualized: ^9.21.2 rimraf: ^3.0.2 store: ^2.0.12 ttypescript: ^1.5.12 typescript: 4.7.4 + unicode-emoji-json: ^0.4.0 languageName: unknown linkType: soft @@ -36571,6 +36594,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^1.1.0": + version: 1.1.1 + resolution: "pathe@npm:1.1.1" + checksum: 34ab3da2e5aa832ebc6a330ffe3f73d7ba8aec6e899b53b8ec4f4018de08e40742802deb12cf5add9c73b7bf719b62c0778246bd376ca62b0fb23e0dde44b759 + languageName: node + linkType: hard + "pbkdf2@npm:^3.0.3": version: 3.1.2 resolution: "pbkdf2@npm:3.1.2" @@ -44372,6 +44402,13 @@ __metadata: languageName: node linkType: hard +"unicode-emoji-json@npm:^0.4.0": + version: 0.4.0 + resolution: "unicode-emoji-json@npm:0.4.0" + checksum: 3c70df3d004d06f7f6efbda0b4935646027e4599389ccc508ccf20e6dd1e69897822a52769e33cf5b6f7c55b467587f2a8f10394b70cdc3f83deec92065c2ebe + languageName: node + linkType: hard + "unicode-match-property-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-match-property-ecmascript@npm:2.0.0"