From 9934ad1cfd5ef0ca2152e171321539abc40e94ec Mon Sep 17 00:00:00 2001 From: Felipe Mendes Date: Mon, 17 Apr 2023 11:40:31 -0300 Subject: [PATCH] feat: UI refactor on dropdown for better customization (#216) * wip: combobox refactor * refactor: new combobox styling * refactor: rename new combobox * wip: refactor combobox factory * refactor: new combobox single component * wip: refactor form combobox * refactor: github branches typing * refactor: ens field form combobox * fix: readd removed branch icon * refactor: replace nfa picker combobox * refactor: remove old combobox * refactor: rename combobox factory * fix: remove leftover combobox code * refactor: query filter by keys * fix: create ap form title * fix: max combobox options height * feat: add chevron on combobox field * refactor: optmize elements generation structure * chore: add todo comments for things thats going to change * fix: code comment addressed on pr --- .../components/core/avatar/avatar.styles.ts | 83 ++-- ui/src/components/core/avatar/avatar.tsx | 14 +- .../core/combobox/combobox.styles.ts | 121 +++++ ui/src/components/core/combobox/combobox.tsx | 466 ++++++++---------- .../core/combobox/combobox.utils.ts | 2 - ui/src/components/core/combobox/index.ts | 2 +- ui/src/components/core/icon/icon.tsx | 27 +- ui/src/components/core/input/input.tsx | 38 +- ui/src/components/form/form.tsx | 37 +- ui/src/mocks/list.ts | 5 +- .../github/async-thunk/fetch-branches.ts | 3 +- .../async-thunk/fetch-user-organizations.ts | 14 +- ui/src/store/features/github/github-client.ts | 34 +- ui/src/store/features/github/github-slice.ts | 7 +- ui/src/store/features/github/index.ts | 1 + ui/src/views/access-point/create-ap-form.tsx | 5 +- .../views/access-point/create-ap.context.tsx | 10 +- ui/src/views/access-point/nfa-picker.tsx | 33 +- .../views/components-test/combobox-test.tsx | 89 ++-- .../explore-list/nfa-list/nfa-list.tsx | 11 +- .../repo-branch-commit-fields.tsx | 84 ++-- .../github-repository-selection.tsx | 16 +- .../repositories-list.tsx | 11 +- .../users-orgs-combobox.tsx | 63 ++- ui/src/views/mint/mint.context.tsx | 16 +- .../ens-domain-field/ens-domain-field.tsx | 2 +- .../fields/ens-domain-field/ens-field.tsx | 37 +- 27 files changed, 689 insertions(+), 542 deletions(-) create mode 100644 ui/src/components/core/combobox/combobox.styles.ts delete mode 100644 ui/src/components/core/combobox/combobox.utils.ts diff --git a/ui/src/components/core/avatar/avatar.styles.ts b/ui/src/components/core/avatar/avatar.styles.ts index 6a78ba5..c57fdf3 100644 --- a/ui/src/components/core/avatar/avatar.styles.ts +++ b/ui/src/components/core/avatar/avatar.styles.ts @@ -10,23 +10,20 @@ export abstract class AvatarStyles { verticalAlign: 'middle', overflow: 'hidden', userSelect: 'none', - width: '$5', - height: '$5', borderRadius: '100%', backgroundColor: '$slate2', - mr: '$2', }); static readonly Image = styled(Avatar.Image, { - width: '100%', - height: '100%', + width: '1em', + height: '1em', objectFit: 'cover', borderRadius: 'inherit', }); static readonly Fallback = styled(Avatar.Fallback, { - width: '100%', - height: '100%', + width: '1em', + height: '1em', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -37,36 +34,44 @@ export abstract class AvatarStyles { }); } -export type AvatarProps = React.ComponentProps & { - /** - * Fallback node. - * In case of string, transformed to upper case and sliced to second letter. - */ - fallback?: React.ReactNode; - /** - * Source of the image. - * If not provided, fallback will be used. - */ - src?: AvatarImageProps['src']; - /** - * Alt text of the image. - */ - alt?: AvatarImageProps['alt']; +export namespace AvatarStyles { + export type RootProps = React.ComponentPropsWithRef< + typeof AvatarStyles.Root + > & { + /** + * Fallback node. + * In case of string, transformed to upper case and sliced to second letter. + */ + fallback?: React.ReactNode; + /** + * Source of the image. + * If not provided, fallback will be used. + */ + src?: ImageProps['src']; + /** + * Alt text of the image. + */ + alt?: ImageProps['alt']; - /** - * Props of the image tag. - * @see {@link AvatarImageProps} - * @default {} - */ - imageProps?: AvatarImageProps; - /** - * Props of the fallback tag. - * @see {@link AvatarFallbackProps} - * @default {} - */ - fallbackProps?: AvatarFallbackProps; -}; -export type AvatarImageProps = React.ComponentProps; -export type AvatarFallbackProps = React.ComponentProps< - typeof AvatarStyles.Fallback ->; + /** + * Props of the image tag. + * @see {@link AvatarImageProps} + * @default {} + */ + imageProps?: ImageProps; + /** + * Props of the fallback tag. + * @see {@link AvatarFallbackProps} + * @default {} + */ + fallbackProps?: FallbackProps; + }; + + export type ImageProps = React.ComponentPropsWithRef< + typeof AvatarStyles.Image + >; + + export type FallbackProps = React.ComponentPropsWithRef< + typeof AvatarStyles.Fallback + >; +} diff --git a/ui/src/components/core/avatar/avatar.tsx b/ui/src/components/core/avatar/avatar.tsx index 6948425..9cfcb19 100644 --- a/ui/src/components/core/avatar/avatar.tsx +++ b/ui/src/components/core/avatar/avatar.tsx @@ -1,13 +1,13 @@ -import { forwardRef } from 'react'; +import { forwardStyledRef } from '@/theme'; -import { AvatarProps, AvatarStyles } from './avatar.styles'; +import { AvatarStyles as AS } from './avatar.styles'; -export const Avatar = forwardRef( - ({ imageProps = {}, src, alt, css, ...rootProps }, ref) => { +export const Avatar = forwardStyledRef( + ({ imageProps = {}, src, alt, ...rootProps }, ref) => { return ( - - - + + + ); } ); diff --git a/ui/src/components/core/combobox/combobox.styles.ts b/ui/src/components/core/combobox/combobox.styles.ts new file mode 100644 index 0000000..3e83dbf --- /dev/null +++ b/ui/src/components/core/combobox/combobox.styles.ts @@ -0,0 +1,121 @@ +import { Combobox as HeadlessCombobox } from '@headlessui/react'; + +import { styled } from '@/theme'; + +import { Icon } from '../icon'; +import { IconStyles } from '../icon/icon.styles'; +import { InputStyled } from '../input'; + +export const ComboboxStyles = { + Wrapper: styled('div', { + position: 'relative', + + variants: { + unattached: { + true: { + position: 'static', + }, + }, + }, + }), + + Option: styled(HeadlessCombobox.Option, { + width: '100%', + position: 'relative', + display: 'flex', + alignItems: 'center', + gap: '$3', + cursor: 'pointer', + padding: '$2 $3', + borderRadius: '$lg', + color: '$slate11', + transition: '$all-200', + + '&[data-headlessui-state*="selected"]': { + backgroundColor: '$slate3', + }, + + '&[data-headlessui-state*="active"]': { + backgroundColor: '$slate2', + color: '$slate12', + }, + }), + + Options: styled(HeadlessCombobox.Options, { + display: 'flex', + flexDirection: 'column', + position: 'absolute', + border: '1px solid $slate6', + backgroundColor: '$black', + boxSizing: 'border-box', + left: 0, + right: 0, + top: 'calc(100% + $3)', + padding: '$3', + gap: '$2', + borderRadius: '$lg', + zIndex: 10, + maxHeight: '30vh', + overflow: 'auto', + }), + + Field: styled(HeadlessCombobox.Button, InputStyled, { + position: 'relative', + display: 'flex', + alignItems: 'center', + width: '100%', + gap: '$3', + + '&:focus-within': { + outline: 'none', + borderColor: '$blue9', + }, + + '&:hover': { + cursor: 'pointer', + }, + }), + + Input: styled(HeadlessCombobox.Input, { + width: '100%', + color: '$slate11', + backgroundColor: 'transparent', + outline: 'none', + }), + + RightPositionedIcon: styled(Icon, { + position: 'absolute', + right: '$3', + }), + + Message: styled('span', { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '$2', + color: '$slate8', + fontStyle: 'italic', + }), + + InnerSearchContainer: styled('div', { + position: 'sticky', + top: '-$3', + padding: '$3 $2 $3 $2', + margin: '-$3 0 0 0', + zIndex: 10, + display: 'flex', + alignItems: 'center', + gap: '$3', + borderBottom: '1px solid $slate6', + backgroundColor: '$black', + + [`${IconStyles.Container}`]: { + fontSize: '1.5em', + color: '$slate8', + }, + + 'input::placeholder': { + color: '$slate8', + }, + }), +}; diff --git a/ui/src/components/core/combobox/combobox.tsx b/ui/src/components/core/combobox/combobox.tsx index 35cc2e9..8f96bbb 100644 --- a/ui/src/components/core/combobox/combobox.tsx +++ b/ui/src/components/core/combobox/combobox.tsx @@ -1,279 +1,219 @@ import { - Combobox as ComboboxLib, - ComboboxInputProps as ComboboxLibInputProps, - Transition, + Combobox as HeadlessCombobox, + ComboboxInputProps, } from '@headlessui/react'; -import React, { forwardRef, Fragment, useEffect, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; -import { Icon, IconName } from '@/components/core/icon'; -import { Flex } from '@/components/layout'; -import { useDebounce } from '@/hooks/use-debounce'; +import { Spinner } from '@/components/spinner'; +import { createContext } from '@/utils'; -import { Separator } from '../separator.styles'; -import { cleanString } from './combobox.utils'; +import { Icon } from '../icon'; +import { ComboboxStyles as CS } from './combobox.styles'; -type ComboboxInputProps = ComboboxLibInputProps<'input', ComboboxItem>; +const EmptyMessage = No items found; +const LoadingMessage = ( + + Searching... + +); -const ComboboxInput: React.FC = ({ +const [Provider, useContext] = createContext>({ + name: 'ComboboxContext', + hookName: 'useComboboxContext', + providerName: 'ComboboxProvider', +}); + +const Input = (props: Combobox.InputProps): JSX.Element => { + const { + query: [, setQuery], + } = useContext() as Combobox.Context; + + const onChange = (event: React.ChangeEvent): void => { + setQuery(event.target.value); + if (props.onChange) props.onChange(event); + }; + + return ; +}; + +const Options = ({ + disableSearch, + children, ...props -}: ComboboxInputProps) => ( -
- - -
-); +}: Combobox.OptionsProps): JSX.Element => { + const { + query: [query], + loading, + selected: [selected], + items, + queryFilter, + } = useContext() as Combobox.Context; -type ComboboxOptionProps = { - option: ComboboxItem; -}; - -const ComboboxOption: React.FC = ({ - option, -}: ComboboxOptionProps) => ( - - `relative cursor-default select-none py-2 px-3.5 text-slate11 rounded-xl mb-2 text-sm ${ - active ? 'bg-slate5 text-slate12' : 'bg-transparent' - }` - } - > - {({ selected, active }) => ( - - - {option.icon} - - {option.label} - - - {selected && } - - )} - -); - -export const NoResults: React.FC = ({ css }: { css?: string }) => ( -
- Nothing found. -
-); - -export type ComboboxItem = { - /** - * The key of the item. - */ - value: string; - /** - * The label to display of the item. - */ - label: string; - /** - * Optional icon to display on the left of the item. - */ - icon?: React.ReactNode; -}; - -export type ComboboxProps = { - /** - * List of items to be displayed in the combobox. - */ - items: ComboboxItem[]; - /** - * The selected value of the combobox. - */ - selectedValue: ComboboxItem | undefined; - /** - * If true, the combobox will add the input if it doesn't exist in the list of items. - */ - withAutocomplete?: boolean; - /** - * Name of the left icon to display in the input. Defualt is "search". - */ - leftIcon?: IconName; - /** - * Callback when the selected value changes. - */ - onChange: (option: ComboboxItem) => void; - /** - * Function to handle the input blur - */ - onBlur?: () => void; - /** - * Value to indicate it's invalid - */ - error?: boolean; - css?: string; //tailwind css -}; - -export const Combobox: React.FC = ({ - items, - selectedValue = { value: '', label: '' }, - withAutocomplete = false, - leftIcon = 'search', - onChange, - onBlur, - error = false, - css, -}) => { - const [filteredItems, setFilteredItems] = useState([]); - const [autocompleteItems, setAutocompleteItems] = useState( - [] + const [ + optionRenderer, + EmptyRender = EmptyMessage, + LoadingRender = LoadingMessage, + ] = useMemo( + () => (Array.isArray(children) ? children : [children]), + [children] ); - useEffect(() => { - // If the selected value doesn't exist in the list of items, we add it - if ( - items.filter((item) => item === selectedValue).length === 0 && - selectedValue.value !== undefined && - autocompleteItems.length === 0 && - withAutocomplete - ) { - setAutocompleteItems([selectedValue]); - } - }, [autocompleteItems.length, items, selectedValue, withAutocomplete]); - - useEffect(() => { - setFilteredItems(items); - }, [items]); - - const handleSearch = useDebounce((searchValue: string) => { - if (searchValue === '') { - setFilteredItems(items); - - if (withAutocomplete) { - setAutocompleteItems([]); - handleComboboxChange({} as ComboboxItem); - } - } else { - const filteredValues = items.filter((item) => - cleanString(item.label).startsWith(cleanString(searchValue)) - ); - - if (withAutocomplete && filteredValues.length === 0) { - // If the search value doesn't exist in the list of items, we add it - setAutocompleteItems([{ value: searchValue, label: searchValue }]); - } - setFilteredItems(filteredValues); - } - }, 200); - - const handleInputChange = ( - event: React.ChangeEvent - ): void => { - event.stopPropagation(); - handleSearch(event.target.value); - }; - - const handleComboboxChange = (optionSelected: ComboboxItem): void => { - onChange(optionSelected); - }; - - const handleLeaveTransition = (): void => { - setFilteredItems(items); - if (selectedValue.value === undefined && withAutocomplete) { - setAutocompleteItems([]); - handleComboboxChange({} as ComboboxItem); - } - }; + const filteredItems = useMemo( + () => items.filter((item) => queryFilter(query, item)), + [items, query, queryFilter] + ); return ( - - {({ open }) => ( -
-
- - - {selectedValue && selectedValue.label - ? selectedValue.label - : 'Search'} - - - - -
- - -
- - - - {[...autocompleteItems, ...filteredItems].length === 0 || - filteredItems === undefined ? ( - - ) : ( - <> - {autocompleteItems.length > 0 && Create new} - {autocompleteItems.map( - (autocompleteOption: ComboboxItem) => ( - - ) - )} - {autocompleteItems.length > 0 && - filteredItems.length > 0 && ( - - )} - {filteredItems.map((option: ComboboxItem) => ( - - ))} - - )} - -
-
-
+ + {!disableSearch && ( + + + + )} -
+ + {filteredItems.map((item) => ( + + {optionRenderer(item, selected === item)} + {selected === item && } + + ))} + + {!loading && filteredItems.length === 0 && EmptyRender} + + {loading && LoadingRender} + ); }; -Combobox.displayName = 'Combobox'; +const Field = ({ + children, + disableChevron, + ...props +}: Combobox.FieldProps): JSX.Element => { + const { + selected: [selected], + } = useContext() as Combobox.Context; + + return ( + + {children(selected)} + {!disableChevron && } + + ); +}; + +export const Combobox = ({ + children, + selected, + isLoading: loading = false, + items, + queryKey, + ...props +}: Combobox.RootProps): JSX.Element => { + const [value, setValue] = selected; + const query = useState(''); + + const queryFilter = useCallback( + (query: string, item: T): boolean => { + if (typeof queryKey === 'undefined') + return `${item}`.includes(query.toLowerCase()); + + const keys = Array.isArray(queryKey) ? queryKey : [queryKey]; + + const searchString = keys + .reduce((acc, key) => { + const value = item[key]; + return `${acc} ${value}`; + }, '') + .toLowerCase(); + + return searchString.includes(query.toLowerCase()); + }, + [queryKey] + ); + + const TypedProvider = Provider as React.Provider>; + + return ( + + {({ open }) => ( + + {children({ + Options: Options, + Input: Input, + Field: Field, + Message: CS.Message, + })} + + )} + + ); +}; + +export namespace Combobox { + export type Context = { + items: T[]; + selected: [T | undefined, (newState: T | undefined) => void]; + query: ReactState; + loading: boolean; + open: boolean; + queryFilter: (query: string, item: T) => boolean; + }; + + export type OptionsProps = Omit< + React.ComponentPropsWithRef, + 'children' + > & { + disableSearch?: boolean; + children: + | ((item: T, selected: boolean) => React.ReactNode) + | [ + (item: T, selected: boolean) => React.ReactNode, + React.ReactNode?, + React.ReactNode? + ]; + }; + + export type InputProps = ComboboxInputProps<'input', T | undefined>; + + export type FieldProps = Omit< + React.ComponentPropsWithRef, + 'children' + > & { + children: (item: T | undefined) => React.ReactElement | React.ReactNode; + disableChevron?: boolean; + }; + + export type Elements = { + Options: React.FC>; + Input: React.FC>; + Field: React.FC>; + Message: typeof CS.Message; + }; + + export type RootProps = Omit< + React.ComponentPropsWithRef, + 'defaultValue' | 'onChange' | 'children' + > & + Pick, 'selected' | 'items'> & { + isLoading?: boolean; + children: (elements: Elements) => React.ReactNode; + } & (T extends object + ? { queryKey: keyof T | (keyof T)[] } + : { queryKey?: undefined }); +} diff --git a/ui/src/components/core/combobox/combobox.utils.ts b/ui/src/components/core/combobox/combobox.utils.ts deleted file mode 100644 index 5c86324..0000000 --- a/ui/src/components/core/combobox/combobox.utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const cleanString = (str: string): string => - str.toLowerCase().replace(/\s+/g, ''); diff --git a/ui/src/components/core/combobox/index.ts b/ui/src/components/core/combobox/index.ts index 76459f6..3f0b164 100644 --- a/ui/src/components/core/combobox/index.ts +++ b/ui/src/components/core/combobox/index.ts @@ -1,2 +1,2 @@ -export * from './combobox'; export * from './dropdown'; +export * from './combobox'; diff --git a/ui/src/components/core/icon/icon.tsx b/ui/src/components/core/icon/icon.tsx index 3ee1ef4..ca44704 100644 --- a/ui/src/components/core/icon/icon.tsx +++ b/ui/src/components/core/icon/icon.tsx @@ -1,4 +1,4 @@ -import { forwardRef } from 'react'; +import { forwardStyledRef } from '@/theme'; import { IconStyles } from './icon.styles'; import { IconLibrary, IconName, IconType } from './icon-library'; @@ -8,19 +8,18 @@ export type IconProps = { iconElementCss?: React.CSSProperties; } & React.ComponentProps; -export const Icon: React.FC = forwardRef( - (props, ref) => { - const { name, iconElementCss, ...rest } = props; - const IconElement: IconType = IconLibrary[name]; +export const Icon: React.FC = forwardStyledRef< + HTMLSpanElement, + IconProps +>((props, ref) => { + const { name, iconElementCss, ...rest } = props; + const IconElement: IconType = IconLibrary[name]; - return ( - - - - ); - } -); + return ( + + + + ); +}); Icon.displayName = 'Icon'; diff --git a/ui/src/components/core/input/input.tsx b/ui/src/components/core/input/input.tsx index 19ea420..2d48507 100644 --- a/ui/src/components/core/input/input.tsx +++ b/ui/src/components/core/input/input.tsx @@ -1,4 +1,6 @@ -import React, { forwardRef } from 'react'; +import React from 'react'; + +import { forwardStyledRef } from '@/theme'; import { IconName } from '../icon'; import { InputIconStyled, InputStyled, TextareaStyled } from './input.styles'; @@ -10,24 +12,26 @@ export const LogoFileInput = StyledInputFile; type InputProps = { leftIcon?: IconName; - css?: string; //tailwind css + wrapperClassName?: string; //tailwind css } & React.ComponentPropsWithRef; -export const Input = forwardRef((props, ref) => { - const { leftIcon, css, ...ownProps } = props; +export const Input = forwardStyledRef( + (props, ref) => { + const { leftIcon, wrapperClassName: css = '', ...ownProps } = props; - return ( -
- {leftIcon && ( - - )} - -
- ); -}); + return ( +
+ {leftIcon && ( + + )} + +
+ ); + } +); Input.displayName = 'Input'; diff --git a/ui/src/components/form/form.tsx b/ui/src/components/form/form.tsx index 1089f65..e5b352b 100644 --- a/ui/src/components/form/form.tsx +++ b/ui/src/components/form/form.tsx @@ -4,7 +4,7 @@ import React, { forwardRef, useMemo, useState } from 'react'; import { hasValidator } from '@/utils'; import { fileToBase64 } from '@/views/mint/nfa-step/form-step/form.utils'; -import { ColorPicker, Combobox, ComboboxItem } from '../core'; +import { ColorPicker, Combobox } from '../core'; import { Input, LogoFileInput, Textarea } from '../core/input'; import { FormProvider, @@ -134,7 +134,10 @@ export abstract class Form { } ); - static readonly Combobox: React.FC = (props) => { + static readonly Combobox = ({ + handleValue, + ...props + }: Form.ComboboxProps): JSX.Element => { const { id, validators, @@ -142,21 +145,16 @@ export abstract class Form { validationEnabled: [validationEnabled, setValidationEnabled], } = useFormFieldContext(); - const comboboxValue = useMemo(() => { - // if it's with autocomplete maybe won't be on the items list - const item = props.items.find((item) => item.label === value); - if (props.withAutocomplete && !item && value !== '') { - //return the selected value if the item doesn't exist - return { label: value, value: value }; - } + const selected = useMemo(() => { + const item = props.items.find((item) => handleValue(item) === value); return item; - }, [props.items, props.withAutocomplete, value]); + }, [props.items, value, handleValue]); const isValid = useFormFieldValidatorValue(id, validators, value); - const handleComboboxChange = (option: ComboboxItem): void => { + const setSelected = (option: T): void => { if (props.onChange) props.onChange(option); - setValue(option.label); + setValue(handleValue(option)); }; const handleComboboxBlur = (): void => { @@ -166,8 +164,7 @@ export abstract class Form { return ( @@ -255,7 +252,7 @@ export abstract class Form { const handleFileInputChange = async ( e: React.ChangeEvent - ): void => { + ): Promise => { const file = e.target.files?.[0]; if (file) { //Convert to string base64 to send to contract @@ -294,12 +291,10 @@ export namespace Form { 'value' | 'error' >; - export type ComboboxProps = { - onChange?: (option: ComboboxItem) => void; - } & Omit< - React.ComponentProps, - 'error' | 'selectedValue' | 'onChange' - >; + export type ComboboxProps = Omit, 'selected'> & { + handleValue: (item: T) => string; + onChange?: (item: T) => void; + }; export type LogoFileInputProps = Omit< React.ComponentProps, diff --git a/ui/src/mocks/list.ts b/ui/src/mocks/list.ts index b83b2ef..c5a7a6b 100644 --- a/ui/src/mocks/list.ts +++ b/ui/src/mocks/list.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { ComboboxItem } from '@/components'; const listSites = [ { @@ -29,13 +28,13 @@ const listSites = [ }, ]; -const listBranches: ComboboxItem[] = [ +const listBranches = [ { value: '4573495837458934', label: 'main' }, { value: '293857439857348', label: 'develop' }, { value: '12344', label: 'feat/nabvar' }, ]; -export const fetchMintedSites = async (): Promise => { +export const fetchMintedSites = async (): Promise => { return new Promise((resolved) => { setTimeout(() => { resolved(listBranches); diff --git a/ui/src/store/features/github/async-thunk/fetch-branches.ts b/ui/src/store/features/github/async-thunk/fetch-branches.ts index 080ed86..8306c27 100644 --- a/ui/src/store/features/github/async-thunk/fetch-branches.ts +++ b/ui/src/store/features/github/async-thunk/fetch-branches.ts @@ -1,6 +1,5 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { ComboboxItem } from '@/components'; import { githubActions, RootState } from '@/store'; import { AppLog } from '@/utils'; @@ -25,7 +24,7 @@ export const fetchBranchesThunk = createAsyncThunk( const branches = await githubClient.fetchBranches(owner, repository); - dispatch(githubActions.setBranches(branches as ComboboxItem[])); + dispatch(githubActions.setBranches(branches)); } catch (error) { AppLog.errorToast( 'We have a problem trying to get your branches. Please try again later.' diff --git a/ui/src/store/features/github/async-thunk/fetch-user-organizations.ts b/ui/src/store/features/github/async-thunk/fetch-user-organizations.ts index e7382ab..717c1f4 100644 --- a/ui/src/store/features/github/async-thunk/fetch-user-organizations.ts +++ b/ui/src/store/features/github/async-thunk/fetch-user-organizations.ts @@ -3,7 +3,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { RootState } from '@/store'; import { AppLog } from '@/utils'; -import { GithubClient, UserData } from '../github-client'; +import { GithubClient } from '../github-client'; import { githubActions } from '../github-slice'; export const fetchUserAndOrgsThunk = createAsyncThunk( @@ -19,24 +19,22 @@ export const fetchUserAndOrgsThunk = createAsyncThunk( const githubClient = new GithubClient(token); - const response = await Promise.all([ + const [userResponse, orgsResponse] = await Promise.all([ githubClient.fetchUser(), githubClient.fetchOrgs(), ]); - const userResponse = response[0]; - const orgsResponse = response[1]; - let comboboxItems: UserData[] = []; + const items: GithubClient.UserData[] = []; if (userResponse) { - comboboxItems.push(userResponse); + items.push(userResponse); } if (orgsResponse) { - comboboxItems = [...comboboxItems, ...orgsResponse]; + items.push(...orgsResponse); } - dispatch(githubActions.setUserAndOrgs(comboboxItems)); + dispatch(githubActions.setUserAndOrgs(items)); } catch (error) { AppLog.errorToast('We have a problem. Please try again later.'); dispatch(githubActions.setQueryState('failed')); diff --git a/ui/src/store/features/github/github-client.ts b/ui/src/store/features/github/github-client.ts index 7ee84ea..2c0ab24 100644 --- a/ui/src/store/features/github/github-client.ts +++ b/ui/src/store/features/github/github-client.ts @@ -1,15 +1,7 @@ import { Octokit } from 'octokit'; -import { DropdownItem } from '@/components'; - import { GithubState } from './github-slice'; -export type UserData = { - value: string; - label: string; - avatar: string; -}; - export class GithubClient { octokit: Octokit; token: string; @@ -21,7 +13,7 @@ export class GithubClient { })); } - async fetchUser(): Promise { + async fetchUser(): Promise { const { data: userData } = await this.octokit.request('GET /user'); return { @@ -31,7 +23,7 @@ export class GithubClient { }; } - async fetchOrgs(): Promise { + async fetchOrgs(): Promise { const { data: organizationsData } = await this.octokit.request( 'GET /user/orgs' ); @@ -63,7 +55,10 @@ export class GithubClient { ); } - async fetchBranches(owner: string, repo: string): Promise { + async fetchBranches( + owner: string, + repo: string + ): Promise { const branches = await this.octokit .request('GET /repos/{owner}/{repo}/branches', { owner, @@ -72,8 +67,8 @@ export class GithubClient { .then((res) => res.data.map((branch) => { return { - label: branch.name, - value: branch.commit.sha, + name: branch.name, + commit: branch.commit.sha, }; }) ); @@ -81,3 +76,16 @@ export class GithubClient { return branches; } } + +export namespace GithubClient { + export type UserData = { + value: string; + label: string; + avatar: string; + }; + + export type Branch = { + name: string; + commit: string; + }; +} diff --git a/ui/src/store/features/github/github-slice.ts b/ui/src/store/features/github/github-slice.ts index c8f3591..29d4626 100644 --- a/ui/src/store/features/github/github-slice.ts +++ b/ui/src/store/features/github/github-slice.ts @@ -1,11 +1,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { ComboboxItem } from '@/components'; import { RootState } from '@/store'; import { useAppSelector } from '@/store/hooks'; import * as asyncThunk from './async-thunk'; -import { UserData } from './github-client'; +import { GithubClient } from './github-client'; export namespace GithubState { export type Token = string; @@ -20,7 +19,7 @@ export namespace GithubState { export type QueryLoading = 'idle' | 'loading' | 'failed' | 'success'; - export type UserAndOrganizations = Array; + export type UserAndOrganizations = Array; export type Repository = { name: string; @@ -30,7 +29,7 @@ export namespace GithubState { export type Repositories = Array; - export type Branches = Array; + export type Branches = Array; } export interface GithubState { diff --git a/ui/src/store/features/github/index.ts b/ui/src/store/features/github/index.ts index 3241e44..321a5c2 100644 --- a/ui/src/store/features/github/index.ts +++ b/ui/src/store/features/github/index.ts @@ -1 +1,2 @@ export * from './github-slice'; +export * from './github-client'; diff --git a/ui/src/views/access-point/create-ap-form.tsx b/ui/src/views/access-point/create-ap-form.tsx index e0e4aa3..643f38c 100644 --- a/ui/src/views/access-point/create-ap-form.tsx +++ b/ui/src/views/access-point/create-ap-form.tsx @@ -1,17 +1,14 @@ import { Card, Grid, Icon, IconButton, Stepper } from '@/components'; -import { CreateAccessPoint } from './create-ap.context'; import { CreateAccessPointFormBody } from './create-ap.form-body'; export const CreateAccessPointForm: React.FC = () => { const { prevStep } = Stepper.useContext(); - const { nfa } = CreateAccessPoint.useContext(); - return ( ; + export type AccessPointContext = { billing: string | undefined; - nfa: ComboboxItem; - setNfa: (nfa: ComboboxItem) => void; + nfa: NFA | undefined; + setNfa: ReactState[1]; }; const [CreateAPProvider, useContext] = createContext({ @@ -29,7 +31,7 @@ export abstract class CreateAccessPoint { children, }) => { const [billing] = useFleekERC721Billing('AddAccessPoint'); - const [nfa, setNfa] = useState({} as ComboboxItem); + const [nfa, setNfa] = useState(); const value = { billing, diff --git a/ui/src/views/access-point/nfa-picker.tsx b/ui/src/views/access-point/nfa-picker.tsx index 1649a93..e25b520 100644 --- a/ui/src/views/access-point/nfa-picker.tsx +++ b/ui/src/views/access-point/nfa-picker.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@apollo/client'; import { useMemo } from 'react'; -import { Combobox, ComboboxItem } from '@/components'; +import { Combobox } from '@/components'; import { getLatestNFAsDocument } from '@/graphclient'; import { AppLog } from '@/utils'; @@ -11,25 +11,26 @@ export const NfaPicker: React.FC = () => { const { nfa, setNfa } = CreateAccessPoint.useContext(); const { data, loading, error } = useQuery(getLatestNFAsDocument); - const handleNfaChange = (item: ComboboxItem): void => { - setNfa(item); - }; - - const items = useMemo(() => { - return data - ? data.tokens.map( - (nfa) => ({ value: nfa.id, label: nfa.name } as ComboboxItem) - ) - : []; - }, [data]); - - if (loading) return
Loading...
; + const items = useMemo(() => data?.tokens || [], [data]); if (error) { - AppLog.errorToast('Error loading NFA list'); + AppLog.errorToast('Error loading NFA list', error); } return ( - + + {({ Field, Options }) => ( + <> + {(selected) => selected?.name || 'Select NFA'} + + {(item) => item.name} + + )} + ); }; diff --git a/ui/src/views/components-test/combobox-test.tsx b/ui/src/views/components-test/combobox-test.tsx index 14825af..6a9f39d 100644 --- a/ui/src/views/components-test/combobox-test.tsx +++ b/ui/src/views/components-test/combobox-test.tsx @@ -1,53 +1,74 @@ import { useState } from 'react'; -import { Combobox, ComboboxItem, Flex } from '@/components'; +import { Combobox, Flex, Icon, IconName } from '@/components'; -const itemsCombobox = [ - { label: 'Item 1', value: 'item-1' }, - { label: 'Item 2', value: 'item-2' }, - { label: 'Item 3', value: 'item-3' }, +type Item = { id: number; label: string; icon: IconName }; + +const Items: Item[] = [ + { id: 1, label: 'Option 1', icon: 'branch' }, + { id: 2, label: 'Option 2', icon: 'ethereum' }, + { id: 3, label: 'Option 3', icon: 'metamask' }, ]; export const ComboboxTest: React.FC = () => { - const [selectedValue, setSelectedValue] = useState({} as ComboboxItem); - const [selectedValueAutocomplete, setSelectedValueAutocomplete] = useState( - {} as ComboboxItem - ); - - const handleComboboxChange = (value: ComboboxItem): void => { - setSelectedValue(value); - }; - - const handleComboboxChangeAutocomplete = (value: ComboboxItem): void => { - setSelectedValueAutocomplete(value); - }; + const selected = useState(); return ( -

Components Test

- - + + {({ Field, Options }) => ( + <> + + {(selected) => ( + <> + + {selected?.label || 'Select an option'} + + )} + - - + + {(item) => ( + <> + {item.label} + + )} + + + )} +
+ + + {({ Field, Options }) => ( + <> + + {(selected) => ( + <> + + {selected?.label || 'Select an option'} + + )} + + + + {(item) => ( + <> + {item.label} + + )} + + + )} + ); }; diff --git a/ui/src/views/explore/explore-list/nfa-list/nfa-list.tsx b/ui/src/views/explore/explore-list/nfa-list/nfa-list.tsx index 6f74413..8d29999 100644 --- a/ui/src/views/explore/explore-list/nfa-list/nfa-list.tsx +++ b/ui/src/views/explore/explore-list/nfa-list/nfa-list.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@apollo/client'; import { useEffect } from 'react'; -import { Flex, NFACard, NFACardSkeleton, NoResults } from '@/components'; +import { Flex, NFACard, NFACardSkeleton } from '@/components'; import { lastNFAsPaginatedDocument } from '@/graphclient'; import { useWindowScrollEnd } from '@/hooks'; @@ -75,7 +75,14 @@ export const NFAListFragment: React.FC = () => { ))} {isLoading && } - {!isLoading && tokens.length === 0 && } + {!isLoading && tokens.length === 0 && ( + // TODO: update this after designs are done +
+ Nothing found. +
+ )} ); diff --git a/ui/src/views/mint/github-step/steps/github-repo-configuration/repo-configuration-body/repo-branch-commit-fields.tsx b/ui/src/views/mint/github-step/steps/github-repo-configuration/repo-configuration-body/repo-branch-commit-fields.tsx index 5345ce8..e91250c 100644 --- a/ui/src/views/mint/github-step/steps/github-repo-configuration/repo-configuration-body/repo-branch-commit-fields.tsx +++ b/ui/src/views/mint/github-step/steps/github-repo-configuration/repo-configuration-body/repo-branch-commit-fields.tsx @@ -1,7 +1,12 @@ import { useEffect } from 'react'; -import { ComboboxItem, Flex, Form, Spinner } from '@/components'; -import { githubActions, useAppDispatch, useGithubStore } from '@/store'; +import { Flex, Form, Icon, Spinner } from '@/components'; +import { + githubActions, + GithubClient, + useAppDispatch, + useGithubStore, +} from '@/store'; import { AppLog } from '@/utils'; import { Mint } from '@/views/mint/mint.context'; import { useMintFormContext } from '@/views/mint/nfa-step/form-step'; @@ -24,33 +29,34 @@ export const RepoBranchCommitFields: React.FC = () => { const { repositoryName, selectedUserOrg } = Mint.useContext(); useEffect(() => { - if (queryLoading === 'idle') { - dispatch( - githubActions.fetchBranchesThunk({ - owner: selectedUserOrg.label, - repository: repositoryName.name, - }) - ); - } - }, [queryLoading, dispatch, selectedUserOrg.label, repositoryName.name]); + if (!(queryLoading === 'idle' && selectedUserOrg && repositoryName)) return; + dispatch( + githubActions.fetchBranchesThunk({ + owner: selectedUserOrg?.label, + repository: repositoryName.name, + }) + ); + }, [queryLoading, dispatch, selectedUserOrg, repositoryName]); useEffect(() => { try { if ( - queryLoading === 'success' && - branches.length > 0 && - repositoryName.defaultBranch !== undefined && - gitBranch === '' //we only set the default branch the first time - ) { - const defaultBranch = branches.find( - (branch) => - branch.label.toLowerCase() === - repositoryName.defaultBranch.toLowerCase() - ); - if (defaultBranch) { - setGitBranch(defaultBranch.label); - setGitCommit(defaultBranch.value); - } + queryLoading !== 'success' || + branches.length === 0 || + !repositoryName || + gitBranch !== '' + ) + return; + + const defaultBranch = branches.find( + (branch) => + branch.name.toLowerCase() === + repositoryName.defaultBranch.toLowerCase() + ); + + if (defaultBranch) { + setGitBranch(defaultBranch.name); + setGitCommit(defaultBranch.commit); } } catch (error) { AppLog.errorToast('We had a problem. Try again'); @@ -58,7 +64,7 @@ export const RepoBranchCommitFields: React.FC = () => { }, [ queryLoading, branches, - repositoryName.defaultBranch, + repositoryName, gitBranch, setGitBranch, setGitCommit, @@ -78,9 +84,9 @@ export const RepoBranchCommitFields: React.FC = () => { ); } - const handleBranchChange = (branch: ComboboxItem): void => { - setGitBranch(branch.label); - setGitCommit(branch.value); + const handleBranchChange = (branch: GithubClient.Branch): void => { + setGitBranch(branch.name); + setGitCommit(branch.commit); }; return ( @@ -88,10 +94,26 @@ export const RepoBranchCommitFields: React.FC = () => { Git Branch + queryKey="name" + handleValue={(item) => item.name} + > + {({ Field, Options }) => ( + <> + + {(selected) => ( + <> + + {selected?.name || 'Select a branch'} + + )} + + + {(item) => item.name} + + )} + Git Commit diff --git a/ui/src/views/mint/github-step/steps/github-repository-selection/github-repository-selection.tsx b/ui/src/views/mint/github-step/steps/github-repository-selection/github-repository-selection.tsx index 6aebcd9..2505305 100644 --- a/ui/src/views/mint/github-step/steps/github-repository-selection/github-repository-selection.tsx +++ b/ui/src/views/mint/github-step/steps/github-repository-selection/github-repository-selection.tsx @@ -1,14 +1,6 @@ import React, { useState } from 'react'; -import { - Card, - ComboboxItem, - Flex, - Grid, - Icon, - IconButton, - Spinner, -} from '@/components'; +import { Card, Flex, Grid, Icon, IconButton, Spinner } from '@/components'; import { Input } from '@/components/core/input'; import { useDebounce } from '@/hooks/use-debounce'; import { useGithubStore } from '@/store'; @@ -50,7 +42,7 @@ export const GithubRepositoryConnection: React.FC = () => { const handlePrevStepClick = (): void => { setGithubStep(1); - setSelectedUserOrg({} as ComboboxItem); + setSelectedUserOrg(undefined); }; return ( @@ -79,13 +71,13 @@ export const GithubRepositoryConnection: React.FC = () => { /> - + {queryLoading === 'loading' || diff --git a/ui/src/views/mint/github-step/steps/github-repository-selection/repositories-list.tsx b/ui/src/views/mint/github-step/steps/github-repository-selection/repositories-list.tsx index 9d372f8..de11b53 100644 --- a/ui/src/views/mint/github-step/steps/github-repository-selection/repositories-list.tsx +++ b/ui/src/views/mint/github-step/steps/github-repository-selection/repositories-list.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react'; -import { Flex, NoResults } from '@/components'; +import { Flex } from '@/components'; import { githubActions, useAppDispatch, useGithubStore } from '@/store'; import { Mint } from '@/views/mint/mint.context'; @@ -27,7 +27,7 @@ export const RepositoriesList: React.FC = ({ }, [searchValue, repositories]); useEffect(() => { - if (queryLoading === 'idle' && selectedUserOrg.value) { + if (queryLoading === 'idle' && selectedUserOrg?.value) { dispatch(githubActions.fetchRepositoriesThunk(selectedUserOrg.value)); } }, [queryLoading, dispatch, selectedUserOrg]); @@ -56,7 +56,12 @@ export const RepositoriesList: React.FC = ({ /> )) ) : ( - + // TODO: update this after designs are done +
+ Nothing found. +
)}
); diff --git a/ui/src/views/mint/github-step/steps/github-repository-selection/users-orgs-combobox.tsx b/ui/src/views/mint/github-step/steps/github-repository-selection/users-orgs-combobox.tsx index 40ff0cc..200ab86 100644 --- a/ui/src/views/mint/github-step/steps/github-repository-selection/users-orgs-combobox.tsx +++ b/ui/src/views/mint/github-step/steps/github-repository-selection/users-orgs-combobox.tsx @@ -1,10 +1,37 @@ import { useEffect } from 'react'; -import { Avatar, Combobox, ComboboxItem } from '@/components'; -import { githubActions, useAppDispatch, useGithubStore } from '@/store'; +import { Avatar, Combobox, Icon } from '@/components'; +import { + githubActions, + GithubClient, + useAppDispatch, + useGithubStore, +} from '@/store'; import { AppLog } from '@/utils'; import { Mint } from '@/views/mint/mint.context'; +const renderSelected = (selected?: GithubClient.UserData): JSX.Element => ( + <> + {selected ? ( + + ) : ( + + )} + {selected?.label || 'Select'} + +); + +const renderItem = (item: GithubClient.UserData): JSX.Element => ( + <> + + {item.label} + +); + export const UserOrgsCombobox: React.FC = () => { const { queryUserAndOrganizations, userAndOrganizations } = useGithubStore(); const dispatch = useAppDispatch(); @@ -17,7 +44,9 @@ export const UserOrgsCombobox: React.FC = () => { } }, [dispatch, queryUserAndOrganizations]); - const handleUserOrgChange = (item: ComboboxItem): void => { + const handleUserOrgChange = ( + item: GithubClient.UserData | undefined + ): void => { if (item) { dispatch(githubActions.fetchRepositoriesThunk(item.value)); setSelectedUserOrg(item); @@ -29,10 +58,10 @@ export const UserOrgsCombobox: React.FC = () => { useEffect(() => { if ( queryUserAndOrganizations === 'success' && - selectedUserOrg.value === undefined && + selectedUserOrg?.value === undefined && userAndOrganizations.length > 0 ) { - //SET first user + // sets the first user setSelectedUserOrg(userAndOrganizations[0]); } }, [ @@ -44,18 +73,18 @@ export const UserOrgsCombobox: React.FC = () => { return ( - ({ - label: item.label, - value: item.value, - icon: , - } as ComboboxItem) + items={userAndOrganizations} + unattached + css={{ flex: 1 }} + selected={[selectedUserOrg, handleUserOrgChange]} + queryKey="label" + > + {({ Field, Options }) => ( + <> + {renderSelected} + {renderItem} + )} - selectedValue={selectedUserOrg} - onChange={handleUserOrgChange} - leftIcon="github" - css="flex-1" - /> + ); }; diff --git a/ui/src/views/mint/mint.context.tsx b/ui/src/views/mint/mint.context.tsx index ff97a11..12b0e83 100644 --- a/ui/src/views/mint/mint.context.tsx +++ b/ui/src/views/mint/mint.context.tsx @@ -1,21 +1,20 @@ import { useState } from 'react'; -import { ComboboxItem } from '@/components'; import { EthereumHooks } from '@/integrations'; -import { GithubState, useFleekERC721Billing } from '@/store'; +import { GithubClient, GithubState, useFleekERC721Billing } from '@/store'; import { AppLog, createContext } from '@/utils'; export type MintContext = { billing: string | undefined; - selectedUserOrg: ComboboxItem; - repositoryName: GithubState.Repository; + selectedUserOrg: GithubClient.UserData | undefined; + repositoryName: GithubState.Repository | undefined; githubStep: number; nfaStep: number; verifyNFA: boolean; setGithubStep: (step: number) => void; setNfaStep: (step: number) => void; - setSelectedUserOrg: (userOrgValue: ComboboxItem) => void; - setRepositoryName: (repo: GithubState.Repository) => void; + setSelectedUserOrg: (userOrgValue: GithubClient.UserData | undefined) => void; + setRepositoryName: (repo: GithubState.Repository | undefined) => void; setVerifyNFA: (verify: boolean) => void; }; @@ -35,9 +34,10 @@ export abstract class Mint { static readonly Provider: React.FC = ({ children }) => { //Github Connection - const [selectedUserOrg, setSelectedUserOrg] = useState({} as ComboboxItem); + const [selectedUserOrg, setSelectedUserOrg] = + useState(); const [repositoryName, setRepositoryName] = - useState({} as GithubState.Repository); + useState(); const [githubStep, setGithubStepContext] = useState(1); //NFA Details diff --git a/ui/src/views/mint/nfa-step/form-step/fields/ens-domain-field/ens-domain-field.tsx b/ui/src/views/mint/nfa-step/form-step/fields/ens-domain-field/ens-domain-field.tsx index 79526c4..76f97a8 100644 --- a/ui/src/views/mint/nfa-step/form-step/fields/ens-domain-field/ens-domain-field.tsx +++ b/ui/src/views/mint/nfa-step/form-step/fields/ens-domain-field/ens-domain-field.tsx @@ -4,7 +4,7 @@ import { DomainField } from './domain-field'; import { EnsField } from './ens-field'; export const EnsDomainField: React.FC = () => ( - + diff --git a/ui/src/views/mint/nfa-step/form-step/fields/ens-domain-field/ens-field.tsx b/ui/src/views/mint/nfa-step/form-step/fields/ens-domain-field/ens-field.tsx index b10d289..5c0dd73 100644 --- a/ui/src/views/mint/nfa-step/form-step/fields/ens-domain-field/ens-field.tsx +++ b/ui/src/views/mint/nfa-step/form-step/fields/ens-domain-field/ens-field.tsx @@ -3,14 +3,14 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useAccount } from 'wagmi'; import { getENSNamesDocument } from '@/../.graphclient'; -import { ComboboxItem, Form } from '@/components'; +import { Form } from '@/components'; import { AppLog } from '@/utils'; import { useMintFormContext } from '../../mint-form.context'; export const EnsField: React.FC = () => { const { address } = useAccount(); - const { data, error } = useQuery(getENSNamesDocument, { + const { data, error, loading } = useQuery(getENSNamesDocument, { variables: { address: address?.toLowerCase() || '', //should skip if undefined }, @@ -34,25 +34,30 @@ export const EnsField: React.FC = () => { }, [error, showError]); const ensNames = useMemo(() => { - const ensList: ComboboxItem[] = []; - if (data && data.account && data.account.domains) { - data.account.domains.forEach((ens) => { - const { name } = ens; - if (name) { - ensList.push({ - label: name, - value: name, - }); - } - }); - } - return ensList; + if (!(data && data.account && data.account.domains)) return []; + return data.account.domains.map((ens) => ens.name as string); }, [data]); return ( ENS - + item} + > + {({ Field, Options, Message }) => ( + <> + {(selected) => selected || 'Select an ENS'} + + + {(item) => item} + No owned ENS names found + + + )} + );