mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-15 04:10:32 +00:00
501 lines
12 KiB
TypeScript
501 lines
12 KiB
TypeScript
import React, { KeyboardEvent, ReactNode } from 'react';
|
|
import intl from 'react-intl-universal';
|
|
import { isUndefined } from 'lodash';
|
|
import {
|
|
Overlay,
|
|
InputGroup,
|
|
Tag,
|
|
MenuItem,
|
|
Spinner,
|
|
Intent,
|
|
OverlayProps,
|
|
Button,
|
|
} from '@blueprintjs/core';
|
|
import { QueryList, ItemRenderer } from '@blueprintjs/select';
|
|
import { x } from '@xstyled/emotion';
|
|
import { css } from '@emotion/css';
|
|
import { Icon, If, FormattedMessage as T } from '@/components';
|
|
import { Select } from '@blueprintjs-formik/select';
|
|
import {
|
|
UniversalSearchProvider,
|
|
useUniversalSearchContext,
|
|
} from './UniversalSearchProvider';
|
|
import { filterItemsByResourceType } from './utils';
|
|
import { RESOURCES_TYPES } from '@/constants/resourcesTypes';
|
|
|
|
// Resource type from RESOURCES_TYPES constant
|
|
type ResourceType = string;
|
|
|
|
// Search type option item
|
|
interface SearchTypeOption {
|
|
key: ResourceType;
|
|
label: string;
|
|
}
|
|
|
|
// Universal search item
|
|
interface UniversalSearchItem {
|
|
id: number | string;
|
|
_type: ResourceType;
|
|
text: string;
|
|
subText?: string;
|
|
label?: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
// CSS styles for complex selectors
|
|
const overlayStyles = css`
|
|
.bp4-overlay-appear,
|
|
.bp4-overlay-enter {
|
|
filter: blur(20px);
|
|
opacity: 0.2;
|
|
}
|
|
.bp4-overlay-appear-active,
|
|
.bp4-overlay-enter-active {
|
|
filter: blur(0);
|
|
opacity: 1;
|
|
transition:
|
|
filter 0.2s cubic-bezier(0.4, 1, 0.75, 0.9),
|
|
opacity 0.2s cubic-bezier(0.4, 1, 0.75, 0.9);
|
|
}
|
|
.bp4-overlay-exit {
|
|
filter: blur(0);
|
|
opacity: 1;
|
|
}
|
|
.bp4-overlay-exit-active {
|
|
filter: blur(20px);
|
|
opacity: 0.2;
|
|
transition:
|
|
filter 0.2s cubic-bezier(0.4, 1, 0.75, 0.9),
|
|
opacity 0.2s cubic-bezier(0.4, 1, 0.75, 0.9);
|
|
}
|
|
`;
|
|
|
|
const containerStyles = css`
|
|
position: fixed;
|
|
filter: blur(0);
|
|
opacity: 1;
|
|
background-color: var(--color-universal-search-background);
|
|
border-radius: 3px;
|
|
box-shadow:
|
|
0 0 0 1px rgba(16, 22, 26, 0.1),
|
|
0 4px 8px rgba(16, 22, 26, 0.2),
|
|
0 18px 46px 6px rgba(16, 22, 26, 0.2);
|
|
left: calc(50% - 250px);
|
|
top: 20vh;
|
|
width: 500px;
|
|
z-index: 20;
|
|
|
|
.bp4-input-group {
|
|
.bp4-icon {
|
|
margin: 16px;
|
|
color: var(--color-universal-search-icon);
|
|
|
|
svg {
|
|
stroke: currentColor;
|
|
fill: none;
|
|
fill-rule: evenodd;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
stroke-width: 2;
|
|
--text-opacity: 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
.bp4-input-group .bp4-input {
|
|
border: 0;
|
|
box-shadow: 0 0 0 0;
|
|
height: 50px;
|
|
line-height: 50px;
|
|
font-size: 20px;
|
|
}
|
|
.bp4-input-group.bp4-large .bp4-input:not(:first-child) {
|
|
padding-left: 50px !important;
|
|
}
|
|
.bp4-input-group.bp4-large .bp4-input:not(:last-child) {
|
|
padding-right: 130px !important;
|
|
}
|
|
|
|
.bp4-menu {
|
|
border-top: 1px solid var(--color-universal-search-menu-border);
|
|
max-height: calc(60vh - 20px);
|
|
overflow: auto;
|
|
|
|
.bp4-menu-item {
|
|
.bp4-text-muted {
|
|
font-size: 12px;
|
|
|
|
.bp4-icon {
|
|
color: var(--bp4-gray-600);
|
|
}
|
|
}
|
|
&.bp4-intent-primary {
|
|
&.bp4-active {
|
|
background-color: var(--bp4-blue-100);
|
|
color: var(--bp4-dark-gray-800);
|
|
|
|
.bp4-menu-item-label {
|
|
color: var(--bp4-gray-600);
|
|
}
|
|
}
|
|
}
|
|
|
|
&-label {
|
|
flex-direction: row;
|
|
text-align: right;
|
|
}
|
|
}
|
|
}
|
|
|
|
.bp4-input-action {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
}
|
|
`;
|
|
|
|
const inputRightElementsStyles = css`
|
|
display: flex;
|
|
margin: 10px;
|
|
|
|
.bp4-spinner {
|
|
margin-right: 6px;
|
|
}
|
|
`;
|
|
|
|
const footerStyles = css`
|
|
padding: 12px 12px;
|
|
border-top: 1px solid var(--color-universal-search-footer-divider);
|
|
`;
|
|
|
|
const actionBaseStyles = css`
|
|
&:not(:first-of-type) {
|
|
margin-left: 14px;
|
|
}
|
|
|
|
.bp4-tag {
|
|
background: var(--color-universal-search-tag-background);
|
|
color: var(--color-universal-search-tag-text);
|
|
}
|
|
`;
|
|
|
|
const actionArrowsStyles = css`
|
|
&:not(:first-of-type) {
|
|
margin-left: 14px;
|
|
}
|
|
|
|
.bp4-tag {
|
|
background: var(--color-universal-search-tag-background);
|
|
color: var(--color-universal-search-tag-text);
|
|
padding: 0;
|
|
text-align: center;
|
|
line-height: 16px;
|
|
margin-left: 4px;
|
|
|
|
svg {
|
|
fill: var(--color-universal-search-tag-text);
|
|
height: 100%;
|
|
display: block;
|
|
width: 100%;
|
|
padding: 2px;
|
|
}
|
|
}
|
|
`;
|
|
|
|
// UniversalSearchInputRightElements props
|
|
interface UniversalSearchInputRightElementsProps {
|
|
/** Callback when search type changes */
|
|
onSearchTypeChange?: (option: SearchTypeOption) => void;
|
|
}
|
|
|
|
/**
|
|
* Universal search input action.
|
|
*/
|
|
function UniversalSearchInputRightElements({
|
|
onSearchTypeChange,
|
|
}: UniversalSearchInputRightElementsProps) {
|
|
const { isLoading, searchType, searchTypeOptions } =
|
|
useUniversalSearchContext();
|
|
|
|
// Find the currently selected item object.
|
|
const selectedItem = searchTypeOptions.find(
|
|
(item) => item.key === searchType,
|
|
);
|
|
|
|
// Handle search type option change.
|
|
const handleSearchTypeChange = (option: SearchTypeOption) => {
|
|
onSearchTypeChange?.(option);
|
|
};
|
|
|
|
// Item renderer for the select dropdown.
|
|
const itemRenderer: ItemRenderer<SearchTypeOption> = (
|
|
item,
|
|
{ handleClick },
|
|
) => {
|
|
return <MenuItem text={item.label} key={item.key} onClick={handleClick} />;
|
|
};
|
|
|
|
return (
|
|
<x.div display="flex" m="10px" className={inputRightElementsStyles}>
|
|
<If condition={isLoading}>
|
|
<Spinner tagName="div" intent={Intent.NONE} size={18} />
|
|
</If>
|
|
|
|
<Select<SearchTypeOption>
|
|
items={searchTypeOptions}
|
|
itemRenderer={itemRenderer}
|
|
onItemSelect={handleSearchTypeChange}
|
|
selectedValue={selectedItem?.key}
|
|
valueAccessor={'key'}
|
|
labelAccessor={'label'}
|
|
filterable={false}
|
|
popoverProps={{
|
|
minimal: true,
|
|
captureDismiss: true,
|
|
}}
|
|
input={({ activeItem }) => (
|
|
<Button minimal={true} text={activeItem?.label} />
|
|
)}
|
|
/>
|
|
</x.div>
|
|
);
|
|
}
|
|
|
|
// QueryList renderer props
|
|
interface QueryListRendererProps {
|
|
/** Current query string */
|
|
query: string;
|
|
/** Callback when query changes */
|
|
handleQueryChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
/** Item list element */
|
|
itemList: ReactNode;
|
|
/** Class name */
|
|
className?: string;
|
|
/** Handle key down */
|
|
handleKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void;
|
|
/** Handle key up */
|
|
handleKeyUp?: (event: KeyboardEvent<HTMLDivElement>) => void;
|
|
}
|
|
|
|
// UniversalSearchQueryList props
|
|
interface UniversalSearchQueryListProps {
|
|
/** Whether the search is open */
|
|
isOpen: boolean;
|
|
/** Whether the search is loading */
|
|
isLoading: boolean;
|
|
/** Callback when search type changes */
|
|
onSearchTypeChange?: (option: SearchTypeOption) => void;
|
|
/** Current search type */
|
|
searchType: ResourceType;
|
|
/** Items to display */
|
|
items: UniversalSearchItem[];
|
|
/** Renderer for items */
|
|
itemRenderer?: ItemRenderer<UniversalSearchItem>;
|
|
/** Callback when an item is selected */
|
|
onItemSelect?: (item: UniversalSearchItem, event?: any) => void;
|
|
/** Current query string */
|
|
query: string;
|
|
/** Callback when query changes */
|
|
onQueryChange?: (query: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Universal search query list.
|
|
*/
|
|
function UniversalSearchQueryList({
|
|
isOpen,
|
|
isLoading,
|
|
onSearchTypeChange,
|
|
...restProps
|
|
}: UniversalSearchQueryListProps) {
|
|
return (
|
|
<QueryList<UniversalSearchItem>
|
|
{...(restProps as any)}
|
|
initialContent={null}
|
|
renderer={(listProps: QueryListRendererProps) => (
|
|
<UniversalSearchBar
|
|
isOpen={isOpen}
|
|
onSearchTypeChange={onSearchTypeChange}
|
|
{...listProps}
|
|
/>
|
|
)}
|
|
noResults={
|
|
!isLoading ? (
|
|
<MenuItem disabled={true} text={<T id={'no_results'} />} />
|
|
) : (
|
|
<MenuItem
|
|
disabled={true}
|
|
text={<T id={'universal_search.loading'} />}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Universal query search actions.
|
|
*/
|
|
function UniversalQuerySearchActions() {
|
|
return (
|
|
<x.div display="flex">
|
|
<x.div className={actionBaseStyles}>
|
|
<Tag>ENTER</Tag>
|
|
<x.span ml="6px">{intl.get('universal_search.enter_text')}</x.span>
|
|
</x.div>
|
|
|
|
<x.div className={actionBaseStyles}>
|
|
<Tag>ESC</Tag>{' '}
|
|
<x.span ml="6px">{intl.get('universal_search.close_text')}</x.span>
|
|
</x.div>
|
|
|
|
<x.div className={actionArrowsStyles}>
|
|
<Tag>
|
|
<Icon icon={'arrow-up-24'} iconSize={16} />
|
|
</Tag>
|
|
<Tag>
|
|
<Icon icon={'arrow-down-24'} iconSize={16} />
|
|
</Tag>
|
|
<x.span ml="6px">{intl.get('universal_seach.navigate_text')}</x.span>
|
|
</x.div>
|
|
</x.div>
|
|
);
|
|
}
|
|
|
|
// UniversalSearchBar props
|
|
interface UniversalSearchBarProps extends QueryListRendererProps {
|
|
/** Whether the search is open */
|
|
isOpen: boolean;
|
|
/** Callback when search type changes */
|
|
onSearchTypeChange?: (option: SearchTypeOption) => void;
|
|
}
|
|
|
|
/**
|
|
* Universal search input bar with items list.
|
|
*/
|
|
function UniversalSearchBar({
|
|
isOpen,
|
|
onSearchTypeChange,
|
|
...listProps
|
|
}: UniversalSearchBarProps) {
|
|
const { handleKeyDown, handleKeyUp } = listProps;
|
|
const handlers = isOpen
|
|
? { onKeyDown: handleKeyDown, onKeyUp: handleKeyUp }
|
|
: {};
|
|
|
|
return (
|
|
<x.div {...handlers}>
|
|
<InputGroup
|
|
large={true}
|
|
leftIcon={<Icon icon={'universal-search'} iconSize={20} />}
|
|
placeholder={intl.get('universal_search.placeholder')}
|
|
onChange={listProps.handleQueryChange}
|
|
value={listProps.query}
|
|
rightElement={
|
|
<UniversalSearchInputRightElements
|
|
onSearchTypeChange={onSearchTypeChange}
|
|
/>
|
|
}
|
|
autoFocus={true}
|
|
/>
|
|
{listProps.itemList}
|
|
</x.div>
|
|
);
|
|
}
|
|
|
|
// UniversalSearch props
|
|
export interface UniversalSearchProps {
|
|
/** Default search resource type */
|
|
defaultSearchResource?: ResourceType;
|
|
/** Controlled search resource type */
|
|
searchResource?: ResourceType;
|
|
/** Overlay props */
|
|
overlayProps?: OverlayProps;
|
|
/** Whether the search overlay is open */
|
|
isOpen: boolean;
|
|
/** Whether the search is loading */
|
|
isLoading: boolean;
|
|
/** Callback when search type changes */
|
|
onSearchTypeChange?: (resource: SearchTypeOption) => void;
|
|
/** Items to display */
|
|
items: UniversalSearchItem[];
|
|
/** Available search type options */
|
|
searchTypeOptions: SearchTypeOption[];
|
|
/** Renderer for items */
|
|
itemRenderer?: ItemRenderer<UniversalSearchItem>;
|
|
/** Callback when an item is selected */
|
|
onItemSelect?: (item: UniversalSearchItem, event?: any) => void;
|
|
/** Current query string */
|
|
query: string;
|
|
/** Callback when query changes */
|
|
onQueryChange?: (query: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Universal search.
|
|
*/
|
|
export function UniversalSearch({
|
|
defaultSearchResource,
|
|
searchResource,
|
|
overlayProps,
|
|
isOpen,
|
|
isLoading,
|
|
onSearchTypeChange,
|
|
items,
|
|
searchTypeOptions,
|
|
...queryListProps
|
|
}: UniversalSearchProps) {
|
|
// Search type state.
|
|
const [searchType, setSearchType] = React.useState<ResourceType>(
|
|
defaultSearchResource || RESOURCES_TYPES.CUSTOMER,
|
|
);
|
|
// Handle search resource type controlled mode.
|
|
React.useEffect(() => {
|
|
if (
|
|
!isUndefined(searchResource) &&
|
|
searchResource !== defaultSearchResource
|
|
) {
|
|
setSearchType(searchResource);
|
|
}
|
|
}, [searchResource, defaultSearchResource]);
|
|
|
|
// Handle search type change.
|
|
const handleSearchTypeChange = (searchTypeResource: SearchTypeOption) => {
|
|
setSearchType(searchTypeResource.key);
|
|
onSearchTypeChange?.(searchTypeResource);
|
|
};
|
|
// Filters query list items based on the given search type.
|
|
const filteredItems = filterItemsByResourceType(items, searchType);
|
|
|
|
return (
|
|
<Overlay
|
|
hasBackdrop={true}
|
|
isOpen={isOpen}
|
|
className={overlayStyles}
|
|
{...overlayProps}
|
|
>
|
|
<UniversalSearchProvider
|
|
isLoading={isLoading}
|
|
searchType={searchType}
|
|
defaultSearchResource={defaultSearchResource}
|
|
searchTypeOptions={searchTypeOptions}
|
|
>
|
|
<x.div className={containerStyles}>
|
|
<UniversalSearchQueryList
|
|
isOpen={isOpen}
|
|
isLoading={isLoading}
|
|
searchType={searchType}
|
|
onSearchTypeChange={handleSearchTypeChange}
|
|
{...queryListProps}
|
|
items={filteredItems}
|
|
/>
|
|
<x.div className={footerStyles}>
|
|
<UniversalQuerySearchActions />
|
|
</x.div>
|
|
</x.div>
|
|
</UniversalSearchProvider>
|
|
</Overlay>
|
|
);
|
|
}
|