Finish chain multiselect UI

Fix warning about table markup
pull/20/head
J M Rossy 2 years ago
parent 675476754a
commit ce823e8a6b
  1. 16
      src/components/buttons/BorderedButton.tsx
  2. 31
      src/components/buttons/TextButton.tsx
  3. 20
      src/components/icons/XIcon.tsx
  4. 4
      src/components/input/Checkbox.module.css
  5. 8
      src/components/input/Checkbox.tsx
  6. 4
      src/components/nav/Header.tsx
  7. 249
      src/components/search/SearchFilterBar.tsx
  8. 16
      src/features/messages/MessageSearch.tsx
  9. 42
      src/features/messages/MessageTable.tsx
  10. 0
      src/utils/arrays.ts
  11. 27
      src/utils/objects.ts

@ -1,7 +1,6 @@
import { PropsWithChildren, ReactElement } from 'react';
interface ButtonProps {
size?: 'xs' | 's' | 'm' | 'l' | 'xl';
type?: 'submit' | 'reset' | 'button';
onClick?: () => void;
classes?: string;
@ -12,15 +11,14 @@ interface ButtonProps {
}
export function BorderedButton(props: PropsWithChildren<ButtonProps>) {
const { size, type, onClick, classes, bold, icon, disabled, title } = props;
const { type, onClick, classes, bold, icon, disabled, title } = props;
const base = 'border-2 border-black transition-all';
const sizing = sizeToClasses(size);
const base = 'border border-black rounded transition-all';
const onHover = 'hover:border-gray-500 hover:text-gray-500';
const onDisabled = 'disabled:border-gray-300 disabled:text-gray-300';
const onActive = 'active:border-gray-400 active:text-gray-400';
const weight = bold ? 'font-semibold' : '';
const allClasses = `${base} ${sizing} ${onHover} ${onDisabled} ${onActive} ${weight} ${classes}`;
const allClasses = `${base} ${onHover} ${onDisabled} ${onActive} ${weight} ${classes}`;
return (
<button
@ -41,11 +39,3 @@ export function BorderedButton(props: PropsWithChildren<ButtonProps>) {
</button>
);
}
function sizeToClasses(size?: string) {
if (size === 'xs') return 'w-20 h-8 p-1 rounded';
if (size === 's') return 'w-30 h-9 p-1 rounded';
if (size === 'l') return 'w-44 h-12 p-1.5 text-lg rounded-lg';
if (size === 'xl') return 'w-48 h-14 p-1.5 text-xl rounded-lg';
return 'w-40 h-11 p-2 rounded-md'; // 'm' or other
}

@ -0,0 +1,31 @@
import { PropsWithChildren } from 'react';
export interface TextButtonProps {
classes?: string;
onClick?: () => void;
disabled?: boolean;
type?: 'button' | 'submit';
passThruProps?: any;
}
export function TextButton(props: PropsWithChildren<TextButtonProps>) {
const { classes, onClick, disabled, type, children, passThruProps } = props;
const base = 'flex place-content-center transition-all';
const onHover = 'hover:opacity-70';
const onDisabled = 'disabled:opacity-50';
const onActive = 'active:opacity-60';
const allClasses = `${base} ${onHover} ${onDisabled} ${onActive} ${classes}`;
return (
<button
onClick={onClick}
type={type || 'button'}
disabled={disabled ?? false}
className={allClasses}
{...passThruProps}
>
{children}
</button>
);
}

@ -0,0 +1,20 @@
import { memo } from 'react';
import X from '../../images/icons/x.svg';
import { IconButton } from '../buttons/IconButton';
function _XIcon({
onClick,
title,
size = 20,
}: {
onClick: () => void;
title?: string;
size?: number;
}) {
return (
<IconButton imgSrc={X} title={title || 'Close'} width={size} height={size} onClick={onClick} />
);
}
export const XIcon = memo(_XIcon);

@ -13,10 +13,6 @@
transition: 200ms all ease-in-out;
}
.checkbox:hover {
opacity: 0.85;
}
.checkbox::before {
content: "";
width: 0.9rem;

@ -4,17 +4,17 @@ import styles from './Checkbox.module.css';
interface Props {
checked: boolean;
onCheck: (c: boolean) => void;
onToggle: (c: boolean) => void;
name?: string;
}
export function CheckBox({ checked, onCheck, name, children }: React.PropsWithChildren<Props>) {
export function CheckBox({ checked, onToggle, name, children }: React.PropsWithChildren<Props>) {
const onChange = () => {
onCheck(!checked);
onToggle(!checked);
};
return (
<label className="flex items-center cursor-pointer">
<label className="flex items-center cursor-pointer hover:opacity-80">
<input
type="checkbox"
name={name}

@ -27,8 +27,8 @@ export function Header({ pathName }: { pathName: string }) {
<Link href="/">
<a className="flex items-center">
<div className="flex items-center scale-90 sm:scale-100">
<Image src={Logo} width={22} height={22} alt="" className="pb-1" />
<Image src={Name} width={110} height={22} alt="Hyperlane" className="ml-2" />
<Image src={Logo} width={22} height={22} alt="" />
<Image src={Name} width={110} height={22} alt="Hyperlane" className="mt-0.5 ml-2" />
<h1 className="ml-2 font-serif text-[1.75rem] xs:text-[1.65rem] leading-[0.5rem] text-blue-500">
Explorer
</h1>

@ -1,21 +1,25 @@
import Image from 'next/future/image';
import { useMemo } from 'react';
import { useState } from 'react';
import useDropdownMenu from 'react-accessible-dropdown-menu-hook';
import { Chain } from 'wagmi';
import { mainnetAndTestChains, mainnetChains, testnetChains } from '../../consts/chains';
import ArrowRightIcon from '../../images/icons/arrow-right-short.svg';
import FunnelIcon from '../../images/icons/funnel.svg';
import { getChainDisplayName } from '../../utils/chains';
import { trimToLength } from '../../utils/string';
import { arrayToObject } from '../../utils/objects';
import { BorderedButton } from '../buttons/BorderedButton';
import { TextButton } from '../buttons/TextButton';
import { ChainIcon } from '../icons/ChainIcon';
import { ChevronIcon } from '../icons/Chevron';
import { XIcon } from '../icons/XIcon';
import { CheckBox } from '../input/Checkbox';
import { SelectField } from '../input/SelectField';
interface Props {
originChainFilter: string;
onChangeOriginFilter: (value: string) => void;
destinationChainFilter: string;
onChangeDestinationFilter: (value: string) => void;
originChainFilter: string | null;
onChangeOriginFilter: (value: string | null) => void;
destinationChainFilter: string | null;
onChangeDestinationFilter: (value: string | null) => void;
}
export function SearchFilterBar({
@ -24,13 +28,6 @@ export function SearchFilterBar({
destinationChainFilter,
onChangeDestinationFilter,
}: Props) {
const chainOptions = useMemo(getChainOptionList, []);
const { buttonProps, itemProps, isOpen, setIsOpen } = useDropdownMenu(1);
const closeDropdown = () => {
setIsOpen(false);
};
return (
<div className="flex items-center space-x-1 sm:space-x-2 md:space-x-3">
<div className="w-px h-8 bg-gray-200"></div>
@ -41,97 +38,183 @@ export function SearchFilterBar({
className="hidden sm:block opacity-20"
alt=""
/>
<div className="relative">
<button className="hover:opacity-80 transition-all" {...buttonProps}>
All Chains
</button>
<div
className={`dropdown-menu w-88 -left-1 top-10 bg-white shadow-md drop-shadow-md xs:border-blue-50 ${
!isOpen && 'hidden'
}`}
role="menu"
>
<ChainMultiSelector
header="Origin Chains"
value={originChainFilter}
onChangeValue={onChangeOriginFilter}
/>
</div>
</div>
<SelectField
classes="w-24 md:w-32"
options={chainOptions}
<ChainMultiSelector
text="Origin"
header="Origin Chains"
value={originChainFilter}
onValueSelect={onChangeOriginFilter}
onChangeValue={onChangeOriginFilter}
position="-right-24"
/>
<Image src={ArrowRightIcon} width={30} height={30} className="opacity-30" alt="" />
<SelectField
classes="w-24 md:w-32"
options={chainOptions}
<ChainMultiSelector
text="Destination"
header="Destination Chains"
value={destinationChainFilter}
onValueSelect={onChangeDestinationFilter}
onChangeValue={onChangeDestinationFilter}
/>
</div>
);
}
function ChainMultiSelector({
text,
header,
value,
onChangeValue,
position,
}: {
text: string;
header: string;
value: string;
onChangeValue: (value: string) => void;
value: string | null; // comma separated list of checked chains
onChangeValue: (value: string | null) => void;
position?: string;
}) {
const { buttonProps, isOpen, setIsOpen } = useDropdownMenu(1);
const closeDropdown = () => {
setIsOpen(false);
};
const [checkedChains, setCheckedChains] = useState(
value ? arrayToObject(value.split(',')) : arrayToObject(mainnetAndTestChains.map((c) => c.id)),
);
const hasAnyUncheckedChain = (chains: Chain[]) => {
for (const c of chains) {
if (!checkedChains[c.id]) return true;
}
return false;
};
const onToggle = (chainId: string | number) => {
return (checked: boolean) => {
setCheckedChains({ ...checkedChains, [chainId]: checked });
};
};
const onToggleSection = (chains: Chain[]) => {
return () => {
const chainIds = chains.map((c) => c.id);
if (hasAnyUncheckedChain(chains)) {
// If some are unchecked, check all
setCheckedChains({ ...checkedChains, ...arrayToObject(chainIds) });
} else {
// If none are unchecked, uncheck all
setCheckedChains({ ...checkedChains, ...arrayToObject(chainIds, false) });
}
};
};
const onToggleAll = () => {
setCheckedChains(arrayToObject(mainnetAndTestChains.map((c) => c.id)));
};
const onToggleNone = () => {
setCheckedChains({});
};
const onClickApply = () => {
const checkedList = Object.keys(checkedChains).filter((c) => !!checkedChains[c]);
if (checkedList.length === 0 || checkedList.length === mainnetAndTestChains.length) {
// Use null value, indicating to filter needed
onChangeValue(null);
} else {
onChangeValue(checkedList.join(','));
}
closeDropdown();
};
return (
<div className="p-1.5">
<h3 className="text-gray-700 text-lg">{header}</h3>
<div className="mt-2.5 flex space-x-6">
<div className="flex flex-col space-y-1">
<div className="pb-1.5">
<CheckBox checked={true} onCheck={alert} name="mainnet-chains">
<h4 className="ml-2 text-gray-700">Mainnet Chains</h4>
</CheckBox>
</div>
{mainnetChains.map((c) => (
<CheckBox key={c.name} checked={true} onCheck={alert} name={c.network}>
<div className="ml-2 text-sm flex items-center">
<span className="mr-1">{getChainDisplayName(c.id, true)}</span>
<ChainIcon chainId={c.id} size={22} />
</div>
</CheckBox>
))}
<div className="relative">
<button
className="text-sm min-w-[6rem] px-2.5 py-1 flex items-center justify-center rounded border border-gray-500 hover:opacity-70 active:opacity-60 transition-all"
{...buttonProps}
>
<span>{text}</span>
<ChevronIcon direction="s" width={10} height={6} classes="ml-2" />
</button>
<div
className={`dropdown-menu w-88 ${
position || 'right-0'
} top-10 bg-white shadow-md drop-shadow-md xs:border-blue-50 ${!isOpen && 'hidden'}`}
role="menu"
>
<div className="absolute top-1.5 right-1.5">
<XIcon onClick={closeDropdown} />
</div>
<div className="self-stretch w-px my-1 bg-gray-300"></div>
<div className="flex flex-col space-y-1">
<div className="pb-1.5">
<CheckBox checked={true} onCheck={alert} name="testnet-chains">
<h4 className="ml-2 text-gray-700">Testnet Chains</h4>
</CheckBox>
<div className="py-0.5 px-1.5">
<div className="flex items-center">
<h3 className="text-gray-700 text-lg">{header}</h3>
<div className="flex ml-[4.7rem]">
<TextButton classes="text-sm underline underline-offset-2" onClick={onToggleAll}>
All
</TextButton>
<TextButton
classes="ml-3.5 text-sm underline underline-offset-2"
onClick={onToggleNone}
>
None
</TextButton>
</div>
</div>
{testnetChains.map((c) => (
<CheckBox key={c.name} checked={true} onCheck={alert} name={c.network}>
<div className="ml-2 text-sm flex items-center">
<span className="mr-1">{getChainDisplayName(c.id, true)}</span>
<ChainIcon chainId={c.id} size={22} />
<div className="mt-2.5 flex space-x-6">
<div className="flex flex-col space-y-1">
<div className="pb-1.5">
<CheckBox
checked={!hasAnyUncheckedChain(mainnetChains)}
onToggle={onToggleSection(mainnetChains)}
name="mainnet-chains"
>
<h4 className="ml-2 text-gray-700">Mainnet Chains</h4>
</CheckBox>
</div>
{mainnetChains.map((c) => (
<CheckBox
key={c.name}
checked={!!checkedChains[c.id]}
onToggle={onToggle(c.id)}
name={c.network}
>
<div className="ml-2 text-sm flex items-center">
<span className="mr-1">{getChainDisplayName(c.id, true)}</span>
<ChainIcon chainId={c.id} size={22} />
</div>
</CheckBox>
))}
</div>
<div className="self-stretch w-px my-1 bg-gray-100"></div>
<div className="flex flex-col space-y-1">
<div className="pb-1.5">
<CheckBox
checked={!hasAnyUncheckedChain(testnetChains)}
onToggle={onToggleSection(testnetChains)}
name="testnet-chains"
>
<h4 className="ml-2 text-gray-700">Testnet Chains</h4>
</CheckBox>
</div>
</CheckBox>
))}
{testnetChains.map((c) => (
<CheckBox
key={c.name}
checked={!!checkedChains[c.id]}
onToggle={onToggle(c.id)}
name={c.network}
>
<div className="ml-2 text-sm flex items-center">
<span className="mr-1">{getChainDisplayName(c.id, true)}</span>
<ChainIcon chainId={c.id} size={22} />
</div>
</CheckBox>
))}
</div>
</div>
<div className="mt-2.5 flex">
<BorderedButton classes="text-sm px-2 py-1 w-full" onClick={onClickApply}>
Apply
</BorderedButton>
</div>
</div>
</div>
</div>
);
}
function getChainOptionList(): Array<{ value: string; display: string }> {
return [
{ value: '', display: 'All Chains' },
...mainnetAndTestChains.map((c) => ({
value: c.id.toString(),
display: trimToLength(c.name, 12),
})),
];
}

@ -50,12 +50,14 @@ export function MessageSearch() {
}, [isValidInput, sanitizedInput]);
// Filter state and handlers
const [originChainFilter, setOriginChainFilter] = useState('');
const [destinationChainFilter, setDestinationChainFilter] = useState('');
const onChangeOriginFilter = (value: string) => {
const [originChainFilter, setOriginChainFilter] = useState<string | null>(null);
console.log('===originChainFilter');
console.log(originChainFilter);
const [destinationChainFilter, setDestinationChainFilter] = useState<string | null>(null);
const onChangeOriginFilter = (value: string | null) => {
setOriginChainFilter(value);
};
const onChangeDestinationFilter = (value: string) => {
const onChangeDestinationFilter = (value: string | null) => {
setDestinationChainFilter(value);
};
@ -116,7 +118,11 @@ export function MessageSearch() {
);
}
function assembleQuery(searchInput: string, originFilter: string, destFilter: string) {
function assembleQuery(
searchInput: string,
originFilter: string | null,
destFilter: string | null,
) {
const hasInput = !!searchInput;
const originChains = originFilter

@ -17,26 +17,30 @@ export function MessageTable({
return (
<table className="w-full mb-1">
<tr className="border-b border-gray-100">
<th className={`${styles.header} xs:text-left pl-3 sm:pl-6`}>Origin</th>
<th className={`${styles.header} xs:text-left pl-1 sm:pl-2`}>Destination</th>
<th className={`${styles.header} hidden sm:table-cell`}>Sender</th>
<th className={`${styles.header} hidden sm:table-cell`}>Recipient</th>
<th className={styles.header}>Time sent</th>
<th className={`${styles.header} hidden lg:table-cell`}>Duration</th>
<th className={styles.header}>Status</th>
</tr>
{messageList.map((m) => (
<tr
key={`message-${m.id}`}
className={`cursor-pointer hover:bg-gray-100 active:bg-gray-200 border-b border-gray-100 last:border-0 ${
isFetching && 'blur-xs'
} transition-all duration-500`}
onClick={() => router.push(`/message/${m.id}`)}
>
<MessageSummaryRow message={m} />
<thead>
<tr className="border-b border-gray-100">
<th className={`${styles.header} xs:text-left pl-3 sm:pl-6`}>Origin</th>
<th className={`${styles.header} xs:text-left pl-1 sm:pl-2`}>Destination</th>
<th className={`${styles.header} hidden sm:table-cell`}>Sender</th>
<th className={`${styles.header} hidden sm:table-cell`}>Recipient</th>
<th className={styles.header}>Time sent</th>
<th className={`${styles.header} hidden lg:table-cell`}>Duration</th>
<th className={styles.header}>Status</th>
</tr>
))}
</thead>
<tbody>
{messageList.map((m) => (
<tr
key={`message-${m.id}`}
className={`cursor-pointer hover:bg-gray-100 active:bg-gray-200 border-b border-gray-100 last:border-0 ${
isFetching && 'blur-xs'
} transition-all duration-500`}
onClick={() => router.push(`/message/${m.id}`)}
>
<MessageSummaryRow message={m} />
</tr>
))}
</tbody>
</table>
);
}

@ -1,3 +1,30 @@
export function invertKeysAndValues(data: any) {
return Object.fromEntries(Object.entries(data).map(([key, value]) => [value, key]));
}
// Get the subset of the object from key list
export function pick<K extends string | number, V = any>(obj: Record<K, V>, keys: K[]) {
const ret: Partial<Record<K, V>> = {};
for (const key of keys) {
ret[key] = obj[key];
}
return ret as Record<K, V>;
}
// Remove a particular key from an object if it exists
export function omit<K extends string | number, V = any>(obj: Record<K, V>, key: K) {
const ret: Partial<Record<K, V>> = {};
for (const k of Object.keys(obj)) {
if (k === key) continue;
ret[k] = obj[k];
}
return ret as Record<K, V>;
}
// Returns an object with the keys as values from an array and value set to true
export function arrayToObject(keys: Array<string | number>, val = true) {
return keys.reduce((result, k) => {
result[k] = val;
return result;
}, {});
}

Loading…
Cancel
Save