diff --git a/package-lock.json b/package-lock.json index 28a6d7105..bb371bffb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.13.0-pre-4", + "version": "1.13.0-pre-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.13.0-pre-4", + "version": "1.13.0-pre-5", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 94f491c82..58d6c1379 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.13.0-pre-4", + "version": "1.13.0-pre-5", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", diff --git a/src/Assets/IconV2/ic-chat-circle-online.svg b/src/Assets/IconV2/ic-chat-circle-online.svg new file mode 100644 index 000000000..77560f3b4 --- /dev/null +++ b/src/Assets/IconV2/ic-chat-circle-online.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Assets/IconV2/ic-discord-fill.svg b/src/Assets/IconV2/ic-discord-fill.svg new file mode 100644 index 000000000..2e3bc9aea --- /dev/null +++ b/src/Assets/IconV2/ic-discord-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Assets/IconV2/ic-edit.svg b/src/Assets/IconV2/ic-edit.svg new file mode 100644 index 000000000..2f3fcf52e --- /dev/null +++ b/src/Assets/IconV2/ic-edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Assets/IconV2/ic-file-edit.svg b/src/Assets/IconV2/ic-file-edit.svg new file mode 100644 index 000000000..a4fa313ce --- /dev/null +++ b/src/Assets/IconV2/ic-file-edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Assets/IconV2/ic-file.svg b/src/Assets/IconV2/ic-file.svg new file mode 100644 index 000000000..8aec25d57 --- /dev/null +++ b/src/Assets/IconV2/ic-file.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Assets/IconV2/ic-files.svg b/src/Assets/IconV2/ic-files.svg new file mode 100644 index 000000000..b1ba9f862 --- /dev/null +++ b/src/Assets/IconV2/ic-files.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Assets/IconV2/ic-megaphone-right.svg b/src/Assets/IconV2/ic-megaphone-right.svg new file mode 100644 index 000000000..7d9a151f4 --- /dev/null +++ b/src/Assets/IconV2/ic-megaphone-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Assets/IconV2/ic-path.svg b/src/Assets/IconV2/ic-path.svg new file mode 100644 index 000000000..5bb3ef197 --- /dev/null +++ b/src/Assets/IconV2/ic-path.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Shared/Components/ActionMenu/ActionMenu.component.tsx b/src/Shared/Components/ActionMenu/ActionMenu.component.tsx index 79e4f97b8..938301e16 100644 --- a/src/Shared/Components/ActionMenu/ActionMenu.component.tsx +++ b/src/Shared/Components/ActionMenu/ActionMenu.component.tsx @@ -1,56 +1,142 @@ -import PopupMenu from '@Common/PopupMenu' +import { MutableRefObject } from 'react' -import ActionMenuOption from './ActionMenuOption' -import { ActionMenuProps } from './types' +import { CustomInput } from '../CustomInput' +import { Popover } from '../Popover' +import { SelectPickerMenuListFooter } from '../SelectPicker/common' +import { ActionMenuItem } from './ActionMenuItem' +import { ActionMenuItemType, ActionMenuProps } from './types' +import { useActionMenu } from './useActionMenu.hook' import './actionMenu.scss' -const ActionMenu = ({ options, disableDescriptionEllipsis, children, onClick }: ActionMenuProps) => ( - - - {/* TODO: fix the issue with immediate button child */} - {children} - - -
- {options.length > 0 - ? options.map((groupOrOption) => - 'options' in groupOrOption ? ( -
-

- {groupOrOption.label} -

- {/* Added this to contain the options in a container and have gap only b/w heading & container */} -
- {groupOrOption.options.length > 0 ? ( - groupOrOption.options.map((option) => ( - - )) - ) : ( -

- No options in group -

- )} -
-
- ) : ( - - ), - ) - : 'No Options'} -
-
-
-) +export const ActionMenu = ({ + id, + options, + onClick, + position, + alignment, + width, + isSearchable, + disableDescriptionEllipsis, + buttonProps, + children, + onOpen, + footerConfig, +}: ActionMenuProps) => { + // HOOKS + const { + open, + filteredOptions, + flatOptions, + triggerProps, + overlayProps, + popoverProps, + focusedIndex, + searchTerm, + handleSearch, + itemsRef, + setFocusedIndex, + closePopover, + scrollableRef, + } = useActionMenu({ + id, + options, + position, + alignment, + width, + isSearchable, + onOpen, + }) + + // HANDLERS + const handleOptionMouseEnter = (index: number) => () => setFocusedIndex(index) + + const handleOptionOnClick = (item: ActionMenuItemType) => () => { + onClick(item) + closePopover() + } -export default ActionMenu + return ( + +
+ {isSearchable && ( +
+ +
+ )} +
    } + role="menu" + className="action-menu m-0 p-0 flex-grow-1 dc__overflow-auto dc__overscroll-none" + > + {filteredOptions.length > 0 ? ( + filteredOptions.map((option, sectionIndex) => ( +
  • + {option.groupLabel && ( +

    + {option.groupLabel} +

    + )} + {option.items.length > 0 ? ( +
      + {option.items.map((item, itemIndex) => { + const index = flatOptions.findIndex( + (flatOption) => + flatOption.sectionIndex === sectionIndex && + flatOption.itemIndex === itemIndex, + ) + + return ( + + key={`${item.label}-${item.id}`} + item={item} + itemRef={itemsRef.current[index]} + isFocused={index === focusedIndex} + onMouseEnter={handleOptionMouseEnter(index)} + onClick={handleOptionOnClick(item)} + disableDescriptionEllipsis={disableDescriptionEllipsis} + /> + ) + })} +
    + ) : ( +

    No options in this group

    + )} +
  • + )) + ) : ( +
  • +

    No options

    +
  • + )} +
+ {footerConfig && ( +
+ +
+ )} +
+
+ ) +} diff --git a/src/Shared/Components/ActionMenu/ActionMenuItem.tsx b/src/Shared/Components/ActionMenu/ActionMenuItem.tsx new file mode 100644 index 000000000..636e3d464 --- /dev/null +++ b/src/Shared/Components/ActionMenu/ActionMenuItem.tsx @@ -0,0 +1,124 @@ +import { LegacyRef, Ref } from 'react' +import { Link } from 'react-router-dom' + +import { Tooltip } from '@Common/Tooltip' + +import { Icon } from '../Icon' +import { getTooltipProps } from '../SelectPicker/common' +import { ActionMenuItemProps } from './types' + +export const ActionMenuItem = ({ + item, + itemRef, + isFocused, + onClick, + onMouseEnter, + disableDescriptionEllipsis = false, +}: ActionMenuItemProps) => { + const { + id, + description, + label, + startIcon, + endIcon, + tooltipProps, + type = 'neutral', + isDisabled, + componentType = 'button', + } = item + + // REFS + const ref: LegacyRef = (el) => { + if (isFocused && el) { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + } + } + + // CONSTANTS + const isNegativeType = type === 'negative' + + // HANDLERS + const handleClick = () => { + onClick(item) + } + + // RENDERERS + const renderIcon = (iconProps: typeof startIcon) => + iconProps && ( +
+ +
+ ) + + const renderContent = () => ( + <> + + {label} + + {description && + (typeof description === 'string' ? ( + + {description} + + ) : ( + description + ))} + + ) + + const renderComponent = () => { + switch (componentType) { + case 'anchor': + return ( + } + className="flex-grow-1" + href={item.href} + target="_blank" + rel="noreferrer" + > + {renderContent()} + + ) + case 'link': + return ( + } className="flex-grow-1" to={item.to}> + {renderContent()} + + ) + case 'button': + default: + return ( + + ) + } + } + + return ( + +
  • + {renderIcon(startIcon)} + {renderComponent()} + {renderIcon(endIcon)} +
  • +
    + ) +} diff --git a/src/Shared/Components/ActionMenu/ActionMenuOption.tsx b/src/Shared/Components/ActionMenu/ActionMenuOption.tsx deleted file mode 100644 index 3d313888a..000000000 --- a/src/Shared/Components/ActionMenu/ActionMenuOption.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Tooltip } from '@Common/Tooltip' - -import { getTooltipProps } from '../SelectPicker/common' -import { ActionMenuOptionProps } from './types' - -const ActionMenuOption = ({ option, onClick, disableDescriptionEllipsis }: ActionMenuOptionProps) => { - const iconBaseClass = 'dc__no-shrink icon-dim-16 flex dc__fill-available-space' - const { description, label, startIcon, endIcon, tooltipProps, type = 'neutral', isDisabled } = option - const isNegativeType = type === 'negative' - - const handleClick = () => { - onClick(option) - } - - return ( - -
    - {startIcon &&
    {startIcon}
    } -
    - -

    - {label} -

    -
    - {description && - (typeof description === 'string' ? ( -

    - {description} -

    - ) : ( -
    {description}
    - ))} -
    - {endIcon &&
    {endIcon}
    } -
    -
    - ) -} - -export default ActionMenuOption diff --git a/src/Shared/Components/ActionMenu/actionMenu.scss b/src/Shared/Components/ActionMenu/actionMenu.scss index 9f8cc2636..127e110d6 100644 --- a/src/Shared/Components/ActionMenu/actionMenu.scss +++ b/src/Shared/Components/ActionMenu/actionMenu.scss @@ -1,20 +1,35 @@ .action-menu { + list-style: none; + &__group { border-top: 1px solid var(--border-secondary-translucent); &:first-child { border-top: none; - padding-top: 0; } + } - &:last-child { - padding-bottom: 0; - } + &__group-list { + list-style: none; } &__option { - & + .action-menu__group { - margin-top: 4px; + &--focused { + background-color: var(--bg-hover); + } + + &--focused-negative { + background-color: var(--R50); } + + > button { + text-align: left; + } + } + + &__searchbox input { + border: none; + padding: 8px 12px; + background-color: transparent; } } diff --git a/src/Shared/Components/ActionMenu/index.ts b/src/Shared/Components/ActionMenu/index.ts index 6f5c24f85..0a12416a8 100644 --- a/src/Shared/Components/ActionMenu/index.ts +++ b/src/Shared/Components/ActionMenu/index.ts @@ -1,2 +1,2 @@ -export { default as ActionMenu } from './ActionMenu.component' -export type { ActionMenuProps } from './types' +export * from './ActionMenu.component' +export type { ActionMenuItemType, ActionMenuOptionType, ActionMenuProps } from './types' diff --git a/src/Shared/Components/ActionMenu/types.ts b/src/Shared/Components/ActionMenu/types.ts index 45f74e5b4..599212c4f 100644 --- a/src/Shared/Components/ActionMenu/types.ts +++ b/src/Shared/Components/ActionMenu/types.ts @@ -1,22 +1,119 @@ -import { ReactElement } from 'react' -import { GroupBase, OptionsOrGroups } from 'react-select' +import { LegacyRef, Ref } from 'react' +import { LinkProps } from 'react-router-dom' +import { IconsProps } from '../Icon' +import { PopoverProps, UsePopoverProps } from '../Popover' import { SelectPickerOptionType, SelectPickerProps } from '../SelectPicker' -type ActionMenuOptionType = SelectPickerOptionType & { +type ConditionalActionMenuComponentType = + | { + /** + * @default 'button' + */ + componentType?: 'button' + href?: never + to?: never + } + | { + componentType?: 'anchor' + /** Specifies the URL for the `` tag. */ + href: string + to?: never + } + | { + componentType?: 'link' + /** Specifies the `to` property for react-router `Link` */ + to: LinkProps['to'] + href?: never + } + +type ActionMenuItemIconType = Pick & { + /** @default 'N800' */ + color?: IconsProps['color'] +} + +export type ActionMenuItemType = Omit< + SelectPickerOptionType, + 'label' | 'value' | 'endIcon' | 'startIcon' +> & { + /** A unique identifier for the action menu item. */ + id: T + /** The text label for the menu item. */ + label: string + /** Indicates whether the menu item is disabled. */ isDisabled?: boolean /** + * Specifies the type of the menu item. * @default 'neutral' */ type?: 'neutral' | 'negative' + /** Defines the icon to be displayed at the start of the menu item. */ + startIcon?: ActionMenuItemIconType + /** Defines the icon to be displayed at the end of the menu item. */ + endIcon?: ActionMenuItemIconType +} & ConditionalActionMenuComponentType + +export type ActionMenuOptionType = { + /** + * The label for the group of menu items. \ + * This is optional and can be used to categorize items under a specific group. + */ + groupLabel?: string + /** + * The list of items belonging to this group. + */ + items: ActionMenuItemType[] } -export interface ActionMenuProps extends Pick { - children: ReactElement - options: OptionsOrGroups> - onClick: (option: SelectPickerOptionType) => void +export type UseActionMenuProps = Omit< + UsePopoverProps, + 'onPopoverKeyDown' | 'onTriggerKeyDown' +> & { + /** + * The options to display in the action menu. + */ + options: ActionMenuOptionType[] + /** + * Determines whether the action menu is searchable. + */ + isSearchable?: boolean } -export interface ActionMenuOptionProps extends Pick { - option: ActionMenuOptionType +export type ActionMenuProps = UseActionMenuProps & + Pick & { + /** + * Callback function triggered when an item is clicked. + * @param item - The selected item. + */ + onClick: (item: ActionMenuItemType) => void + /** + * Config for the footer at the bottom of action menu list. It is sticky by default + */ + footerConfig?: SelectPickerProps['menuListFooterConfig'] + } & ( + | { + /** + * The React element to which the ActionMenu is attached. + * @note only use when children is not `Button` component otherwise use `buttonProps`. + */ + children: NonNullable + buttonProps?: never + } + | { + children?: never + /** + * Properties for the button to which the Popover is attached. + */ + buttonProps: NonNullable + } + ) + +export type ActionMenuItemProps = Pick< + ActionMenuProps, + 'onClick' | 'disableDescriptionEllipsis' +> & { + item: ActionMenuItemType + itemRef: Ref | LegacyRef + isFocused?: boolean + onMouseEnter?: () => void } diff --git a/src/Shared/Components/ActionMenu/useActionMenu.hook.ts b/src/Shared/Components/ActionMenu/useActionMenu.hook.ts new file mode 100644 index 000000000..4898d836d --- /dev/null +++ b/src/Shared/Components/ActionMenu/useActionMenu.hook.ts @@ -0,0 +1,128 @@ +import { ChangeEvent, createRef, RefObject, useEffect, useMemo, useRef, useState } from 'react' + +import { usePopover, UsePopoverProps } from '../Popover' +import { UseActionMenuProps } from './types' +import { filterActionMenuOptions, getActionMenuFlatOptions } from './utils' + +export const useActionMenu = ({ + id, + position = 'bottom', + alignment = 'start', + width = 'auto', + options, + isSearchable, + onOpen, +}: UseActionMenuProps) => { + // STATES + const [focusedIndex, setFocusedIndex] = useState(-1) + const [searchTerm, setSearchTerm] = useState('') + + // MEMOIZED CONSTANTS + const filteredOptions = useMemo( + () => (isSearchable ? filterActionMenuOptions(options, searchTerm) : options), + [isSearchable, JSON.stringify(options), searchTerm], + ) + + const flatOptions = useMemo(() => getActionMenuFlatOptions(filteredOptions), [filteredOptions]) + + // REFS + const itemsRef = useRef[]>( + flatOptions.map(() => createRef()), + ) + + useEffect(() => { + itemsRef.current = flatOptions.map(() => createRef()) + }, [flatOptions.length]) + + // HANDLERS + const handleSearch = (e: ChangeEvent) => { + setSearchTerm(e.target.value) + } + + const getNextIndex = (start: number, arrowDirection: 1 | -1) => { + let index = start + const totalOptions = flatOptions.length + for (let i = 0; i < totalOptions; i++) { + index = (index + arrowDirection + totalOptions) % totalOptions + if (!flatOptions[index]?.option?.isDisabled) { + return index + } + } + return start + } + + const handlePopoverKeyDown: UsePopoverProps['onPopoverKeyDown'] = (e, openState, closePopover) => { + e.stopPropagation() + + if (openState) { + switch (e.key) { + case 'Escape': + e.preventDefault() + closePopover() + break + case 'ArrowDown': + e.preventDefault() + setFocusedIndex((i) => getNextIndex(i, 1)) + break + case 'ArrowUp': + e.preventDefault() + setFocusedIndex((i) => getNextIndex(i, -1)) + break + case 'Enter': + case ' ': { + e.preventDefault() + const selectedItem = flatOptions[focusedIndex].option + const selectedItemRef = itemsRef.current[focusedIndex].current + if (!selectedItem.isDisabled && selectedItemRef) { + selectedItemRef.click() + } + break + } + default: + } + } + } + + const handleTriggerKeyDown: UsePopoverProps['onTriggerKeyDown'] = (e, openState, closePopover) => { + if (!openState && (e.key === 'Enter' || e.key === ' ')) { + setFocusedIndex(0) + } + + handlePopoverKeyDown(e, openState, closePopover) + } + + // POPOVER HOOK + const { open, closePopover, overlayProps, popoverProps, triggerProps, scrollableRef } = usePopover({ + id, + position, + alignment, + width, + onOpen, + onPopoverKeyDown: handlePopoverKeyDown, + onTriggerKeyDown: handleTriggerKeyDown, + }) + + // CLEANING UP STATES AFTER ACTION MENU CLOSE + useEffect(() => { + if (!open) { + setFocusedIndex(-1) + setSearchTerm('') + } + }, [open]) + + return { + open, + flatOptions, + filteredOptions, + focusedIndex, + itemsRef, + triggerProps, + overlayProps, + popoverProps, + setFocusedIndex, + closePopover, + searchTerm, + handleSearch, + scrollableRef, + } +} diff --git a/src/Shared/Components/ActionMenu/utils.ts b/src/Shared/Components/ActionMenu/utils.ts new file mode 100644 index 000000000..c2230e9f2 --- /dev/null +++ b/src/Shared/Components/ActionMenu/utils.ts @@ -0,0 +1,34 @@ +import { UseActionMenuProps } from './types' + +export const getActionMenuFlatOptions = (options: UseActionMenuProps['options']) => + options.flatMap( + (option, sectionIndex) => + option.items.map((groupOption, itemIndex) => ({ + option: groupOption, + itemIndex, + sectionIndex, + })), + [], + ) + +const normalize = (str: string) => str.toLowerCase() + +const fuzzyMatch = (text: string, term: string) => normalize(text).includes(term) + +export const filterActionMenuOptions = ( + options: UseActionMenuProps['options'], + searchTerm: string, +) => { + if (!searchTerm) { + return options + } + + const target = normalize(searchTerm) + + return options + .map((option) => { + const filteredItems = option.items.filter((item) => fuzzyMatch(item.label, target)) + return filteredItems.length > 0 ? { ...option, items: filteredItems } : null + }) + .filter(Boolean) +} diff --git a/src/Shared/Components/Header/HeaderWithCreateButton/HeaderWithCreateButon.tsx b/src/Shared/Components/Header/HeaderWithCreateButton/HeaderWithCreateButon.tsx index 811226972..0b503f3d2 100644 --- a/src/Shared/Components/Header/HeaderWithCreateButton/HeaderWithCreateButon.tsx +++ b/src/Shared/Components/Header/HeaderWithCreateButton/HeaderWithCreateButon.tsx @@ -14,122 +14,57 @@ * limitations under the License. */ -import { useState } from 'react' -import { useHistory, useLocation, useParams } from 'react-router-dom' +import { useLocation, useParams } from 'react-router-dom' -import { ReactComponent as AddIcon } from '@Icons/ic-add.svg' -import { ReactComponent as DropDown } from '@Icons/ic-caret-down-small.svg' -import { ReactComponent as ChartIcon } from '@Icons/ic-charts.svg' -import { ReactComponent as JobIcon } from '@Icons/ic-k8s-job.svg' +import { SERVER_MODE, URLS } from '@Common/Constants' +import { noop } from '@Common/Helper' +import { ActionMenu } from '@Shared/Components/ActionMenu' +import { ButtonComponentType } from '@Shared/Components/Button' import Button from '@Shared/Components/Button/Button.component' +import { Icon } from '@Shared/Components/Icon' +import { AppListConstants, ComponentSizeType } from '@Shared/constants' +import { useMainContext } from '@Shared/Providers' -import { Modal, SERVER_MODE, URLS } from '../../../../Common' -import { AppListConstants, ComponentSizeType } from '../../../constants' -import { useMainContext } from '../../../Providers' import PageHeader from '../PageHeader' -import { getIsShowingLicenseData } from '../utils' - -import './HeaderWithCreateButton.scss' - -export interface HeaderWithCreateButtonProps { - headerName: string -} +import { HeaderWithCreateButtonProps } from './types' +import { getCreateActionMenuOptions } from './utils' export const HeaderWithCreateButton = ({ headerName }: HeaderWithCreateButtonProps) => { + // HOOKS + const { serverMode } = useMainContext() const params = useParams<{ appType: string }>() - const history = useHistory() const location = useLocation() - const { serverMode, licenseData } = useMainContext() - const [showCreateSelectionModal, setShowCreateSelectionModal] = useState(false) - - const showingLicenseBar = getIsShowingLicenseData(licenseData) - - const handleCreateButton = () => { - setShowCreateSelectionModal((prevState) => !prevState) - } - const redirectToHelmAppDiscover = () => { - history.push(URLS.CHARTS_DISCOVER) - } - - const openCreateDevtronAppModel = () => { - const _urlPrefix = `${URLS.APP}/${URLS.APP_LIST}/${params.appType ?? AppListConstants.AppType.DEVTRON_APPS}` - history.push(`${_urlPrefix}/${AppListConstants.CREATE_DEVTRON_APP_URL}${location.search}`) - } - - const openCreateJobModel = () => { - history.push(`${URLS.JOB}/${URLS.APP_LIST}/${URLS.CREATE_JOB}`) - } + // CONSTANTS + const createCustomAppURL = `${URLS.APP}/${URLS.APP_LIST}/${params.appType ?? AppListConstants.AppType.DEVTRON_APPS}/${AppListConstants.CREATE_DEVTRON_APP_URL}${location.search}` const renderActionButtons = () => serverMode === SERVER_MODE.FULL ? ( - + + {isEnterprise && ( + + This button is hidden on UI (opening type-form via ref) + + )} + + ) +} diff --git a/src/Shared/Components/Header/HelpNav.tsx b/src/Shared/Components/Header/HelpNav.tsx deleted file mode 100644 index 0916f53f9..000000000 --- a/src/Shared/Components/Header/HelpNav.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Fragment } from 'react' -import ReactGA from 'react-ga4' -import { NavLink } from 'react-router-dom' -import { SliderButton } from '@typeform/embed-react' - -import { ReactComponent as Feedback } from '../../../Assets/Icon/ic-feedback.svg' -import { ReactComponent as GettingStartedIcon } from '../../../Assets/Icon/ic-onboarding.svg' -import { stopPropagation, URLS } from '../../../Common' -import { useMainContext } from '../../Providers' -import { Icon } from '../Icon' -import { HelpNavType, HelpOptionType, InstallationType } from './types' -import { getHelpOptions } from './utils' - -const HelpNav = ({ - className, - setShowHelpCard, - serverInfo, - fetchingServerInfo, - setGettingStartedClicked, - showHelpCard, -}: HelpNavType) => { - const { currentServerInfo, handleOpenLicenseInfoDialog, licenseData } = useMainContext() - const isEnterprise = currentServerInfo?.serverInfo?.installationType === InstallationType.ENTERPRISE - const isTrial = licenseData?.isTrial ?? false - const FEEDBACK_FORM_ID = `UheGN3KJ#source=${window.location.hostname}` - - const CommonHelpOptions: HelpOptionType[] = getHelpOptions(isEnterprise, isTrial) - - const onClickGettingStarted = (): void => { - setGettingStartedClicked(true) - } - - const onClickHelpOptions = (option: HelpOptionType): void => { - ReactGA.event({ - category: 'Help Nav', - action: `${option.name} Clicked`, - }) - } - - const toggleHelpCard = (): void => { - setShowHelpCard(!showHelpCard) - } - - const renderHelpFeedback = (): JSX.Element => ( -
    - - - Give feedback - -
    - ) - - const handleHelpOptions = (e) => { - const option = CommonHelpOptions[e.currentTarget.dataset.index] - onClickHelpOptions(option) - } - - const handleOpenAboutDevtron = () => { - ReactGA.event({ - category: 'help-nav__about-devtron', - action: 'ABOUT_DEVTRON_CLICKED', - }) - handleOpenLicenseInfoDialog() - } - - const renderHelpOptions = (): JSX.Element => ( - <> - {CommonHelpOptions.map((option, index) => ( - -
    - -
    {option.name}
    -
    - {index === 1 && ( - <> - - {isEnterprise && ( -
    - Enterprise Support -
    - )} - - )} - - ))} - - ) - - return ( -
    -
    - {!window._env_.K8S_CLIENT && ( - - -
    - Getting started -
    -
    - )} - {renderHelpOptions()} - {isEnterprise && renderHelpFeedback()} - {serverInfo?.installationType === InstallationType.OSS_HELM && ( -
    - {fetchingServerInfo ? ( - Checking current version - ) : ( - version {serverInfo?.currentVersion || ''} - )} -
    - Check for Updates -
    - )} -
    -
    - ) -} - -export default HelpNav diff --git a/src/Shared/Components/Header/PageHeader.tsx b/src/Shared/Components/Header/PageHeader.tsx index 6ad4ca278..e30dfdedb 100644 --- a/src/Shared/Components/Header/PageHeader.tsx +++ b/src/Shared/Components/Header/PageHeader.tsx @@ -20,7 +20,6 @@ import Tippy from '@tippyjs/react' import { ReactComponent as ICCaretDownSmall } from '@Icons/ic-caret-down-small.svg' import { ReactComponent as Close } from '@Icons/ic-close.svg' -import { ReactComponent as Question } from '@Icons/ic-help-outline.svg' import { ReactComponent as ICMediumPaintBucket } from '@IconsV2/ic-medium-paintbucket.svg' import { getAlphabetIcon, TippyCustomized, TippyTheme } from '../../../Common' @@ -29,7 +28,7 @@ import { useMainContext, useTheme, useUserEmail } from '../../Providers' import GettingStartedCard from '../GettingStartedCard/GettingStarted' import { InfoIconTippy } from '../InfoIconTippy' import LogoutCard from '../LogoutCard' -import HelpNav from './HelpNav' +import { HelpButton } from './HelpButton' import { IframePromoButton } from './IframePromoButton' import { getServerInfo } from './service' import { InstallationType, PageHeaderType, ServerInfo } from './types' @@ -50,19 +49,12 @@ const PageHeader = ({ markAsBeta, tippyProps, }: PageHeaderType) => { - const { - loginCount, - setLoginCount, - showGettingStartedCard, - setShowGettingStartedCard, - setGettingStartedClicked, - licenseData, - } = useMainContext() + const { loginCount, setLoginCount, showGettingStartedCard, setShowGettingStartedCard, licenseData } = + useMainContext() const { showSwitchThemeLocationTippy, handleShowSwitchThemeLocationTippyChange } = useTheme() const { isTippyCustomized, tippyRedirectLink, TippyIcon, tippyMessage, onClickTippyButton, additionalContent } = tippyProps || {} - const [showHelpCard, setShowHelpCard] = useState(false) const [showLogOutCard, setShowLogOutCard] = useState(false) const { email } = useUserEmail() const [currentServerInfo, setCurrentServerInfo] = useState<{ serverInfo: ServerInfo; fetchingServerInfo: boolean }>( @@ -108,24 +100,22 @@ const PageHeader = ({ const onClickLogoutButton = () => { handleCloseSwitchThemeLocationTippyChange() setShowLogOutCard(!showLogOutCard) - if (showHelpCard) { - setShowHelpCard(false) - } setActionWithExpiry('clickedOkay', 1) hideGettingStartedCard() } - const onClickHelp = async () => { + const onHelpButtonClick = async () => { if ( !window._env_.K8S_CLIENT && currentServerInfo.serverInfo?.installationType !== InstallationType.ENTERPRISE ) { await getCurrentServerInfo() } - setShowHelpCard(!showHelpCard) + if (showLogOutCard) { setShowLogOutCard(false) } + setActionWithExpiry('clickedOkay', 1) hideGettingStartedCard() await handlePostHogEventUpdate(POSTHOG_EVENT_ONBOARDING.HELP) @@ -144,18 +134,11 @@ const PageHeader = ({ const renderLogoutHelpSection = () => ( <> -
    - - - - - Help - - -
    + {!window._env_.K8S_CLIENT && ( {showTabs && renderHeaderTabs()} - {showHelpCard && ( - - )} {!window._env_.K8S_CLIENT && showGettingStartedCard && loginCount >= 0 && diff --git a/src/Shared/Components/Header/constants.ts b/src/Shared/Components/Header/constants.ts index e4b2ac646..0d0263c34 100644 --- a/src/Shared/Components/Header/constants.ts +++ b/src/Shared/Components/Header/constants.ts @@ -14,45 +14,89 @@ * limitations under the License. */ +import { DISCORD_LINK, DOCUMENTATION_HOME_PAGE, URLS } from '@Common/Constants' import { CONTACT_SUPPORT_LINK, OPEN_NEW_TICKET, RAISE_ISSUE, VIEW_ALL_TICKETS } from '@Shared/constants' -import { ReactComponent as Chat } from '../../../Assets/Icon/ic-chat-circle-dots.svg' -import { ReactComponent as EditFile } from '../../../Assets/Icon/ic-edit-file.svg' -import { ReactComponent as Files } from '../../../Assets/Icon/ic-files.svg' -import { DISCORD_LINK } from '../../../Common' -import { HelpOptionType } from './types' +import { HelpButtonActionMenuProps, HelpMenuItems } from './types' -export const EnterpriseHelpOptions: HelpOptionType[] = [ +export const COMMON_HELP_ACTION_MENU_ITEMS: HelpButtonActionMenuProps['options'][number]['items'] = [ + ...((!window._env_?.K8S_CLIENT + ? [ + { + id: HelpMenuItems.GETTING_STARTED, + label: 'Getting started', + startIcon: { name: 'ic-path' }, + componentType: 'link', + to: `/${URLS.GETTING_STARTED}`, + }, + ] + : []) satisfies HelpButtonActionMenuProps['options'][number]['items']), { - name: 'Open new ticket', - link: OPEN_NEW_TICKET, - icon: EditFile, + id: HelpMenuItems.VIEW_DOCUMENTATION, + label: 'View documentation', + startIcon: { name: 'ic-book-open' }, + componentType: 'anchor', + href: DOCUMENTATION_HOME_PAGE, }, { - name: 'View all tickets', - link: VIEW_ALL_TICKETS, - icon: Files, + id: HelpMenuItems.JOIN_DISCORD_COMMUNITY, + label: 'Join discord community', + startIcon: { name: 'ic-discord-fill' }, + componentType: 'anchor', + href: DISCORD_LINK, + }, + { + id: HelpMenuItems.ABOUT_DEVTRON, + label: 'About Devtron', + startIcon: { name: 'ic-devtron' }, }, ] -export const OSSHelpOptions: HelpOptionType[] = [ +export const OSS_HELP_ACTION_MENU_ITEMS: HelpButtonActionMenuProps['options'][number]['items'] = [ { - name: 'Chat with support', - link: DISCORD_LINK, - icon: Chat, + id: HelpMenuItems.CHAT_WITH_SUPPORT, + label: 'Chat with support', + componentType: 'anchor', + href: DISCORD_LINK, + startIcon: { name: 'ic-chat-circle-online' }, }, + { + id: HelpMenuItems.RAISE_ISSUE_REQUEST, + label: 'Raise an issue/request', + startIcon: { name: 'ic-file-edit' }, + componentType: 'anchor', + href: RAISE_ISSUE, + }, +] +export const ENTERPRISE_TRIAL_HELP_ACTION_MENU_ITEMS: HelpButtonActionMenuProps['options'][number]['items'] = [ { - name: 'Raise an issue/request', - link: RAISE_ISSUE, - icon: EditFile, + id: HelpMenuItems.REQUEST_SUPPORT, + label: 'Request Support', + startIcon: { name: 'ic-file-edit' }, + componentType: 'anchor', + href: CONTACT_SUPPORT_LINK, }, ] -export const TrialHelpOptions: HelpOptionType[] = [ +export const ENTERPRISE_HELP_ACTION_MENU_ITEMS: HelpButtonActionMenuProps['options'][number]['items'] = [ + { + id: HelpMenuItems.OPEN_NEW_TICKET, + label: 'Open new ticket', + startIcon: { name: 'ic-edit' }, + componentType: 'anchor', + href: OPEN_NEW_TICKET, + }, + { + id: HelpMenuItems.VIEW_ALL_TICKETS, + label: 'View all tickets', + startIcon: { name: 'ic-files' }, + componentType: 'anchor', + href: VIEW_ALL_TICKETS, + }, { - name: 'Request Support', - link: CONTACT_SUPPORT_LINK, - icon: EditFile, + id: HelpMenuItems.GIVE_FEEDBACK, + label: 'Give feedback', + startIcon: { name: 'ic-megaphone-right' }, }, ] diff --git a/src/Shared/Components/Header/types.ts b/src/Shared/Components/Header/types.ts index 51a12814e..0dc15f387 100644 --- a/src/Shared/Components/Header/types.ts +++ b/src/Shared/Components/Header/types.ts @@ -17,6 +17,7 @@ import { ModuleStatus } from '@Shared/types' import { ResponseType, TippyCustomizedProps } from '../../../Common' +import { ActionMenuProps } from '../ActionMenu' export enum InstallationType { OSS_KUBECTL = 'oss_kubectl', @@ -55,17 +56,23 @@ export interface ServerInfoResponse extends ResponseType { result?: ServerInfo } -export interface HelpNavType { - className: string - setShowHelpCard: React.Dispatch> +export interface HelpButtonProps { serverInfo: ServerInfo fetchingServerInfo: boolean - setGettingStartedClicked: (isClicked: boolean) => void - showHelpCard: boolean + onClick: () => void } -export interface HelpOptionType { - name: string - link: string - icon: React.FunctionComponent> +export enum HelpMenuItems { + GETTING_STARTED = 'getting-started', + VIEW_DOCUMENTATION = 'view-documentation', + JOIN_DISCORD_COMMUNITY = 'join-discord-community', + ABOUT_DEVTRON = 'about-devtron', + REQUEST_SUPPORT = 'request-support', + OPEN_NEW_TICKET = 'open-new-ticket', + VIEW_ALL_TICKETS = 'view-all-tickets', + GIVE_FEEDBACK = 'give-feedback', + CHAT_WITH_SUPPORT = 'chat-with-support', + RAISE_ISSUE_REQUEST = 'raise-issue-request', } + +export type HelpButtonActionMenuProps = ActionMenuProps diff --git a/src/Shared/Components/Header/utils.ts b/src/Shared/Components/Header/utils.ts index feebdc56a..769041b14 100644 --- a/src/Shared/Components/Header/utils.ts +++ b/src/Shared/Components/Header/utils.ts @@ -14,13 +14,17 @@ * limitations under the License. */ -import { ReactComponent as Discord } from '@Icons/ic-discord-fill.svg' -import { ReactComponent as File } from '@Icons/ic-file-text.svg' +import { LOGIN_COUNT } from '@Common/Constants' -import { DISCORD_LINK, DOCUMENTATION_HOME_PAGE, LOGIN_COUNT } from '../../../Common' import { DevtronLicenseInfo, LicenseStatus } from '../License' -import { EnterpriseHelpOptions, OSSHelpOptions, TrialHelpOptions } from './constants' +import { + COMMON_HELP_ACTION_MENU_ITEMS, + ENTERPRISE_HELP_ACTION_MENU_ITEMS, + ENTERPRISE_TRIAL_HELP_ACTION_MENU_ITEMS, + OSS_HELP_ACTION_MENU_ITEMS, +} from './constants' import { updatePostHogEvent } from './service' +import { HelpButtonActionMenuProps } from './types' const millisecondsInDay = 86400000 export const getDateInMilliseconds = (days) => 1 + new Date().valueOf() + (days ?? 0) * millisecondsInDay @@ -42,27 +46,26 @@ export const setActionWithExpiry = (key: string, days: number): void => { export const getIsShowingLicenseData = (licenseData: DevtronLicenseInfo) => licenseData && (licenseData.licenseStatus !== LicenseStatus.ACTIVE || licenseData.isTrial) -const getInstallationSpecificHelpOptions = (isEnterprise: boolean, isTrial: boolean) => { - if (isEnterprise) { - return isTrial ? TrialHelpOptions : EnterpriseHelpOptions - } - return OSSHelpOptions -} - -export const getHelpOptions = (isEnterprise: boolean, isTrial: boolean) => { - const HelpOptions = getInstallationSpecificHelpOptions(isEnterprise, isTrial) - return [ - { - name: 'View documentation', - link: DOCUMENTATION_HOME_PAGE, - icon: File, - }, - - { - name: 'Join discord community', - link: DISCORD_LINK, - icon: Discord, - }, - ...HelpOptions, - ] -} +export const getHelpActionMenuOptions = ({ + isEnterprise, + isTrial, +}: { + isEnterprise: boolean + isTrial: boolean +}): HelpButtonActionMenuProps['options'] => [ + { + items: COMMON_HELP_ACTION_MENU_ITEMS, + }, + ...(isEnterprise + ? [ + { + groupLabel: 'Enterprise Support', + items: isTrial ? ENTERPRISE_TRIAL_HELP_ACTION_MENU_ITEMS : ENTERPRISE_HELP_ACTION_MENU_ITEMS, + }, + ] + : [ + { + items: OSS_HELP_ACTION_MENU_ITEMS, + }, + ]), +] diff --git a/src/Shared/Components/Icon/Icon.tsx b/src/Shared/Components/Icon/Icon.tsx index d4f40a33c..c3953b9fa 100644 --- a/src/Shared/Components/Icon/Icon.tsx +++ b/src/Shared/Components/Icon/Icon.tsx @@ -27,6 +27,7 @@ import { ReactComponent as ICCaretLeft } from '@IconsV2/ic-caret-left.svg' import { ReactComponent as ICCaretRight } from '@IconsV2/ic-caret-right.svg' import { ReactComponent as ICCd } from '@IconsV2/ic-cd.svg' import { ReactComponent as ICChatCircleDots } from '@IconsV2/ic-chat-circle-dots.svg' +import { ReactComponent as ICChatCircleOnline } from '@IconsV2/ic-chat-circle-online.svg' import { ReactComponent as ICCheck } from '@IconsV2/ic-check.svg' import { ReactComponent as ICChecks } from '@IconsV2/ic-checks.svg' import { ReactComponent as ICCiLinked } from '@IconsV2/ic-ci-linked.svg' @@ -51,8 +52,10 @@ import { ReactComponent as ICDelhivery } from '@IconsV2/ic-delhivery.svg' import { ReactComponent as ICDevtron } from '@IconsV2/ic-devtron.svg' import { ReactComponent as ICDevtronHeaderLogo } from '@IconsV2/ic-devtron-header-logo.svg' import { ReactComponent as ICDisconnect } from '@IconsV2/ic-disconnect.svg' +import { ReactComponent as ICDiscordFill } from '@IconsV2/ic-discord-fill.svg' import { ReactComponent as ICDockerhub } from '@IconsV2/ic-dockerhub.svg' import { ReactComponent as ICEcr } from '@IconsV2/ic-ecr.svg' +import { ReactComponent as ICEdit } from '@IconsV2/ic-edit.svg' import { ReactComponent as ICEnterpriseFeat } from '@IconsV2/ic-enterprise-feat.svg' import { ReactComponent as ICEnterpriseTag } from '@IconsV2/ic-enterprise-tag.svg' import { ReactComponent as ICEnv } from '@IconsV2/ic-env.svg' @@ -60,7 +63,10 @@ import { ReactComponent as ICError } from '@IconsV2/ic-error.svg' import { ReactComponent as ICExpandRightSm } from '@IconsV2/ic-expand-right-sm.svg' import { ReactComponent as ICExpandSm } from '@IconsV2/ic-expand-sm.svg' import { ReactComponent as ICFailure } from '@IconsV2/ic-failure.svg' +import { ReactComponent as ICFile } from '@IconsV2/ic-file.svg' +import { ReactComponent as ICFileEdit } from '@IconsV2/ic-file-edit.svg' import { ReactComponent as ICFileKey } from '@IconsV2/ic-file-key.svg' +import { ReactComponent as ICFiles } from '@IconsV2/ic-files.svg' import { ReactComponent as ICFolderUser } from '@IconsV2/ic-folder-user.svg' import { ReactComponent as ICGear } from '@IconsV2/ic-gear.svg' import { ReactComponent as ICGift } from '@IconsV2/ic-gift.svg' @@ -99,6 +105,7 @@ import { ReactComponent as ICLogout } from '@IconsV2/ic-logout.svg' import { ReactComponent as ICMediumDelete } from '@IconsV2/ic-medium-delete.svg' import { ReactComponent as ICMediumPaintbucket } from '@IconsV2/ic-medium-paintbucket.svg' import { ReactComponent as ICMegaphoneLeft } from '@IconsV2/ic-megaphone-left.svg' +import { ReactComponent as ICMegaphoneRight } from '@IconsV2/ic-megaphone-right.svg' import { ReactComponent as ICMemory } from '@IconsV2/ic-memory.svg' import { ReactComponent as ICMicrosoft } from '@IconsV2/ic-microsoft.svg' import { ReactComponent as ICMinikube } from '@IconsV2/ic-minikube.svg' @@ -113,6 +120,7 @@ import { ReactComponent as ICOpenInNew } from '@IconsV2/ic-open-in-new.svg' import { ReactComponent as ICOpenshift } from '@IconsV2/ic-openshift.svg' import { ReactComponent as ICOutOfSync } from '@IconsV2/ic-out-of-sync.svg' import { ReactComponent as ICPaperPlaneColor } from '@IconsV2/ic-paper-plane-color.svg' +import { ReactComponent as ICPath } from '@IconsV2/ic-path.svg' import { ReactComponent as ICPencil } from '@IconsV2/ic-pencil.svg' import { ReactComponent as ICQuay } from '@IconsV2/ic-quay.svg' import { ReactComponent as ICQuote } from '@IconsV2/ic-quote.svg' @@ -176,6 +184,7 @@ export const iconMap = { 'ic-caret-right': ICCaretRight, 'ic-cd': ICCd, 'ic-chat-circle-dots': ICChatCircleDots, + 'ic-chat-circle-online': ICChatCircleOnline, 'ic-check': ICCheck, 'ic-checks': ICChecks, 'ic-ci-linked': ICCiLinked, @@ -200,8 +209,10 @@ export const iconMap = { 'ic-devtron-header-logo': ICDevtronHeaderLogo, 'ic-devtron': ICDevtron, 'ic-disconnect': ICDisconnect, + 'ic-discord-fill': ICDiscordFill, 'ic-dockerhub': ICDockerhub, 'ic-ecr': ICEcr, + 'ic-edit': ICEdit, 'ic-enterprise-feat': ICEnterpriseFeat, 'ic-enterprise-tag': ICEnterpriseTag, 'ic-env': ICEnv, @@ -209,7 +220,10 @@ export const iconMap = { 'ic-expand-right-sm': ICExpandRightSm, 'ic-expand-sm': ICExpandSm, 'ic-failure': ICFailure, + 'ic-file-edit': ICFileEdit, 'ic-file-key': ICFileKey, + 'ic-file': ICFile, + 'ic-files': ICFiles, 'ic-folder-user': ICFolderUser, 'ic-gear': ICGear, 'ic-gift-gradient': ICGiftGradient, @@ -248,6 +262,7 @@ export const iconMap = { 'ic-medium-delete': ICMediumDelete, 'ic-medium-paintbucket': ICMediumPaintbucket, 'ic-megaphone-left': ICMegaphoneLeft, + 'ic-megaphone-right': ICMegaphoneRight, 'ic-memory': ICMemory, 'ic-microsoft': ICMicrosoft, 'ic-minikube': ICMinikube, @@ -262,6 +277,7 @@ export const iconMap = { 'ic-openshift': ICOpenshift, 'ic-out-of-sync': ICOutOfSync, 'ic-paper-plane-color': ICPaperPlaneColor, + 'ic-path': ICPath, 'ic-pencil': ICPencil, 'ic-quay': ICQuay, 'ic-quote': ICQuote, diff --git a/src/Shared/Components/Icon/IconBase.tsx b/src/Shared/Components/Icon/IconBase.tsx index 03786a21b..a42bdce0b 100644 --- a/src/Shared/Components/Icon/IconBase.tsx +++ b/src/Shared/Components/Icon/IconBase.tsx @@ -16,6 +16,7 @@ import { ConditionalWrap } from '@Common/Helper' import { Tooltip } from '@Common/Tooltip' +import { isNullOrUndefined } from '@Shared/Helpers' import { ICON_STROKE_WIDTH_MAP } from './constants' import { IconBaseProps } from './types' @@ -28,7 +29,16 @@ const conditionalWrap = (tooltipProps: IconBaseProps['tooltipProps']) => (childr ) -export const IconBase = ({ name, iconMap, size = 16, tooltipProps, color }: IconBaseProps) => { +export const IconBase = ({ + name, + iconMap, + size = 16, + tooltipProps, + color, + dataTestId, + rotateBy, + fillSpace = false, +}: IconBaseProps) => { const IconComponent = iconMap[name] if (!IconComponent) { @@ -38,12 +48,14 @@ export const IconBase = ({ name, iconMap, size = 16, tooltipProps, color }: Icon return ( diff --git a/src/Shared/Components/Icon/types.ts b/src/Shared/Components/Icon/types.ts index 72262f719..bfcf0be32 100644 --- a/src/Shared/Components/Icon/types.ts +++ b/src/Shared/Components/Icon/types.ts @@ -22,21 +22,43 @@ import { IconBaseColorType, IconBaseSizeType } from '@Shared/index' type IconMap = Record>> export interface IconBaseProps { - /** The name of the icon to render. */ + /** + * The name of the icon to render. + */ name: keyof IconMap - /** The map containing all available icons. */ + /** + * A map containing all available icons. + */ iconMap: IconMap /** - * The size of the icon in pixels. + * The size of the icon in pixels. If not provided, the default size is `16px`. + * * @default 16 */ size?: IconBaseSizeType | null - /** Props to configure the tooltip when hovering over the icon. */ + /** + * Configuration for the tooltip displayed when hovering over the icon. + */ tooltipProps?: TooltipProps /** - * The color of the icon (color tokens). \ - * If `null`, the default color present in icon is used. - * @example `'B500'`, `'N200'`, `'G50'`, `'R700'` + * The color of the icon, specified using predefined color tokens. + * If set to `null`, the icon's default color will be used. + * + * @example 'B500', 'N200', 'G50', 'R700' */ color: IconBaseColorType + /** + * A unique identifier for testing purposes, typically used in test automation. + */ + dataTestId?: string + /** + * Rotates the icon by the specified number of degrees. + * + * @example 90, 180, 270 + */ + rotateBy?: number + /** + * If true, the icon will expand to fill the available space of its container. + */ + fillSpace?: boolean } diff --git a/src/Shared/Components/Popover/Popover.component.tsx b/src/Shared/Components/Popover/Popover.component.tsx new file mode 100644 index 000000000..68640c840 --- /dev/null +++ b/src/Shared/Components/Popover/Popover.component.tsx @@ -0,0 +1,38 @@ +import { AnimatePresence, motion } from 'framer-motion' + +import { Button } from '../Button' +import { PopoverProps } from './types' + +import './popover.scss' + +/** + * Popover Component \ + * This component serves as a base for creating popovers. It is not intended to be used directly. + * @note Use this component in conjunction with the `usePopover` hook to create a custom popover component. \ + * For example, see the `ActionMenu` component for reference. + */ +export const Popover = ({ + open, + popoverProps, + overlayProps, + triggerProps, + buttonProps, + triggerElement, + children, +}: PopoverProps) => ( +
    +
    {triggerElement ||
    + + + {open && ( + <> + {/* Overlay to block interactions with the background */} +
    + + {children} + + + )} + +
    +) diff --git a/src/Shared/Components/Popover/index.ts b/src/Shared/Components/Popover/index.ts new file mode 100644 index 000000000..76a40223d --- /dev/null +++ b/src/Shared/Components/Popover/index.ts @@ -0,0 +1,3 @@ +export * from './Popover.component' +export * from './types' +export * from './usePopover.hook' diff --git a/src/Shared/Components/Popover/popover.scss b/src/Shared/Components/Popover/popover.scss new file mode 100644 index 000000000..f241ad180 --- /dev/null +++ b/src/Shared/Components/Popover/popover.scss @@ -0,0 +1,12 @@ +.popover-overlay { + position: fixed; + top:0; + right:0; + bottom:0; + left:0; + z-index: var(--modal-index); +} + +.popover-content { + z-index: var(--modal-index); +} diff --git a/src/Shared/Components/Popover/types.ts b/src/Shared/Components/Popover/types.ts new file mode 100644 index 000000000..5b1fdaac8 --- /dev/null +++ b/src/Shared/Components/Popover/types.ts @@ -0,0 +1,107 @@ +import { DetailedHTMLProps, KeyboardEvent, MutableRefObject, ReactElement } from 'react' +import { HTMLMotionProps } from 'framer-motion' + +import { ButtonProps } from '../Button' + +export interface UsePopoverProps { + /** + * A unique identifier for the popover. + */ + id: string + /** + * The width of the popover. + * Can be a number representing the width in pixels or the string `'auto'` for automatic sizing (up to 250px). + * @default 'auto' + */ + width?: number | 'auto' + /** + * The position of the popover relative to its trigger element. + * Possible values are: + * - `'bottom'`: Positions the popover below the trigger. + * - `'top'`: Positions the popover above the trigger. + * - `'left'`: Positions the popover to the left of the trigger. + * - `'right'`: Positions the popover to the right of the trigger. + * @default 'bottom' + */ + position?: 'bottom' | 'top' | 'left' | 'right' + /** + * The alignment of the popover relative to its trigger element. + * Possible values are: + * - `'start'`: Aligns the popover to the start of the trigger. + * - `'middle'`: Aligns the popover to the center of the trigger. + * - `'end'`: Aligns the popover to the end of the trigger. + * @default 'start' + */ + alignment?: 'start' | 'middle' | 'end' + /** + * Callback function triggered when the popover is opened or closed. + * @param open - A boolean indicating whether the popover is open. + */ + onOpen?: (open: boolean) => void + /** + * Callback function triggered when a key is pressed while the trigger element is focused. + * @param e - The keyboard event. + * @param openState - A boolean indicating whether the popover is currently open. + * @param closePopover - A function to close the popover. + */ + onTriggerKeyDown?: (e: KeyboardEvent, openState: boolean, closePopover: () => void) => void + /** + * Callback function triggered when a key is pressed while the popover is focused. + * @param e - The keyboard event. + * @param openState - A boolean indicating whether the popover is currently open. + * @param closePopover - A function to close the popover. + */ + onPopoverKeyDown?: (e: KeyboardEvent, openState: boolean, closePopover: () => void) => void +} + +/** + * Represents the return type of the `usePopover` hook, providing properties and methods + * to manage and interact with a popover component. + */ +export interface UsePopoverReturnType { + /** + * Indicates whether the popover is currently open. + */ + open: boolean + /** + * Props to be spread onto the trigger element that opens the popover. + * These props include standard HTML attributes for a `div` element. + */ + triggerProps: DetailedHTMLProps, HTMLDivElement> + /** + * Props to be spread onto the overlay element of the popover. + * These props include standard HTML attributes for a `div` element. + */ + overlayProps: DetailedHTMLProps, HTMLDivElement> + /** + * Props to be spread onto the popover element itself. + * Includes motion-related props for animations and a `ref` to the popover's `div` element. + */ + popoverProps: HTMLMotionProps<'div'> & { ref: MutableRefObject } + /** + * A mutable reference to the scrollable element inside the popover. \ + * This reference should be assigned to the element that is scrollable. + */ + scrollableRef: MutableRefObject + /** + * A function to close the popover. + */ + closePopover: () => void +} + +export type PopoverProps = Pick & { + /** + * Popover contents. + * This can be any React element or JSX to render inside the popover. + */ + children: ReactElement + /** + * Properties for the button to which the Popover is attached. + */ + buttonProps: ButtonProps | null + /** + * The React element to which the Popover is attached. + * @note only use when `triggerElement` is not `Button` component otherwise use `buttonProps`. + */ + triggerElement: ReactElement | null +} diff --git a/src/Shared/Components/Popover/usePopover.hook.ts b/src/Shared/Components/Popover/usePopover.hook.ts new file mode 100644 index 000000000..d95ff93b2 --- /dev/null +++ b/src/Shared/Components/Popover/usePopover.hook.ts @@ -0,0 +1,126 @@ +import { useLayoutEffect, useRef, useState } from 'react' + +import { UsePopoverProps, UsePopoverReturnType } from './types' +import { + getPopoverActualPositionAlignment, + getPopoverAlignmentStyle, + getPopoverFramerProps, + getPopoverPositionStyle, +} from './utils' + +export const usePopover = ({ + id, + position = 'bottom', + alignment = 'start', + width = 'auto', + onOpen, + onPopoverKeyDown, + onTriggerKeyDown, +}: UsePopoverProps): UsePopoverReturnType => { + // STATES + const [open, setOpen] = useState(false) + const [actualPosition, setActualPosition] = useState(position) + const [actualAlignment, setActualAlignment] = useState(alignment) + + // CONSTANTS + const isAutoWidth = width === 'auto' + + // REFS + const triggerRef = useRef(null) + const popover = useRef(null) + const scrollableRef = useRef(null) + + // HANDLERS + const updateOpenState = (openState: typeof open) => { + setOpen(openState) + onOpen?.(openState) + } + + const togglePopover = () => updateOpenState(!open) + + const closePopover = () => updateOpenState(false) + + const handleTriggerKeyDown = (e: React.KeyboardEvent) => { + if (!open && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + updateOpenState(true) + } + + onTriggerKeyDown?.(e, open, closePopover) + } + + const handlePopoverKeyDown = (e: React.KeyboardEvent) => onPopoverKeyDown(e, open, closePopover) + + useLayoutEffect(() => { + if (!open || !triggerRef.current || !popover.current || !scrollableRef.current) { + return + } + + const triggerRect = triggerRef.current.getBoundingClientRect() + const popoverRect = popover.current.getBoundingClientRect() + + const { fallbackPosition, fallbackAlignment } = getPopoverActualPositionAlignment({ + position, + alignment, + triggerRect, + popoverRect, + }) + + setActualPosition(fallbackPosition) + setActualAlignment(fallbackAlignment) + + // prevent scroll propagation unless scrollable + const handleWheel = (e: WheelEvent) => { + e.stopPropagation() + + const atTop = scrollableRef.current.scrollTop === 0 && e.deltaY < 0 + const atBottom = + scrollableRef.current.scrollHeight - scrollableRef.current.clientHeight === + scrollableRef.current.scrollTop && e.deltaY > 0 + + if (atTop || atBottom) { + e.preventDefault() + } + } + + scrollableRef.current.addEventListener('wheel', handleWheel, { passive: false }) + // eslint-disable-next-line consistent-return + return () => { + scrollableRef.current.removeEventListener('wheel', handleWheel) + } + }, [open, position, alignment]) + + return { + open, + triggerProps: { + role: 'button', + ref: triggerRef, + onClick: togglePopover, + onKeyDown: handleTriggerKeyDown, + 'aria-haspopup': 'listbox', + 'aria-expanded': open, + tabIndex: 0, + }, + overlayProps: { + role: 'dialog', + onClick: closePopover, + className: 'popover-overlay', + }, + popoverProps: { + id, + ref: popover, + role: 'listbox', + className: `popover-content dc__position-abs bg__menu--primary shadow__menu border__primary br-6 dc__overflow-hidden ${isAutoWidth ? 'dc_width-max-content dc__mxw-250' : ''}`, + onKeyDown: handlePopoverKeyDown, + style: { + width: !isAutoWidth ? `${width}px` : undefined, + ...getPopoverPositionStyle({ position: actualPosition }), + ...getPopoverAlignmentStyle({ position: actualPosition, alignment: actualAlignment }), + }, + ...getPopoverFramerProps({ position: actualPosition, alignment: actualAlignment }), + transition: { duration: 0.2 }, + }, + scrollableRef, + closePopover, + } +} diff --git a/src/Shared/Components/Popover/utils.ts b/src/Shared/Components/Popover/utils.ts new file mode 100644 index 000000000..c1d6f3928 --- /dev/null +++ b/src/Shared/Components/Popover/utils.ts @@ -0,0 +1,119 @@ +import { HTMLMotionProps } from 'framer-motion' + +import { UsePopoverProps } from './types' + +export const getPopoverAlignmentStyle = ({ position, alignment }: Pick) => { + if (position === 'top' || position === 'bottom') { + switch (alignment) { + case 'end': + return { right: 0 } + case 'middle': + return { left: '50%' } + case 'start': + default: + return { left: 0 } + } + } + + switch (alignment) { + case 'end': + return { bottom: 0 } + case 'middle': + return { top: '50%' } + case 'start': + default: + return { top: 0 } + } +} + +export const getPopoverPositionStyle = ({ position }: Pick) => { + switch (position) { + case 'top': + return { bottom: '100%', marginBottom: 6 } + case 'left': + return { right: '100%', marginRight: 6 } + case 'right': + return { left: '100%', marginLeft: 6 } + case 'bottom': + default: + return { top: '100%', marginTop: 6 } + } +} + +const getPopoverAnimationProps = (axisKey: 'x' | 'y', axisInitialValue: number, isMiddleAlignment: boolean) => + ({ + initial: { opacity: 0, [axisKey]: axisInitialValue }, + animate: { opacity: 1, [axisKey]: 0 }, + exit: { opacity: 0, [axisKey]: axisInitialValue }, + transformTemplate: (isMiddleAlignment + ? (params) => + axisKey === 'y' ? `translate(-50%, ${params[axisKey]})` : `translate(${params[axisKey]}, -50%,)` + : undefined) as HTMLMotionProps<'div'>['transformTemplate'], + }) satisfies HTMLMotionProps<'div'> + +export const getPopoverFramerProps = ({ position, alignment }: Pick) => { + const isMiddleAlignment = alignment === 'middle' + + if (position === 'top' || position === 'bottom') { + const initialY = position === 'bottom' ? -12 : 12 + return getPopoverAnimationProps('y', initialY, isMiddleAlignment) + } + + const initialX = position === 'right' ? -12 : 12 + return getPopoverAnimationProps('x', initialX, isMiddleAlignment) +} + +export const getPopoverActualPositionAlignment = ({ + position, + alignment, + triggerRect, + popoverRect, +}: Pick & { triggerRect: DOMRect; popoverRect: DOMRect }) => { + const space = { + top: triggerRect.top, + bottom: window.innerHeight - triggerRect.bottom, + left: triggerRect.left, + right: window.innerWidth - triggerRect.right, + } + + const fits = { + top: popoverRect.height <= space.top, + bottom: popoverRect.height <= space.bottom, + left: popoverRect.width <= space.left, + right: popoverRect.width <= space.right, + } + + const fallbackPosition = + (fits[position] && position) || + (fits.bottom && 'bottom') || + (fits.top && 'top') || + (fits.right && 'right') || + (fits.left && 'left') || + position + + const fitsAlign = + fallbackPosition === 'top' || fallbackPosition === 'bottom' + ? { + start: triggerRect.left + popoverRect.width <= window.innerWidth, + middle: + triggerRect.left + triggerRect.width / 2 - popoverRect.width / 2 >= 0 && + triggerRect.left + triggerRect.width / 2 + popoverRect.width / 2 <= window.innerWidth, + end: triggerRect.right - popoverRect.width >= 0, + } + : { + start: triggerRect.top + popoverRect.height <= window.innerHeight, + middle: + triggerRect.top + triggerRect.height / 2 - popoverRect.height / 2 >= 0 && + triggerRect.top + triggerRect.height / 2 + popoverRect.height / 2 <= window.innerHeight, + end: triggerRect.bottom - popoverRect.height >= 0, + } + + const fallbackAlignment = + (fitsAlign[alignment] && alignment) || + (fitsAlign.start && 'start') || + (fitsAlign.middle && 'middle') || + (fitsAlign.end && 'end') || + alignment + + return { fallbackPosition, fallbackAlignment } +} diff --git a/src/Shared/Components/SelectPicker/common.tsx b/src/Shared/Components/SelectPicker/common.tsx index 1f64d8905..772593823 100644 --- a/src/Shared/Components/SelectPicker/common.tsx +++ b/src/Shared/Components/SelectPicker/common.tsx @@ -259,7 +259,7 @@ export const SelectPickerOption = ({ ) } -const SelectPickerMenuListFooter = ({ +export const SelectPickerMenuListFooter = ({ menuListFooterConfig, }: Required>) => { if (!menuListFooterConfig) { @@ -294,6 +294,12 @@ const SelectPickerMenuListFooter = ({ ) } + if (type === 'customNode') { + const { value } = menuListFooterConfig + + return value + } + return null } diff --git a/src/Shared/Components/SelectPicker/type.ts b/src/Shared/Components/SelectPicker/type.ts index 3ef481f64..2ef42c294 100644 --- a/src/Shared/Components/SelectPicker/type.ts +++ b/src/Shared/Components/SelectPicker/type.ts @@ -73,6 +73,11 @@ type MenuListFooterConfigType = variant: ButtonVariantType.primary | ButtonVariantType.borderLess } & Omit, 'size' | 'fullWidth' | 'icon' | 'endIcon' | 'variant' | 'style'> } + | { + type: 'customNode' + value: ReactElement + buttonProps?: never + } declare module 'react-select/base' { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/vite.config.ts b/vite.config.ts index e62fa6155..bbf0c0665 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -88,7 +88,7 @@ export default defineConfig({ return '@vendor' } - if (id.includes('codemirror') || id.includes('src/Common/CodeMirror')) { + if (id.match('codemirror') || id.includes('src/Shared/Components/CodeEditor')) { return '@code-editor' }