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
This commit is contained in:
Felipe Mendes 2023-04-17 11:40:31 -03:00 committed by GitHub
parent fdedc18b02
commit 9934ad1cfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 689 additions and 542 deletions

View File

@ -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<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?: 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<typeof AvatarStyles.Image>;
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
>;
}

View File

@ -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<HTMLDivElement, AvatarProps>(
({ imageProps = {}, src, alt, css, ...rootProps }, ref) => {
export const Avatar = forwardStyledRef<HTMLDivElement, AS.RootProps>(
({ imageProps = {}, src, alt, ...rootProps }, ref) => {
return (
<AvatarStyles.Root {...rootProps} ref={ref} css={css}>
<AvatarStyles.Image src={src} alt={alt} {...imageProps} />
</AvatarStyles.Root>
<AS.Root {...rootProps} ref={ref}>
<AS.Image src={src} alt={alt} {...imageProps} />
</AS.Root>
);
}
);

View File

@ -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',
},
}),
};

View File

@ -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 = <CS.Message>No items found</CS.Message>;
const LoadingMessage = (
<CS.Message>
<Spinner /> Searching...
</CS.Message>
);
const ComboboxInput: React.FC<ComboboxInputProps> = ({
const [Provider, useContext] = createContext<Combobox.Context<unknown>>({
name: 'ComboboxContext',
hookName: 'useComboboxContext',
providerName: 'ComboboxProvider',
});
const Input = <T,>(props: Combobox.InputProps<T>): JSX.Element => {
const {
query: [, setQuery],
} = useContext() as Combobox.Context<T>;
const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
setQuery(event.target.value);
if (props.onChange) props.onChange(event);
};
return <CS.Input {...props} onChange={onChange} />;
};
const Options = <T,>({
disableSearch,
children,
...props
}: ComboboxInputProps) => (
<div className="relative w-full">
<Icon
name="search"
size="sm"
css={{
position: 'absolute',
left: '$3',
top: '$3',
fontSize: '$xl',
color: '$slate8',
}}
/>
<ComboboxLib.Input
placeholder="Search"
className={`w-full h-9 py-3 px-10 text-sm bg-transparent leading-5 text-slate11 outline-none `}
{...props}
/>
</div>
);
}: Combobox.OptionsProps<T>): JSX.Element => {
const {
query: [query],
loading,
selected: [selected],
items,
queryFilter,
} = useContext() as Combobox.Context<T>;
type ComboboxOptionProps = {
option: ComboboxItem;
};
const ComboboxOption: React.FC<ComboboxOptionProps> = ({
option,
}: ComboboxOptionProps) => (
<ComboboxLib.Option
value={option}
className={({ active }) =>
`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 }) => (
<Flex css={{ justifyContent: 'space-between' }}>
<Flex css={{ flexDirection: 'row', maxWidth: '95%' }}>
{option.icon}
<span
className={`${active ? 'text-slate12' : 'text-slate11'} ${
option.icon ? 'max-w-70' : 'max-w-full'
} whitespace-nowrap text-ellipsis overflow-hidden`}
>
{option.label}
</span>
</Flex>
{selected && <Icon name="check" color="white" />}
</Flex>
)}
</ComboboxLib.Option>
);
export const NoResults: React.FC = ({ css }: { css?: string }) => (
<div
className={`relative cursor-default select-none pt-2 px-3.5 pb-4 text-slate11 ${css}`}
>
Nothing found.
</div>
);
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<ComboboxProps> = ({
items,
selectedValue = { value: '', label: '' },
withAutocomplete = false,
leftIcon = 'search',
onChange,
onBlur,
error = false,
css,
}) => {
const [filteredItems, setFilteredItems] = useState<ComboboxItem[]>([]);
const [autocompleteItems, setAutocompleteItems] = useState<ComboboxItem[]>(
[]
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<HTMLInputElement>
): 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 (
<ComboboxLib
value={selectedValue}
by="value"
onChange={handleComboboxChange}
>
{({ open }) => (
<div className={`relative w-full ${css ? css : ''}`}>
<div className="relative w-full">
<Icon
name={leftIcon}
size="sm"
css={{
position: 'absolute',
left: '$3',
top: '$3',
fontSize: '$xl',
color: 'slate8',
}}
/>
<ComboboxLib.Button
className={`w-full text-left border-solid border rounded-xl h-11 py-3 px-10 text-sm leading-5 text-slate11 outline-none ${
error ? 'border-red9' : 'border-slate7'
}`}
onBlur={onBlur}
>
{selectedValue && selectedValue.label
? selectedValue.label
: 'Search'}
</ComboboxLib.Button>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-4">
<Icon name="chevron-down" css={{ fontSize: '$xs' }} />
</span>
</div>
<Transition
show={open}
as={Fragment}
enter="transition duration-400 ease-out"
leave="transition ease-out duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={handleLeaveTransition}
>
<div className="absolute max-h-60 mt-2 w-full z-10 overflow-auto rounded-xl border-solid border-slate6 border bg-black pt-2 px-3 text-base focus:outline-none sm:text-sm">
<ComboboxInput onChange={handleInputChange} onBlur={onBlur} />
<Separator />
<ComboboxLib.Options className="mt-1 z-20">
{[...autocompleteItems, ...filteredItems].length === 0 ||
filteredItems === undefined ? (
<NoResults />
) : (
<>
{autocompleteItems.length > 0 && <span>Create new</span>}
{autocompleteItems.map(
(autocompleteOption: ComboboxItem) => (
<ComboboxOption
key={autocompleteOption.value}
option={autocompleteOption}
/>
)
)}
{autocompleteItems.length > 0 &&
filteredItems.length > 0 && (
<Separator css={{ mb: '$2' }} />
)}
{filteredItems.map((option: ComboboxItem) => (
<ComboboxOption key={option.value} option={option} />
))}
</>
)}
</ComboboxLib.Options>
</div>
</Transition>
</div>
<CS.Options {...props}>
{!disableSearch && (
<CS.InnerSearchContainer>
<Icon name="search" />
<Input placeholder="Search..." />
</CS.InnerSearchContainer>
)}
</ComboboxLib>
{filteredItems.map((item) => (
<CS.Option key={JSON.stringify(item)} value={item}>
{optionRenderer(item, selected === item)}
{selected === item && <CS.RightPositionedIcon name="check" />}
</CS.Option>
))}
{!loading && filteredItems.length === 0 && EmptyRender}
{loading && LoadingRender}
</CS.Options>
);
};
Combobox.displayName = 'Combobox';
const Field = <T,>({
children,
disableChevron,
...props
}: Combobox.FieldProps<T>): JSX.Element => {
const {
selected: [selected],
} = useContext() as Combobox.Context<T>;
return (
<CS.Field {...props}>
{children(selected)}
{!disableChevron && <CS.RightPositionedIcon name="chevron-down" />}
</CS.Field>
);
};
export const Combobox = <T,>({
children,
selected,
isLoading: loading = false,
items,
queryKey,
...props
}: Combobox.RootProps<T>): 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<Combobox.Context<T>>;
return (
<HeadlessCombobox
as={CS.Wrapper}
value={value}
onChange={setValue}
{...props}
>
{({ open }) => (
<TypedProvider
value={{
selected,
query,
loading,
open,
items,
queryFilter,
}}
>
{children({
Options: Options<T>,
Input: Input<T>,
Field: Field<T>,
Message: CS.Message,
})}
</TypedProvider>
)}
</HeadlessCombobox>
);
};
export namespace Combobox {
export type Context<T> = {
items: T[];
selected: [T | undefined, (newState: T | undefined) => void];
query: ReactState<string>;
loading: boolean;
open: boolean;
queryFilter: (query: string, item: T) => boolean;
};
export type OptionsProps<T> = Omit<
React.ComponentPropsWithRef<typeof CS.Options>,
'children'
> & {
disableSearch?: boolean;
children:
| ((item: T, selected: boolean) => React.ReactNode)
| [
(item: T, selected: boolean) => React.ReactNode,
React.ReactNode?,
React.ReactNode?
];
};
export type InputProps<T> = ComboboxInputProps<'input', T | undefined>;
export type FieldProps<T> = Omit<
React.ComponentPropsWithRef<typeof CS.Field>,
'children'
> & {
children: (item: T | undefined) => React.ReactElement | React.ReactNode;
disableChevron?: boolean;
};
export type Elements<T> = {
Options: React.FC<OptionsProps<T>>;
Input: React.FC<InputProps<T>>;
Field: React.FC<FieldProps<T>>;
Message: typeof CS.Message;
};
export type RootProps<T> = Omit<
React.ComponentPropsWithRef<typeof CS.Wrapper>,
'defaultValue' | 'onChange' | 'children'
> &
Pick<Context<T>, 'selected' | 'items'> & {
isLoading?: boolean;
children: (elements: Elements<T>) => React.ReactNode;
} & (T extends object
? { queryKey: keyof T | (keyof T)[] }
: { queryKey?: undefined });
}

View File

@ -1,2 +0,0 @@
export const cleanString = (str: string): string =>
str.toLowerCase().replace(/\s+/g, '');

View File

@ -1,2 +1,2 @@
export * from './combobox';
export * from './dropdown';
export * from './combobox';

View File

@ -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<typeof IconStyles.Container>;
export const Icon: React.FC<IconProps> = forwardRef<HTMLSpanElement, IconProps>(
(props, ref) => {
const { name, iconElementCss, ...rest } = props;
const IconElement: IconType<typeof name> = IconLibrary[name];
export const Icon: React.FC<IconProps> = forwardStyledRef<
HTMLSpanElement,
IconProps
>((props, ref) => {
const { name, iconElementCss, ...rest } = props;
const IconElement: IconType<typeof name> = IconLibrary[name];
return (
<IconStyles.Container {...rest} ref={ref}>
<IconElement
style={{ width: '1em', height: '1em', ...iconElementCss }}
/>
</IconStyles.Container>
);
}
);
return (
<IconStyles.Container {...rest} ref={ref}>
<IconElement style={{ width: '1em', height: '1em', ...iconElementCss }} />
</IconStyles.Container>
);
});
Icon.displayName = 'Icon';

View File

@ -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<typeof InputStyled>;
export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const { leftIcon, css, ...ownProps } = props;
export const Input = forwardStyledRef<HTMLInputElement, InputProps>(
(props, ref) => {
const { leftIcon, wrapperClassName: css = '', ...ownProps } = props;
return (
<div className={`relative ${css ? css : ''}`}>
{leftIcon && (
<InputIconStyled name={leftIcon} css={{ fontSize: '$lg' }} />
)}
<InputStyled
{...props}
ref={ref}
css={{ ...(leftIcon && { pl: '$10' }), ...(ownProps.css || {}) }}
/>
</div>
);
});
return (
<div className={`relative ${css}`}>
{leftIcon && (
<InputIconStyled name={leftIcon} css={{ fontSize: '$lg' }} />
)}
<InputStyled
{...props}
ref={ref}
css={{ ...(leftIcon && { pl: '$10' }), ...(ownProps.css || {}) }}
/>
</div>
);
}
);
Input.displayName = 'Input';

View File

@ -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<Form.ComboboxProps> = (props) => {
static readonly Combobox = <T,>({
handleValue,
...props
}: Form.ComboboxProps<T>): 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 (
<Combobox
{...props}
onChange={handleComboboxChange}
selectedValue={comboboxValue || ({} as ComboboxItem)}
selected={[selected, setSelected]}
onBlur={handleComboboxBlur}
error={validationEnabled && !isValid}
/>
@ -255,7 +252,7 @@ export abstract class Form {
const handleFileInputChange = async (
e: React.ChangeEvent<HTMLInputElement>
): void => {
): Promise<void> => {
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<typeof Combobox>,
'error' | 'selectedValue' | 'onChange'
>;
export type ComboboxProps<T> = Omit<Combobox.RootProps<T>, 'selected'> & {
handleValue: (item: T) => string;
onChange?: (item: T) => void;
};
export type LogoFileInputProps = Omit<
React.ComponentProps<typeof LogoFileInput>,

View File

@ -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<ComboboxItem[]> => {
export const fetchMintedSites = async (): Promise<typeof listBranches> => {
return new Promise((resolved) => {
setTimeout(() => {
resolved(listBranches);

View File

@ -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<void, FetchBranches>(
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.'

View File

@ -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'));

View File

@ -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<UserData> {
async fetchUser(): Promise<GithubClient.UserData> {
const { data: userData } = await this.octokit.request('GET /user');
return {
@ -31,7 +23,7 @@ export class GithubClient {
};
}
async fetchOrgs(): Promise<UserData[]> {
async fetchOrgs(): Promise<GithubClient.UserData[]> {
const { data: organizationsData } = await this.octokit.request(
'GET /user/orgs'
);
@ -63,7 +55,10 @@ export class GithubClient {
);
}
async fetchBranches(owner: string, repo: string): Promise<DropdownItem[]> {
async fetchBranches(
owner: string,
repo: string
): Promise<GithubClient.Branch[]> {
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;
};
}

View File

@ -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<UserData>;
export type UserAndOrganizations = Array<GithubClient.UserData>;
export type Repository = {
name: string;
@ -30,7 +29,7 @@ export namespace GithubState {
export type Repositories = Array<Repository>;
export type Branches = Array<ComboboxItem>;
export type Branches = Array<GithubClient.Branch>;
}
export interface GithubState {

View File

@ -1 +1,2 @@
export * from './github-slice';
export * from './github-client';

View File

@ -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 (
<Card.Container css={{ width: '$107h' }}>
<Card.Heading
title={`Create Access Point - ${nfa.label || ''}`}
title="Create Access Point"
leftIcon={
<IconButton
aria-label="Add"

View File

@ -1,14 +1,16 @@
import { useState } from 'react';
import { ComboboxItem } from '@/components';
import { Token } from '@/graphclient';
import { EthereumHooks } from '@/integrations';
import { useFleekERC721Billing } from '@/store';
import { AppLog, createContext, pushToast } from '@/utils';
type NFA = Pick<Token, 'id' | 'name'>;
export type AccessPointContext = {
billing: string | undefined;
nfa: ComboboxItem;
setNfa: (nfa: ComboboxItem) => void;
nfa: NFA | undefined;
setNfa: ReactState<NFA | undefined>[1];
};
const [CreateAPProvider, useContext] = createContext<AccessPointContext>({
@ -29,7 +31,7 @@ export abstract class CreateAccessPoint {
children,
}) => {
const [billing] = useFleekERC721Billing('AddAccessPoint');
const [nfa, setNfa] = useState<ComboboxItem>({} as ComboboxItem);
const [nfa, setNfa] = useState<NFA>();
const value = {
billing,

View File

@ -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 <div>Loading...</div>;
const items = useMemo(() => data?.tokens || [], [data]);
if (error) {
AppLog.errorToast('Error loading NFA list');
AppLog.errorToast('Error loading NFA list', error);
}
return (
<Combobox items={items} selectedValue={nfa} onChange={handleNfaChange} />
<Combobox
isLoading={loading}
items={items}
selected={[nfa, setNfa]}
queryKey={['name', 'id']}
>
{({ Field, Options }) => (
<>
<Field>{(selected) => selected?.name || 'Select NFA'}</Field>
<Options>{(item) => item.name}</Options>
</>
)}
</Combobox>
);
};

View File

@ -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<Item>();
return (
<Flex
css={{
position: 'relative',
flexDirection: 'column',
margin: '100px',
justifyContent: 'center',
gap: '10px',
width: '600px',
alignSelf: 'center',
}}
>
<h1>Components Test</h1>
<Flex css={{ width: '600px', gap: '$2' }}>
<Combobox
items={itemsCombobox}
selectedValue={selectedValue}
onChange={handleComboboxChange}
leftIcon="github"
/>
<Combobox unattached items={Items} selected={selected} queryKey="label">
{({ Field, Options }) => (
<>
<Field>
{(selected) => (
<>
<Icon name={selected?.icon || 'search'} />
{selected?.label || 'Select an option'}
</>
)}
</Field>
<Combobox
items={itemsCombobox}
selectedValue={selectedValueAutocomplete}
onChange={handleComboboxChangeAutocomplete}
withAutocomplete
/>
</Flex>
<Options>
{(item) => (
<>
<Icon name={item.icon} /> {item.label}
</>
)}
</Options>
</>
)}
</Combobox>
<Combobox unattached items={Items} selected={selected} queryKey="label">
{({ Field, Options }) => (
<>
<Field>
{(selected) => (
<>
<Icon name={selected?.icon || 'search'} />
{selected?.label || 'Select an option'}
</>
)}
</Field>
<Options>
{(item) => (
<>
<Icon name={item.icon} /> {item.label}
</>
)}
</Options>
</>
)}
</Combobox>
</Flex>
);
};

View File

@ -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 = () => {
<NFACard data={token} key={token.id} />
))}
{isLoading && <LoadingSkeletons />}
{!isLoading && tokens.length === 0 && <NoResults />}
{!isLoading && tokens.length === 0 && (
// TODO: update this after designs are done
<div
className={`relative cursor-default select-none pt-2 px-3.5 pb-4 text-slate11 text-center`}
>
Nothing found.
</div>
)}
</Flex>
</Flex>
);

View File

@ -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 = () => {
<Form.Field context={gitBranchContext}>
<Form.Label>Git Branch</Form.Label>
<Form.Combobox
leftIcon="branch"
items={branches}
onChange={handleBranchChange}
/>
queryKey="name"
handleValue={(item) => item.name}
>
{({ Field, Options }) => (
<>
<Field>
{(selected) => (
<>
<Icon name="branch" />
{selected?.name || 'Select a branch'}
</>
)}
</Field>
<Options>{(item) => item.name}</Options>
</>
)}
</Form.Combobox>
</Form.Field>
<Form.Field context={gitCommitContext}>
<Form.Label>Git Commit</Form.Label>

View File

@ -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 = () => {
/>
<Card.Body css={{ pt: '$4' }}>
<Grid css={{ rowGap: '$2' }}>
<Flex css={{ gap: '$4', pr: '$3h' }}>
<Flex css={{ gap: '$4', pr: '$3h', position: 'relative' }}>
<UserOrgsCombobox />
<Input
leftIcon="search"
placeholder="Search repo"
onChange={handleSearchChange}
css="flex-1"
wrapperClassName="flex-1"
/>
</Flex>
{queryLoading === 'loading' ||

View File

@ -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<RepositoriesListProps> = ({
}, [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<RepositoriesListProps> = ({
/>
))
) : (
<NoResults css="text-center" />
// TODO: update this after designs are done
<div
className={`relative cursor-default select-none pt-2 px-3.5 pb-4 text-slate11 text-center`}
>
Nothing found.
</div>
)}
</Flex>
);

View File

@ -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 ? (
<Avatar
src={selected.avatar}
alt={selected.label}
css={{ fontSize: '$2xl' }}
/>
) : (
<Icon name="github" css={{ fontSize: '$2xl' }} />
)}
{selected?.label || 'Select'}
</>
);
const renderItem = (item: GithubClient.UserData): JSX.Element => (
<>
<Avatar src={item.avatar} alt={item.label} />
{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 (
<Combobox
items={userAndOrganizations.map(
(item) =>
({
label: item.label,
value: item.value,
icon: <Avatar src={item.avatar} />,
} as ComboboxItem)
items={userAndOrganizations}
unattached
css={{ flex: 1 }}
selected={[selectedUserOrg, handleUserOrgChange]}
queryKey="label"
>
{({ Field, Options }) => (
<>
<Field>{renderSelected}</Field>
<Options>{renderItem}</Options>
</>
)}
selectedValue={selectedUserOrg}
onChange={handleUserOrgChange}
leftIcon="github"
css="flex-1"
/>
</Combobox>
);
};

View File

@ -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<Mint.ProviderProps> = ({ children }) => {
//Github Connection
const [selectedUserOrg, setSelectedUserOrg] = useState({} as ComboboxItem);
const [selectedUserOrg, setSelectedUserOrg] =
useState<GithubClient.UserData>();
const [repositoryName, setRepositoryName] =
useState<GithubState.Repository>({} as GithubState.Repository);
useState<GithubState.Repository>();
const [githubStep, setGithubStepContext] = useState(1);
//NFA Details

View File

@ -4,7 +4,7 @@ import { DomainField } from './domain-field';
import { EnsField } from './ens-field';
export const EnsDomainField: React.FC = () => (
<Flex css={{ columnGap: '$4' }}>
<Flex css={{ columnGap: '$4', position: 'relative' }}>
<EnsField />
<DomainField />
</Flex>

View File

@ -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 (
<Form.Field context={ens} css={{ flex: 1 }}>
<Form.Label>ENS</Form.Label>
<Form.Combobox items={ensNames} />
<Form.Combobox
unattached
isLoading={loading}
items={ensNames}
handleValue={(item) => item}
>
{({ Field, Options, Message }) => (
<>
<Field>{(selected) => selected || 'Select an ENS'}</Field>
<Options>
{(item) => item}
<Message>No owned ENS names found</Message>
</Options>
</>
)}
</Form.Combobox>
<Form.Overline />
</Form.Field>
);